From 7241adb1468ff8b7bd270d1a2c0e7d8e18cc5309 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 9 Sep 2026 10:20:22 -0700 Subject: [PATCH] Fix PIN spacing and keep message navigation hints visible Use measured text rows for companion and repeater layouts, retain the V4 6px footer, and add overlap and navigation regressions. Correct the MQTT logging sleep documentation contract test. --- docs/home_text_spacing.md | 23 +++ docs/v4_pixel5_font_trial.md | 15 +- examples/companion_radio/ui-new/UITask.cpp | 67 ++------ examples/simple_repeater/UITask.cpp | 25 ++- src/helpers/ui/CompanionHomeLayout.h | 62 ++++++++ src/helpers/ui/ReaderNavigationHint.h | 33 ++-- src/helpers/ui/SSD1306Display.cpp | 6 +- src/helpers/ui/SSD1306Display.h | 2 + src/helpers/ui/ST7735Display.cpp | 5 + src/helpers/ui/ST7735Display.h | 1 + test/fixtures/st7735_native/render.cpp | 6 + test/test_bluetooth_pairing_ui.py | 3 +- .../test_display_driver.cpp | 19 ++- test/test_home_text_spacing.py | 150 ++++++++++++++++++ test/test_logging_sleep_contract.py | 26 ++- test/test_t096_native_resolution.py | 2 +- 16 files changed, 343 insertions(+), 102 deletions(-) create mode 100644 docs/home_text_spacing.md create mode 100644 test/test_home_text_spacing.py diff --git a/docs/home_text_spacing.md b/docs/home_text_spacing.md new file mode 100644 index 000000000..aaf43699a --- /dev/null +++ b/docs/home_text_spacing.md @@ -0,0 +1,23 @@ +# Home-screen text spacing + +On non-Indicator companions, a visible Bluetooth PIN or connection status owns +a separate bottom-aligned block. It replaces the ordinary inbox instruction, +clock and WiFi rows while displayed. The PIN stays below the inbox title; the +label and value use measured font heights, including the MeshPocket's larger +e-paper font. An unusually short viewport may omit the label, never overlap +the title or clip the PIN. Indicator's dedicated pairing layout is unchanged. + +The repeater home page also stacks measured font-height rows instead of fixed +ten-pixel offsets. Long names and status lines are ellipsized horizontally. + +Run `python3 -m unittest discover -s test -p test_home_text_spacing.py -v` on a +host with a C++ compiler. This renders the actual companion and repeater home +branches, recording every text rectangle and rejecting overlaps or clipping. +It covers 128x64 OLED, 160x80 T096, 250x122 MeshPocket and larger viewports, +PIN/connected/disabled/prompt states, WiFi variants, long strings and a sweep +of font heights. It also checks the static reader hint. The separate native +display and T096 tests exercise the driver drawing/measurement methods. + +Hardware acceptance: check that the full six-digit PIN is near the bottom, +with no text over it; connect/disconnect Bluetooth and verify no stale digits; +then check the repeater's five rows and the V4's non-blinking reader footer. diff --git a/docs/v4_pixel5_font_trial.md b/docs/v4_pixel5_font_trial.md index 0731ec643..0b11d4b37 100644 --- a/docs/v4_pixel5_font_trial.md +++ b/docs/v4_pixel5_font_trial.md @@ -32,18 +32,19 @@ 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 alternates between `<- 2 tap 1 tap -> long press: exit` and -`<<- 4 tap 3 tap ->>` every three seconds. Double tap goes to the previous +The bottom continuously shows `4 <<- 2 <- tap -> 1 ->> 3 hold: Exit` without alternating or +blinking. Double tap goes to the previous message, one tap advances, three taps select the next channel, and four taps select the previous channel. Channel selection cycles through All, configured channels, and direct messages, starting at the newest message in each filter. The header shows `All`, `Ch N`, or `DM`, including when a channel is empty. Hold to return home; advancing past the last message also returns home. -The hints share the same footer space and use `hold: X` when the longer exit -label does not fit. Both fit in the V4's existing 8-pixel footer at y=56, leaving -five message rows. Narrower screens split the hint across lines; switching -hints does not change the space reserved for text. The hints appear even when -the inbox is empty. These are button taps. Touchscreen and joystick builds retain +The complete hint fits in the V4's existing 8-pixel footer at y=56, leaving +five message rows. The footer keeps the same 6px font, using 108 pixels with +10-pixel margins on the V4. Narrower screens first use the compact `hold:X` +hint without the extra spaces or arrow dashes, then split the +same controls across fixed lines. The hint appears even when the inbox is +empty. 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 diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index e772f3032..911c4f581 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -656,7 +656,11 @@ public: #else display.setTextSize(2); sprintf(tmp, "INBOX: %d", _task->getPreviewCount()); - display.drawTextCentered(display.width() / 2, body_top + 2, tmp); + const int home_content_top = body_top + 2 + display.textLineHeight() + + (display.height() > 64 ? 2 : 0); + mesh::ui::drawTextCenteredEllipsized(display, + {0, body_top + 2, display.width(), display.textLineHeight()}, + body_top + 2, tmp); #endif #ifdef UI_DEDICATED_PAIRING_BLOCK const mesh::ui::CompanionHomeLayout layout = @@ -770,15 +774,7 @@ public: mesh::ui::shouldDisplayBluetoothPairingPin( bluetooth_enabled, bluetooth_connected, bluetooth_pin) && (!_task->isPairingPromptActive() || display.height() > 64); - const bool compact_pairing = - mesh::ui::usesCompactCompanionPairingLayout( - display.width(), display.height()) - && (bluetooth_connected || show_bluetooth_pin); - - if (compact_pairing) { - const mesh::ui::CompactCompanionPairingLayout layout = - mesh::ui::makeCompactCompanionPairingLayout( - display.width(), display.height()); + if (bluetooth_connected || show_bluetooth_pin) { char pairing_pin[16]; const char* pairing_label = bluetooth_connected ? "BLUETOOTH" : "BLUETOOTH PIN"; @@ -789,22 +785,15 @@ public: pairing_value = pairing_pin; } - // This repaint deliberately removes the ordinary instruction and - // Wi-Fi/IP rows. Both occupy this same lower area on a 128x64 screen. - display.setColor(UIColor::title_bkg); - mesh::ui::clearDisplayRegion(display, layout.pairing); - display.setColor(UIColor::title_txt); - display.setTextSize(1); - mesh::ui::drawTextCenteredEllipsized( - display, layout.pairing, layout.pairing_label_y, pairing_label); - display.setTextSize(2); - mesh::ui::drawTextCenteredEllipsized( - display, layout.pairing, layout.pairing_value_y, pairing_value); + mesh::ui::drawBottomPairingBlock(display, home_content_top, + pairing_label, pairing_value); } else { + mesh::ui::BoundedTextRows rows(display, + {0, home_content_top, display.width(), + display.height() - home_content_top}); display.setTextSize(1); display.setColor(UIColor::secondary_txt); - display.drawTextCentered(display.width() / 2, 43, - PRESS_LABEL ": inbox"); + rows.draw(PRESS_LABEL ": inbox"); #ifdef UI_SHOW_CLOCK display.setTextSize(3); @@ -813,10 +802,10 @@ public: now += (int32_t)tz * 3600; DateTime dt (now); sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); - display.drawTextCentered(display.width() / 2, 60, tmp); + rows.draw(tmp); display.setTextSize(1); sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year()); - display.drawTextCentered(display.width() / 2, 80, tmp); + rows.draw(tmp); #endif #ifdef WIFI_SSID @@ -832,31 +821,8 @@ public: strcpy(tmp, "WiFi: SETUP"); } display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); + rows.draw(tmp); #endif - - if (bluetooth_connected) { - display.setColor(UIColor::warning_txt); - display.setTextSize(1); - #ifdef UI_SHOW_CLOCK - display.drawTextCentered(display.width() / 2, 110, - "< Connected >"); - #else - display.drawTextCentered(display.width() / 2, 43, - "< Connected >"); - #endif - } else if (show_bluetooth_pin) { // BT pin - display.setColor(UIColor::warning_txt); - snprintf(tmp, sizeof(tmp), "Pin:%06u", - (unsigned int)bluetooth_pin); - #ifdef UI_SHOW_CLOCK - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 110, tmp); - #else - display.setTextSize(2); - display.drawTextCentered(display.width() / 2, 43, tmp); - #endif - } } #endif #if UI_MESSAGES_HOME_PAGE == 1 @@ -1500,8 +1466,7 @@ public: #endif const mesh::ui::ButtonReaderHintLayout hint = mesh::ui::makeButtonReaderHintLayout( - reader_text, hint_line_height, body_bottom, - (millis() / 3000U) % 2 != 0); + reader_text, hint_line_height, body_bottom); body_bottom = hint.top; DisplayDriver& header = reader_text; #else diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 8fcdebc40..5790ed9e5 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #ifdef DISPLAY_REDRAW_ON_CHANGE #include @@ -209,42 +210,38 @@ void UITask::renderCurrScreen() { renderDashboard(); return; #endif - // node name - _display->setCursor(0, 0); + // Reserve a full measured font-height for each row on OLED/TFT/e-paper. + mesh::ui::BoundedTextRows rows(*_display, + {0, 0, _display->width(), _display->height()}); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); - _display->print(_node_prefs->node_name); + rows.draw(_node_prefs->node_name, false); // freq / sf - _display->setCursor(0, 20); sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); - _display->print(tmp); + rows.draw(tmp, false); // bw / cr - _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); - _display->print(tmp); + rows.draw(tmp, false); #ifdef WITH_MQTT_BRIDGE // Display IP address for MQTT bridge devices if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); - _display->setCursor(0, 40); _display->setColor(UIColor::primary_txt); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); - _display->print(tmp); + rows.draw(tmp, false); } else #endif { - _display->setCursor(0, 40); snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f); - _display->print(tmp); + rows.draw(tmp, false); } - // Keep power-saving state visible even when the MQTT IP occupies row 40. - _display->setCursor(0, 50); + // Keep power-saving state visible even when the MQTT IP replaces battery. snprintf(tmp, sizeof(tmp), "PowerSaving: %s", _node_prefs->powersaving_enabled ? "ON" : "off"); - _display->print(tmp); + rows.draw(tmp, false); } } diff --git a/src/helpers/ui/CompanionHomeLayout.h b/src/helpers/ui/CompanionHomeLayout.h index 78cd71f74..e7586172b 100644 --- a/src/helpers/ui/CompanionHomeLayout.h +++ b/src/helpers/ui/CompanionHomeLayout.h @@ -157,5 +157,67 @@ inline void drawTextCenteredEllipsized(DisplayDriver& display, } } +// Stack real font-height rows, never fixed ten-pixel baselines. E-paper's +// smallest font is already taller than an entire OLED row. +class BoundedTextRows { + DisplayDriver& display; + DisplayRegion region; + int next_y; + int gap; +public: + BoundedTextRows(DisplayDriver& d, const DisplayRegion& r, int spacing = 2) + : display(d), region(r), next_y(r.y), gap(spacing) {} + + bool draw(const char* text, bool centered = true) { + const int height = display.textLineHeight(); + if (height <= 0 || !displayRegionContainsLine(region, next_y, height)) + return false; + if (centered) { + drawTextCenteredEllipsized(display, region, next_y, text); + } else { + display.drawTextEllipsized(region.x, next_y, region.width, text); + } + next_y += height + gap; + return true; + } +}; + +// PIN/connection status owns the bottom of every non-Indicator home screen, +// including native 160x80 T096 and 250x122 MeshPocket panels. The caller must +// not draw ordinary instructions/network rows in this same area. +inline void drawBottomPairingBlock(DisplayDriver& display, int content_top, + const char* label, const char* value) { + const int bottom = display.height() - (display.height() > 64 ? 2 : 0); + const int available = bottom - content_top; + const int margin = display.width() >= 16 ? 4 : 0; + const int width = display.width() - 2 * margin; + if (available <= 0 || width <= 0) return; + display.setTextSize(1); + const int label_height = display.textLineHeight(); + int value_size = 2; + display.setTextSize(value_size); + if (display.getTextWidth(value) > width + || display.textLineHeight() > available) { + display.setTextSize(value_size = 1); + } + const int value_height = display.textLineHeight(); + if (value_height <= 0 || value_height > available) return; + // Prefer a readable complete PIN; omit its label on unusually short + // viewports instead of colliding with the title or clipping the digits. + const bool show_label = label_height > 0 + && label_height + 2 + value_height <= available; + const int block_height = value_height + (show_label ? label_height + 2 : 0); + const DisplayRegion region = {margin, bottom - block_height, width, block_height}; + display.setColor(UIColor::title_bkg); + clearDisplayRegion(display, region); + display.setColor(UIColor::title_txt); + if (show_label) { + display.setTextSize(1); + drawTextCenteredEllipsized(display, region, region.y, label); + } + display.setTextSize(value_size); + drawTextCenteredEllipsized(display, region, bottom - value_height, value); +} + } // namespace ui } // namespace mesh diff --git a/src/helpers/ui/ReaderNavigationHint.h b/src/helpers/ui/ReaderNavigationHint.h index a54c3ac90..603aae764 100644 --- a/src/helpers/ui/ReaderNavigationHint.h +++ b/src/helpers/ui/ReaderNavigationHint.h @@ -12,35 +12,28 @@ struct ButtonReaderHintLayout { const char* lines[3]; }; -// Alternate page and group navigation within the same footer. Measure both -// views so changing the hint never repaginates text or hides a message row. +// All gestures stay visible: no blinking between page and channel controls. +// Separate the controls where space permits, retaining the existing font. +// Narrow displays use the compact hint before needing additional rows. inline ButtonReaderHintLayout makeButtonReaderHintLayout( DisplayDriver& text, int line_height, int bottom, bool show_groups = false) { + (void)show_groups; // Kept for source compatibility; no timed alternation. ButtonReaderHintLayout layout = { - 0, line_height, 1, {"<- 2 tap 1 tap -> long press: exit", nullptr, nullptr}}; - const char* group_line = "<<- 4 tap 3 tap ->>"; - if (text.getTextWidth(layout.lines[0]) > text.width() - && text.getTextWidth("<-2 tap 1 tap-> hold: X") <= text.width()) { - layout.lines[0] = "<-2 tap 1 tap-> hold: X"; - } - if (text.getTextWidth(layout.lines[0]) > text.width() - || text.getTextWidth(group_line) > text.width()) { + 0, line_height, 1, {"4 <<- 2 <- tap -> 1 ->> 3 hold: Exit", nullptr, nullptr}}; + if (text.getTextWidth(layout.lines[0]) > text.width()) + layout.lines[0] = "4<<21>>3 hold:X"; + if (text.getTextWidth(layout.lines[0]) > text.width()) { layout.line_count = 2; - layout.lines[0] = "<- 2 tap 1 tap ->"; - layout.lines[1] = "long press: exit"; + layout.lines[0] = "4<<21>>3"; + layout.lines[1] = "hold:X"; if (text.getTextWidth(layout.lines[0]) > text.width() - || text.getTextWidth(group_line) > text.width() || text.getTextWidth(layout.lines[1]) > text.width()) { layout.line_count = 3; - layout.lines[0] = "<- 2 tap"; - layout.lines[1] = "1 tap ->"; - layout.lines[2] = "hold: X"; + layout.lines[0] = "4<<2<"; + layout.lines[1] = ">1>>3"; + layout.lines[2] = "holdX"; } } - if (show_groups) { - layout.lines[0] = layout.line_count < 3 ? group_line : "<<- 4 tap"; - if (layout.line_count == 3) layout.lines[1] = "3 tap ->>"; - } layout.top = bottom - layout.line_count * line_height; if (layout.top < 0) layout.top = 0; return layout; diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 069faf00c..a8bfe5f0a 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -98,12 +98,14 @@ void SSD1306Display::startFrame(ColorVal bkg) { display.clearDisplay(); // TODO: apply 'bkg' _color = SSD1306_WHITE; display.setTextColor(_color); - display.setTextSize(1); + setTextSize(1); + display.setTextWrap(false); // Layout/ellipsis owns wrapping and row spacing. display.cp437(true); // Use full 256 char 'Code Page 437' font } void SSD1306Display::setTextSize(int sz) { - display.setTextSize(sz); + _text_size = sz > 0 ? sz : 1; + display.setTextSize(_text_size); } void SSD1306Display::setColor(ColorVal c) { diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index d753d9d7b..aeda876bd 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -26,6 +26,7 @@ class SSD1306Display : public DisplayDriver { uint8_t _base_rotation = 0; #endif uint8_t _color; + int _text_size = 1; RefCountedDigitalPin* _peripher_power; bool i2c_probe(TwoWire& wire, uint8_t addr); @@ -48,6 +49,7 @@ public: void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; + int textLineHeight() override { return 8 * _text_size; } void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 3b5a87e86..f2d852c5e 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -605,6 +605,7 @@ void ST7735Display::startFrame(ColorVal bkg) { sprite->setTextColor(curr_color = UIColor::primary_txt); sprite->setFreeFont(); sprite->setTextSize(1); // This one affects size of Please wait... message + sprite->setTextWrap(false); //sprite->cp437(true); // Use full 256 char 'Code Page 437' font } @@ -613,6 +614,10 @@ void ST7735Display::setTextSize(int sz) { sprite->setTextSize(sz); } +int ST7735Display::textLineHeight() { + return spriteReady() ? sprite->fontHeight() : 8; +} + void ST7735Display::setColor(ColorVal c) { if (!spriteReady()) return; curr_color = c; diff --git a/src/helpers/ui/ST7735Display.h b/src/helpers/ui/ST7735Display.h index 1ea40b216..f0995fe59 100644 --- a/src/helpers/ui/ST7735Display.h +++ b/src/helpers/ui/ST7735Display.h @@ -36,6 +36,7 @@ public: void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; + int textLineHeight() override; void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; diff --git a/test/fixtures/st7735_native/render.cpp b/test/fixtures/st7735_native/render.cpp index c438d030b..88724d5c4 100644 --- a/test/fixtures/st7735_native/render.cpp +++ b/test/fixtures/st7735_native/render.cpp @@ -3,6 +3,7 @@ #include #include #include +#include ColorVal UIColor::window_bkg = 0, UIColor::title_bkg = 0, UIColor::title_txt = 1; ColorVal UIColor::primary_txt = 1, UIColor::secondary_txt = 1, UIColor::warning_txt = 1; @@ -16,6 +17,7 @@ struct Canvas { void setTextColor(int) {} void print(const char*) { ++prints; } int textWidth(const char* s) { return std::strlen(s) * 6 * size; } + int fontHeight() { return 8 * size; } void fillRect(int xx, int yy, int w, int h, int) { assert(xx >= 0 && yy >= 0 && xx + w <= 160 && yy + h <= 80); for (int row = yy; row < yy + h; ++row) @@ -45,6 +47,10 @@ int main() { display.setCursor(159, 79); assert(canvas.x == 159 && canvas.y == 79); assert(display.getTextWidth("MMMM") == 24); + assert(display.textLineHeight() == 8); + display.setTextSize(2); + assert(display.textLineHeight() == 16); + display.setTextSize(1); assert(!display.useSmallMessageFont()); // Every logical pixel covers its own exact physical cell, without gaps or // overlap. In native mode each of these cells must be exactly one pixel. diff --git a/test/test_bluetooth_pairing_ui.py b/test/test_bluetooth_pairing_ui.py index 3fb37d996..d4b331c1d 100644 --- a/test/test_bluetooth_pairing_ui.py +++ b/test/test_bluetooth_pairing_ui.py @@ -84,7 +84,8 @@ static_assert(mesh::ui::isBluetoothPairingPromptActive( full_ui = UI_VARIANTS[0].read_text(encoding="utf-8") self.assertNotIn("else if (bluetooth_pin != 0)", full_ui) - self.assertIn("else if (show_bluetooth_pin) { // BT pin", full_ui) + self.assertIn("if (bluetooth_connected || show_bluetooth_pin)", full_ui) + self.assertIn("drawBottomPairingBlock(display, home_content_top,", full_ui) self.assertIn("_pairing_screen_until = 0;", full_ui) def test_incoming_pairing_overrides_setup_screen_with_top_pin(self): diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 839a0cc8a..086a8432f 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -292,12 +292,29 @@ TEST(SmallMessageText, GroupHintFitsV4FooterWithoutLosingMessageRows) { const auto hint = mesh::ui::makeButtonReaderHintLayout( text, text.lineHeight(), display.height(), true); ASSERT_EQ(1, hint.line_count); - EXPECT_STREQ("<<- 4 tap 3 tap ->>", hint.lines[0]); + EXPECT_STREQ("4 <<- 2 <- tap -> 1 ->> 3 hold: Exit", hint.lines[0]); + EXPECT_EQ(6, text.capitalHeight()); + EXPECT_EQ(108, text.getTextWidth(hint.lines[0])); + EXPECT_GT(text.getTextWidth(hint.lines[0]), text.getTextWidth("4<<21>>3 hold:X")); EXPECT_LE(text.getTextWidth(hint.lines[0]), 128); EXPECT_EQ(56, hint.top); EXPECT_EQ(5, text.lineCount(2 * text.lineHeight(), hint.top)); } +TEST(SmallMessageText, HintDropsExtraSpacesBeforeAddingRows) { + for (int width : {64, 107, 108, 128}) { + TestDisplay display(width, 128); + mesh::ui::SmallMessageText text(display); + const auto hint = mesh::ui::makeButtonReaderHintLayout( + text, text.lineHeight(), display.height()); + ASSERT_EQ(1, hint.line_count); + EXPECT_STREQ(width >= text.getTextWidth("4 <<- 2 <- tap -> 1 ->> 3 hold: Exit") + ? "4 <<- 2 <- tap -> 1 ->> 3 hold: Exit" : "4<<21>>3 hold:X", hint.lines[0]); + EXPECT_LE(text.getTextWidth(hint.lines[0]), width); + EXPECT_EQ(120, hint.top); + } +} + TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) { class RecordingText : public mesh::ui::SmallMessageText { public: diff --git a/test/test_home_text_spacing.py b/test/test_home_text_spacing.py new file mode 100644 index 000000000..a78f8c177 --- /dev/null +++ b/test/test_home_text_spacing.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Render production home-page branches; reject overlapping/clipped text boxes.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +PREAMBLE = r''' +#include +#include +#include +#include +#include +#include +#include +ColorVal UIColor::window_bkg=0, UIColor::title_bkg=0, UIColor::title_txt=1; +ColorVal UIColor::primary_txt=1, UIColor::secondary_txt=1, UIColor::warning_txt=1; +ColorVal UIColor::popup_bkg=0, UIColor::popup_txt=1, UIColor::corp_blue=1; +#define PRESS_LABEL "Press" +struct Display : DisplayDriver { + struct Row { mesh::ui::DisplayRegion box; std::string text; }; + std::vector rows; + int size=1,x=0,y=0,small,large; + Display(int w,int h,int a,int b):DisplayDriver(w,h),small(a),large(b) {} + bool isOn() override { return true; } + void turnOn() override {} void turnOff() override {} + void clear() override { rows.clear(); } + void startFrame(ColorVal=0) override { clear(); } void endFrame() override {} + void setTextSize(int s) override { size=s; } + int textLineHeight() override { return size==1?small:large; } + void setColor(ColorVal) override {} + void setCursor(int a,int b) override { x=a;y=b; } + uint16_t getTextWidth(const char* text) override { + int w=0; for(;*text;++text) w += small==8 ? 6*size : (*text=='i'?3:9)*size; + return w; + } + void print(const char* text) override { + if(!*text) return; + mesh::ui::DisplayRegion box{x,y,getTextWidth(text),textLineHeight()}; + assert(x>=0 && y>=0 && box.right()<=width() && box.bottom()<=height()); + for(const auto& row:rows) assert(!mesh::ui::displayRegionsOverlap(box,row.box)); + rows.push_back({box,text}); + } + void fillRect(int x,int y,int w,int h) override { + assert(x>=0 && y>=0 && w>=0 && h>=0 && x+w<=width() && y+h<=height()); + } + void drawRect(int x,int y,int w,int h) override { fillRect(x,y,w,h); } + void drawXbm(int,int,const uint8_t*,int,int) override {} + const Row* find(const char* text) { + for(const auto& row:rows) if(row.text==text) return &row; + return nullptr; + } +}; +struct Task { + bool enabled=true,connected=false,prompt=false; + int count=0; + bool isBluetoothEnabled(){return enabled;} bool hasBluetoothConnection(){return connected;} + bool isPairingPromptActive(){return prompt;} int getPreviewCount(){return count;} +}; +struct Mesh { uint32_t pin=123456; uint32_t getBLEPin(){return pin;} } the_mesh; +struct HomePage { enum { FIRST=0 }; }; +struct IPAddress { int operator[](int i){return 255-i;} }; +struct Wifi { IPAddress localIP(){return {};} int status(){return 3;} } WiFi; +constexpr int WL_CONNECTED=3; +bool isCompanionWiFiEnabled(){return true;} +bool isCompanionWiFiConnected(){return true;} +bool hasCompanionWiFiCredentials(){return true;} +struct Prefs { const char* node_name="A very long repeater name that must be truncated safely"; + float freq=910.525,bw=62.5; int sf=7,cr=5; bool powersaving_enabled=true; +}; +struct Board { int getBattMilliVolts(){return 4100;} }; +''' + +SCENARIOS = r''' +int main() { + for(auto profile: {std::vector{128,64,8,16}, {160,80,8,16}, + {250,122,21,27}, {250,122,22,29}, {200,200,22,29}, {240,135,10,20}}) { + Display d(profile[0],profile[1],profile[2],profile[3]); Task task; + for(int state=0;state<5;++state) for(int count:{0,99,99999}) { + task.enabled=state!=2; task.connected=state==1; task.prompt=state==4; + task.count=count; the_mesh.pin=state==3?0:123456; + d.clear(); home(d,&task); + bool pin_expected=task.enabled && !task.connected && the_mesh.pin + && (!task.prompt || d.height()>64); + assert((d.find("123456")!=nullptr)==pin_expected); + if(pin_expected) { + auto pin=d.find("123456"); + assert(pin->box.y>=d.height()/2); + assert(pin->box.bottom()==d.height()-(d.height()>64?2:0)); + assert(!d.find("Press: inbox")); + } + if(task.connected) assert(d.find("CONNECTED")); + } + Prefs prefs; Board board; d.clear(); repeater(&d,&prefs,&board); + assert(d.rows.size()==5); // Never solve overlap by losing a status row. + d.clear(); d.setTextSize(1); + const auto hint=mesh::ui::makeButtonReaderHintLayout(d,d.textLineHeight(),d.height()); + const auto later=mesh::ui::makeButtonReaderHintLayout(d,d.textLineHeight(),d.height(),true); + for(int i=0;i1>>3 hold:X")); + } + } + // Sweep font sizes and short viewports: retain complete digits, omit only + // a label that cannot fit, and never draw outside the reserved lower band. + for(int h=16;h<=160;++h) for(int line=8;line<=32;++line) { + Display d(160,h,line,2*line); + mesh::ui::drawBottomPairingBlock(d,h/3,"BLUETOOTH PIN","012345"); + for(const auto& row:d.rows) assert(row.box.y>=h/3); + } +} +''' + + +class HomeTextSpacingTest(unittest.TestCase): + def test_actual_home_and_repeater_branches(self): + source = (ROOT / 'examples/companion_radio/ui-new/UITask.cpp').read_text() + start = source.index(' const int normal_line =') + metrics = source[start:source.index(' display.setColor', start)] + start = source.index(' if (_page == HomePage::FIRST) {', start) + home = source[start:source.index('#if UI_MESSAGES_HOME_PAGE == 1', start)] + repeater = (ROOT / 'examples/simple_repeater/UITask.cpp').read_text() + start = repeater.index(' // Reserve a full measured font-height') + repeater = repeater[start:repeater.index('\n }\n}', start)] + implementation = ('void home(DisplayDriver& display, Task* _task) {\n' + 'display.setTextSize(1); char tmp[80]; int _page=0;\n' + + metrics + 'display.drawTextEllipsized(0,2,display.width(),"Node name");\n' + + home + '\n}}\n' + 'void repeater(DisplayDriver* _display, Prefs* _node_prefs, Board* _board) {' + 'char tmp[80];\n' + repeater + '\n}\n') + for wifi in (False, True): + with self.subTest(wifi=wifi), tempfile.TemporaryDirectory() as temp: + binary = str(Path(temp) / 'spacing') + flags = ['-DWIFI_SSID="test"', '-DWITH_MQTT_BRIDGE'] if wifi else [] + result = subprocess.run(['c++', '-std=c++11', '-fsanitize=address,undefined', + '-fno-pie', '-no-pie', '-I'+str(ROOT/'src'), + *flags, '-x', 'c++', '-', '-o', binary], + input=PREAMBLE+implementation+SCENARIOS, + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([binary], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_logging_sleep_contract.py b/test/test_logging_sleep_contract.py index e4845731c..068037efe 100644 --- a/test/test_logging_sleep_contract.py +++ b/test/test_logging_sleep_contract.py @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] def method(path, signature): - text = (ROOT / path).read_text() + text = (ROOT / path).read_text(encoding="utf-8") start = text.index(signature) end = text.index("{", start) + 1 depth = 1 @@ -98,7 +98,7 @@ class LoggingSleepContractTest(unittest.TestCase): "docs/firmware_picker.md", "docs/releases/1.17.1.5.md", ): with self.subTest(path=path): - text = (ROOT / path).read_text() + text = (ROOT / path).read_text(encoding="utf-8") blocks = re.findall(r"```text\n(.*?)```", text, re.S) self.assertTrue(any("powersaving off\nset usb.logging on\n" in block for block in blocks), path) @@ -108,9 +108,25 @@ class LoggingSleepContractTest(unittest.TestCase): for path in ("docs/cli_commands.md", "docs/role_feature_switches.md", "docs/releases/1.17.1.5.md"): with self.subTest(path=path): - text = " ".join((ROOT / path).read_text().split()) - self.assertRegex(text, r"WiFi/MQTT-only logging.*?does not need") - self.assertIn("get bridge.running", text) + text = (ROOT / path).read_text(encoding="utf-8") + paragraphs = [ + " ".join(block.split()) + for block in re.split(r"\n\s*\n", text) + if "WiFi/MQTT-only logging" in block + ] + self.assertTrue(paragraphs, f"{path}: missing WiFi-only sleep guidance") + for paragraph in paragraphs: + self.assertTrue( + re.search(r"WiFi/MQTT-only logging.*?does not need", paragraph), + f"{path}: missing running-MQTT sleep exception", + ) + # Check the actual guidance, not an unrelated bridge-command + # reference elsewhere in the document. MQTT has its own + # shared running-state command across firmware roles. + self.assertTrue( + "`get mqtt.running`" in paragraph, + f"{path}: WiFi-only sleep guidance must check `get mqtt.running`", + ) def test_running_mqtt_prevents_sleep_in_repeater_and_room(self): running = method("src/helpers/bridges/MQTTBridge.h", diff --git a/test/test_t096_native_resolution.py b/test/test_t096_native_resolution.py index c8dfa5387..f597d3484 100644 --- a/test/test_t096_native_resolution.py +++ b/test/test_t096_native_resolution.py @@ -16,7 +16,7 @@ class T096NativeResolutionTest(unittest.TestCase): source = (FIXTURE / "render.cpp").read_text() + "\n" source += "\n".join(line for line in driver.splitlines() if line.startswith("#define SCALE_")) + "\n" - for name in ("setTextSize", "setColor", "setCursor", "print", "fillRect", + for name in ("setTextSize", "textLineHeight", "setColor", "setCursor", "print", "fillRect", "drawRect", "drawXbm", "getTextWidth"): start = driver.index(f"ST7735Display::{name}(") start = driver.rfind("\n", 0, start) + 1