Show button controls on companion home and message screens

This commit is contained in:
mikecarper committed 2026-09-08 17:11:43 -07:00
1 parent ac694d536e
commit 74d87c8d13
6 files changed
+265 -24

No files matched your search

+23 -5
View File
@@ -16,13 +16,30 @@ letters taking less space and `M` and `W` taking 6 pixels. Squeezed Regular 6
is a [public-domain font by Oliver Kraus](https://github.com/olikraus/u8g2/wiki/fntgrpu8g#squeezed_r6).
Its bitmap data is stored as constants without heap allocation.
On a 128 x 64 OLED, the compact channel/sender line starts at y=14 and message
text at y=22. Five complete 6px-font rows fit. The software comparison fits
three representative 160-character messages in those five rows. Capacity
On a 128 x 64 OLED with the single-button message reader, the compact
channel/sender line starts at y=8 and message text at y=16. The header uses
the same small font. Five complete 6px-font rows fit above the navigation
hint at y=56. The software comparison fits three representative 160-character
messages in those five rows. Capacity
depends on the characters; messages with many wide letters can still overflow.
Text wraps at character boundaries, with `...` on the last line if necessary.
Unsupported characters appear as `?`. Long channel/sender names are ellipsized
to stay on their own line. An enabled channel footer reserves its own space.
Readers without the button hint retain the y=14 origin and y=22 message text.
On V4 and other single-button builds using this reader, the home screen says
`hold button: inbox`. Hold the user button for about 1.2 seconds to open it.
The bottom of the reader shows `<- 2tap 1tap -> long press:exit`: double tap
the button for the previous message, tap once for the next, and hold to
return home. Advancing past the last message also returns home. The hint
appears even when the inbox is empty and reflows onto additional lines on
narrower screens. It replaces the unusable channel selector on single-button
builds. These are button taps. Touchscreen and joystick builds retain
instructions appropriate to their controls. During the first eight
seconds after startup, holding the button on an ordinary home page enters
CLI rescue instead; wait for that startup window to finish before opening
the inbox. Exiting a message preview and the WiFi setup page's hold action
remain available immediately.
Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi
setup QR codes keep their normal layout. Small TFTs retain their existing
@@ -80,8 +97,9 @@ pio test -e native -f test_display_driver -f test_companion_message_history
The native tests exercise both capital heights, automatic font selection,
160-character messages, complete rows at display edges, long sender lines,
reserved footers, overflow markers and tiny/rotated screen geometry. The
additional rendering comparison checks all 95 printable ASCII glyphs in each
navigation hints, reserved footers, overflow markers and tiny/rotated screen
geometry. The additional rendering comparison checks all 95 printable ASCII
glyphs in each
font, under address/undefined-behavior sanitizers. Picopixel is compared pixel
for pixel with Adafruit GFX; Squeezed Regular 6 is compared with the upstream
BDF fixture independently of the converted C++ tables. This needs a cached
+78 -18
View File
@@ -3,6 +3,8 @@
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/CompanionMessageHistory.h>
#include <helpers/ui/ReaderNavigationHint.h>
#include <helpers/ui/DisplayTextLayout.h>
#if UI_SMALL_MESSAGE_FONT == 1
#include <helpers/ui/SmallMessageText.h>
#endif
@@ -72,10 +74,22 @@ static uint64_t companionMessageElapsedMillis(uint64_t heard_millis) {
#define UI_RECENT_LIST_SIZE 4
#endif
#if UI_HAS_JOYSTICK || UI_HAS_ROTARY_INPUT
#ifdef HAS_TOUCH
#define PRESS_LABEL "tap center"
#define SELECT_LABEL "TAP"
#elif UI_HAS_JOYSTICK || defined(UI_HAS_NAV_INPUT)
#define PRESS_LABEL "press Enter"
#define SELECT_LABEL "ENTER"
#else
#define PRESS_LABEL "long press"
#define PRESS_LABEL "hold button"
#define SELECT_LABEL "HOLD"
#endif
#if !defined(HAS_TOUCH) && !UI_HAS_JOYSTICK && !defined(UI_HAS_NAV_INPUT) \
&& (defined(PIN_USER_BTN) || defined(PIN_USER_BTN_ANA))
#define UI_BUTTON_READER_HINT 1
#else
#define UI_BUTTON_READER_HINT 0
#endif
#ifdef COMPANION_EXCLUSIVE_WIFI_BLE
@@ -278,7 +292,7 @@ static void drawCompanionWiFiSetupPage(DisplayDriver& display) {
display.drawTextCentered(display.width() / 2, 45, "WIFI SETUP");
display.setTextSize(2);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 90, "TAP TO START");
display.drawTextCentered(display.width() / 2, 90, SELECT_LABEL " TO START");
} else {
display.setTextSize(3);
display.drawTextCentered(display.width() / 2, 45, "WIFI");
@@ -657,7 +671,7 @@ public:
display.setColor(UIColor::secondary_txt);
mesh::ui::drawTextCenteredEllipsized(
display, layout.info, layout.instruction_y,
"tap center: inbox");
PRESS_LABEL ": inbox");
#ifdef WIFI_SSID
if (!isCompanionWiFiEnabled()) {
@@ -713,7 +727,7 @@ public:
display.setColor(UIColor::secondary_txt);
display.setTextSize(3);
mesh::ui::drawTextCenteredEllipsized(
display, layout.pairing, layout.pairing_label_y, "TAP");
display, layout.pairing, layout.pairing_label_y, SELECT_LABEL);
#ifdef WIFI_SSID
if (!isCompanionWiFiEnabled()) {
strcpy(tmp, "OFF");
@@ -776,7 +790,7 @@ public:
display.setTextSize(1);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 43,
"tap center: inbox");
PRESS_LABEL ": inbox");
#ifdef UI_SHOW_CLOCK
display.setTextSize(3);
@@ -1206,8 +1220,10 @@ public:
#ifndef UI_COMPACT_MESSAGE_STATUS
#define UI_COMPACT_MESSAGE_STATUS 0
#endif
// Single-button readers cannot operate the up/down channel selector. Use
// their footer space for the available navigation controls instead.
#ifndef UI_MESSAGE_CHANNEL_FOOTER
#if defined(MESHCORE_HAS_SMALL_DISPLAY)
#if defined(MESHCORE_HAS_SMALL_DISPLAY) || UI_BUTTON_READER_HINT == 1
#define UI_MESSAGE_CHANNEL_FOOTER 0
#else
#define UI_MESSAGE_CHANNEL_FOOTER 1
@@ -1427,19 +1443,42 @@ public:
}
int render(DisplayDriver& display) override {
const mesh::ui::CompanionMessageChromeLayout layout =
mesh::ui::CompanionMessageChromeLayout layout =
mesh::ui::makeCompanionMessageChromeLayout(
UI_COMPACT_MESSAGE_STATUS == 1);
int body_bottom = UI_MESSAGE_CHANNEL_FOOTER == 1
? display.height() - layout.filter_height : display.height();
display.setCompactText(layout.compact_text);
display.setTextSize(1);
#if UI_BUTTON_READER_HINT == 1
#if UI_SMALL_MESSAGE_FONT == 1
mesh::ui::SmallMessageText reader_text(display);
// Eight-pixel rows for the header and origin leave five message rows and
// a navigation row on a 128x64 V4. Keep the 5px font on tiny panels.
const int hint_line_height = reader_text.lineHeight();
layout.header_divider_y = hint_line_height - 1;
layout.origin_y = hint_line_height;
layout.message_y = 2 * hint_line_height;
#else
DisplayDriver& reader_text = display;
const int hint_line_height = layout.message_y - layout.origin_y;
#endif
const mesh::ui::ButtonReaderHintLayout hint =
mesh::ui::makeButtonReaderHintLayout(
reader_text, hint_line_height, body_bottom);
body_bottom = hint.top;
DisplayDriver& header = reader_text;
#else
DisplayDriver& header = display;
#endif
char tmp[24];
int filtered_count = filteredCount();
if (view_offset >= filtered_count) view_offset = 0;
display.setCompactText(layout.compact_text);
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(UIColor::corp_blue);
header.setCursor(0, 0);
header.setColor(UIColor::corp_blue);
snprintf(tmp, sizeof(tmp), display.width() < 100 ? "%d/%d" : "Message %d/%d",
filtered_count == 0 ? 0 : view_offset + 1, filtered_count);
display.print(tmp);
header.print(tmp);
const MsgEntry* p = filteredEntry(view_offset);
@@ -1447,8 +1486,16 @@ public:
display.drawRect(0, layout.header_divider_y, display.width(), 1);
display.setCompactText(false);
display.setColor(UIColor::secondary_txt);
#if UI_BUTTON_READER_HINT == 1
display.setCompactText(layout.compact_text);
reader_text.drawTextEllipsized(0, layout.origin_y, display.width(),
"No buffered messages");
mesh::ui::drawButtonReaderHint(reader_text, hint);
display.setCompactText(false);
#else
display.drawTextCentered(display.width() / 2, 40,
"No buffered messages");
#endif
renderChannelFilter(display);
return 5000;
}
@@ -1459,17 +1506,15 @@ public:
char* suffix = strchr(tmp, ' ');
if (suffix != nullptr) *suffix = 0;
}
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
header.setCursor(display.width() - header.getTextWidth(tmp) - 2, 0);
header.print(tmp);
display.drawRect(0, layout.header_divider_y, display.width(), 1);
display.setCompactText(false);
#if UI_SMALL_MESSAGE_FONT == 1
mesh::ui::drawSmallMessageBody(display, p->origin, p->message,
layout.origin_y,
UI_MESSAGE_CHANNEL_FOOTER == 1
? display.height() - layout.filter_height : display.height());
layout.origin_y, body_bottom);
#else
display.setCursor(0, layout.origin_y);
display.setColor(UIColor::secondary_txt);
@@ -1481,9 +1526,21 @@ public:
display.setColor(UIColor::primary_txt);
char filtered_msg[sizeof(p->message)];
display.translateUTF8ToBlocks(filtered_msg, p->message, sizeof(filtered_msg));
#if UI_BUTTON_READER_HINT == 1
const int line_height = layout.message_y - layout.origin_y;
const int message_rows = (body_bottom - layout.message_y) / line_height;
mesh::ui::drawTextWrapped(display, 0, layout.message_y, display.width(),
line_height, message_rows, filtered_msg);
#else
display.printWordWrap(filtered_msg, display.width());
#endif
#endif
#if UI_BUTTON_READER_HINT == 1
display.setCompactText(layout.compact_text);
mesh::ui::drawButtonReaderHint(reader_text, hint);
display.setCompactText(false);
#endif
renderChannelFilter(display);
#if AUTO_OFF_MILLIS==0 // probably e-ink
@@ -2067,6 +2124,9 @@ char UITask::checkDisplayOn(char c) {
}
char UITask::handleLongPress(char c) {
// Exit an incoming message preview even during the startup rescue window.
// The on-screen reader hint must not unexpectedly enter CLI rescue.
if (curr == msg_preview) return c;
#if UI_WIFI_SETUP_HOME_PAGE == 1
// A setup AP may focus this page immediately at boot. Its documented HOLD
// action must win over the otherwise-global early-boot CLI rescue gesture.
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include "DisplayDriver.h"
namespace mesh {
namespace ui {
struct ButtonReaderHintLayout {
int top;
int line_height;
int line_count;
const char* lines[3];
};
// Match the single-button UI: double click is Previous, click is Next, and
// hold is Enter/exit. Measure with the reader's font, including tiny panels.
inline ButtonReaderHintLayout makeButtonReaderHintLayout(
DisplayDriver& text, int line_height, int bottom) {
ButtonReaderHintLayout layout = {
0, line_height, 1, {"<- 2tap 1tap -> long press:exit", nullptr, nullptr}};
if (text.getTextWidth(layout.lines[0]) > text.width()) {
layout.line_count = 2;
layout.lines[0] = "<- 2tap 1tap ->";
layout.lines[1] = "long press:exit";
if (text.getTextWidth(layout.lines[0]) > text.width()
|| text.getTextWidth(layout.lines[1]) > text.width()) {
layout.line_count = 3;
layout.lines[0] = "<- 2tap";
layout.lines[1] = "1tap ->";
layout.lines[2] = "hold:exit";
}
}
layout.top = bottom - layout.line_count * line_height;
if (layout.top < 0) layout.top = 0;
return layout;
}
inline void drawButtonReaderHint(DisplayDriver& text,
const ButtonReaderHintLayout& layout) {
text.setColor(UIColor::window_bkg);
text.fillRect(0, layout.top, text.width(),
layout.line_count * layout.line_height);
text.setColor(UIColor::secondary_txt);
for (int row = 0; row < layout.line_count; ++row) {
text.drawTextCentered(text.width() / 2,
layout.top + row * layout.line_height,
layout.lines[row]);
}
}
} // namespace ui
} // namespace mesh
@@ -4,6 +4,7 @@
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/SmallMessageText.h>
#include <helpers/ui/ReaderNavigationHint.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
#include <helpers/ui/WiFiSetupQrPayload.h>
@@ -233,6 +234,51 @@ TEST(SmallMessageText, MessageBodyRespectsReservedFooter) {
for (const auto& pixel : display.fills) EXPECT_LT(pixel.y, 56);
}
TEST(SmallMessageText, ButtonHintKeepsFiveMessageRowsWithoutOverlappingText) {
TestDisplay display(128, 64);
mesh::ui::SmallMessageText text(display);
const auto hint = mesh::ui::makeButtonReaderHintLayout(
text, text.lineHeight(), display.height());
ASSERT_EQ(1, hint.line_count);
EXPECT_EQ(56, hint.top);
EXPECT_EQ(5, text.lineCount(2 * text.lineHeight(), hint.top));
mesh::ui::drawSmallMessageBody(display, "Ch 0 Public [4h]:",
std::string(160, 'W').c_str(),
text.lineHeight(), hint.top);
ASSERT_FALSE(display.fills.empty());
for (const auto& pixel : display.fills) EXPECT_LT(pixel.y, hint.top);
display.fills.clear();
mesh::ui::drawButtonReaderHint(text, hint);
ASSERT_FALSE(display.fills.empty());
for (const auto& pixel : display.fills) {
EXPECT_GE(pixel.x, 0);
EXPECT_LE(pixel.x + pixel.width, display.width());
EXPECT_GE(pixel.y, hint.top);
EXPECT_LE(pixel.y + pixel.height, display.height());
}
}
TEST(SmallMessageText, ButtonHintReflowsOnTinyAndRotatedScreens) {
for (auto dimensions : {std::pair<int, int>{72, 40}, {40, 72},
{128, 32}, {32, 128}, {64, 128}, {128, 64}}) {
TestDisplay display(dimensions.first, dimensions.second);
mesh::ui::SmallMessageText text(display);
const auto hint = mesh::ui::makeButtonReaderHintLayout(
text, text.lineHeight(), display.height());
for (int row = 0; row < hint.line_count; ++row)
EXPECT_LE(text.getTextWidth(hint.lines[row]), display.width());
mesh::ui::drawButtonReaderHint(text, hint);
ASSERT_FALSE(display.fills.empty());
for (const auto& pixel : display.fills) {
EXPECT_GE(pixel.x, 0);
EXPECT_LE(pixel.x + pixel.width, display.width());
EXPECT_GE(pixel.y, hint.top);
EXPECT_LE(pixel.y + pixel.height, display.height());
}
}
}
TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) {
class RecordingText : public mesh::ui::SmallMessageText {
public:
@@ -257,6 +303,15 @@ TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) {
EXPECT_EQ(5, mesh::ui::drawTextWrapped(text, 0, 22, 128,
text.lineHeight(), text.lineCount(22), sample));
EXPECT_EQ(sample, text.drawn);
// The button reader moves its header/origin up to reserve the hint, so
// the same complete 160-character text still fits in five body rows.
text.drawn.clear();
const auto hint = mesh::ui::makeButtonReaderHintLayout(
text, text.lineHeight(), display.height());
const int message_y = 2 * text.lineHeight();
EXPECT_EQ(5, mesh::ui::drawTextWrapped(text, 0, message_y, 128,
text.lineHeight(), text.lineCount(message_y, hint.top), sample));
EXPECT_EQ(sample, text.drawn);
}
}
+56
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
from pathlib import Path
import subprocess
import tempfile
import unittest
@@ -141,6 +143,60 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase):
self.assertNotIn("savePrefs", loop[stop:state_service])
self.assertNotIn("saveEnabled", loop[stop:state_service])
def test_reader_exit_wins_over_startup_rescue_but_home_keeps_rescue(self):
ui = UI.read_text(encoding="utf-8")
start = ui.index("char UITask::handleLongPress(char c) {")
end = ui.index("\nchar UITask::handleDoubleClick", start)
source = r'''
#include <cassert>
#define UI_WIFI_SETUP_HOME_PAGE 1
unsigned long now = 4000;
unsigned long millis() { return now; }
struct Screen {};
struct HomeScreen : Screen {
bool setup = false;
bool isWiFiSetupPage() const { return setup; }
};
struct Mesh {
int rescue_calls = 0;
void enterCLIRescue() { ++rescue_calls; }
} the_mesh;
struct UITask {
Screen* curr;
Screen* home;
Screen* msg_preview;
unsigned long ui_started_at = 0;
char handleLongPress(char c);
};
''' + ui[start:end] + r'''
int main() {
HomeScreen home;
Screen reader;
UITask task{&reader, &home, &reader};
assert(task.handleLongPress('E') == 'E');
assert(the_mesh.rescue_calls == 0);
task.curr = &home;
assert(task.handleLongPress('E') == 0);
assert(the_mesh.rescue_calls == 1);
home.setup = true;
assert(task.handleLongPress('E') == 'E');
assert(the_mesh.rescue_calls == 1);
home.setup = false;
now = 9000;
assert(task.handleLongPress('E') == 'E');
task.curr = &reader;
assert(task.handleLongPress('E') == 'E');
assert(the_mesh.rescue_calls == 1);
}
'''
with tempfile.TemporaryDirectory() as temp_dir:
executable = Path(temp_dir) / "reader_hold"
subprocess.run(
["c++", "-std=c++17", "-x", "c++", "-", "-o", str(executable)],
input=source, text=True, capture_output=True, check=True,
)
subprocess.run([str(executable)], check=True)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -78,7 +78,7 @@ class T096MessageFooterProfileTest(unittest.TestCase):
ui = UI.read_text(encoding="utf-8")
self.assertIn(
"#ifndef UI_MESSAGE_CHANNEL_FOOTER\n"
" #if defined(MESHCORE_HAS_SMALL_DISPLAY)\n"
" #if defined(MESHCORE_HAS_SMALL_DISPLAY) || UI_BUTTON_READER_HINT == 1\n"
" #define UI_MESSAGE_CHANNEL_FOOTER 0\n"
" #else\n"
" #define UI_MESSAGE_CHANNEL_FOOTER 1\n"