Use compact message text and sender lines on all small displays

This commit is contained in:
mikecarper
2026-09-08 13:36:40 -07:00
parent a7f088290b
commit eeea15ef01
19 changed files with 507 additions and 141 deletions
+3 -2
View File
@@ -22,8 +22,9 @@ It is not the generic XIAO profile for separately wired radio modules. Both
boards retain their parent Full Companion features, USB mOTA sender, WiFi
update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT.
An additional [V4 Picopixel trial](v4_pixel5_font_trial.md) uses 5-pixel capital
letters and a longer on-device message preview.
The [small-screen Picopixel layout](v4_pixel5_font_trial.md) uses 5-pixel
capital letters, a compact channel/sender line and full message previews.
A named V4 trial profile is available for hardware qualification.
## Build
+47 -28
View File
@@ -1,23 +1,36 @@
# V4 Picopixel message font trial
# Small-screen Picopixel message font
This optional V4.2/V4.3 OLED Full Companion build uses Adafruit's Picopixel
font for received message text. Capital letters are 5 pixels high; descenders
can occupy a sixth pixel, so lines advance by 7 pixels. Most characters
advance by 4 pixels horizontally, compared with the normal font's 6 pixels.
The normal font has 7-pixel letters and an 8-pixel line height.
Small-screen Companion builds use Picopixel for received message text and the
channel/sender line, including `Ch 0 Public`. This covers SSD1306 and SH1106
OLEDs, ST7735 small TFTs, and the U8g2 T-Echo Card display. The shared renderer
uses the same font data on every driver. Larger displays keep their existing
font. Menus, Bluetooth PINs and WiFi setup QR codes keep their normal layout.
The title, channel/sender, menus, Bluetooth PIN and WiFi QR page retain their
normal font. The message preview buffer increases from 78 bytes (77 text
bytes plus a terminator) to 161 bytes, enough for a complete 160-byte MeshCore
message. The screen retains 32 previews; this is separate from the V4's
512-frame offline queue and its mOTA policy.
Capital letters are 5 logical pixels high; descenders can occupy a sixth
pixel, so lines advance by 7 pixels. Most characters advance by 4 pixels
horizontally. The normal OLED font uses 7-pixel letters, 8-pixel line spacing
and 6-pixel character advances. Small TFTs retain their existing logical to
physical scaling.
Five complete Picopixel message lines fit below the title and sender on the
128 x 64 OLED. A typical message can fit in full; wide letters can still
exceed the available space. The last line then ends with `...`. Text wraps
at character boundaries. Unsupported characters appear as `?`.
On a 128 x 64 OLED, compacting the channel/sender line moves message text from
y=25 to y=21. Six complete small-font rows fit instead of five, roughly 30
additional characters depending on the letters. Text wraps at character
boundaries; the last line ends with `...` if it still cannot fit. Unsupported
characters appear as `?`. Long channel/sender names are ellipsized so they
cannot overlap the message.
## Build and install
The preview buffer holds a complete 160-byte MeshCore message plus its
terminator. Previously the main message UI allocated 78 bytes, leaving room
for only 77 bytes of text. It still retains 32 previews; larger records add
about 2.8 KB of RAM. The firmware RAM guard includes that increase. This
history is separate from the offline queue and its mOTA policy.
The tiny 72 x 40 T-Echo Card interface now previews the latest received
message below its status bar, with three small-font message rows. A button
press dismisses it. It stores one full message, and shows `...` when the
screen fills. Incoming text does not replace an active Bluetooth pairing PIN.
## V4 hardware trial
Enable `platformio.nimble.ini` in the ignored `platformio.local.ini` as shown
in the [NimBLE trial guide](nimble_companion_trial.md#build), then run:
@@ -29,26 +42,32 @@ OUTPUT_DIR=.releases/v4-pixel5 bash build.sh build-firmware \
--radio-preset usa-cascadia --profile cascade --standard --require-ota
```
The named trial keeps NimBLE, 350 contacts, 40 channels, the V4's 512-frame
PSRAM queue, USB mOTA sending and WiFi OTA support. The smaller font is also
the default in ordinary small-screen Companion builds from this source.
For a V4 already using the matching 16 MB Full Companion partition layout,
flash the application `.bin` at `0x10000`, or use it for WiFi OTA. A clean
USB install uses the merged image at address 0. The trial preserves NimBLE,
350 contacts, 40 channels, USB mOTA sending and WiFi OTA support.
USB install uses the merged image at address 0.
To restore the normal message font, install
`heltec_v4_2_v4_3_companion_radio_full_femon_nimble`. Both builds use the same
partition layout; erasing settings is unnecessary.
A custom build can set `-D UI_SMALL_MESSAGE_FONT=0` to restore the old font
and spacing. Remove any explicit `UI_MSG_PREVIEW_SIZE` flag too if the old
preview capacity is desired. These are compile-time options, not CLI commands.
No settings erase is necessary when changing between matching V4 layouts.
## Verification
```sh
python3 -B test/test_ssd1306_picopixel.py
python3 -B test/test_firmware_ram.py
pio test -e native -f test_display_driver -f test_companion_message_history
```
The font test uses the real SSD1306 driver, Adafruit GFX rendering and
Picopixel bitmaps with a host panel double. It checks a full 160-character
sample, all printable ASCII characters, descenders, rotations, bottom-edge
clipping, overflow markers, unsupported-character placeholders and restoring
the normal font. It runs with address/undefined-behavior sanitizers. It needs
the Adafruit GFX dependency from an existing PlatformIO build; without that
dependency it reports a skip. Run only one PlatformIO command at a time.
The native tests exercise the shared renderer, five-pixel capitals, complete
rows at display edges, long sender lines, overflow markers and tiny/rotated
screen geometry. The additional Adafruit comparison checks all 95 printable
ASCII glyphs pixel for pixel against the original Picopixel font, under
address/undefined-behavior sanitizers. That comparison needs a cached
PlatformIO Adafruit GFX library; set `MESHCORE_GFX_LIBRARY` to its directory
if needed. It reports a skip when the library is absent. The native tests do
not require that dependency. Run only one PlatformIO command at a time.
+20 -2
View File
@@ -3,6 +3,9 @@
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/CompanionMessageHistory.h>
#if UI_SMALL_MESSAGE_FONT == 1
#include <helpers/ui/Pixel5Text.h>
#endif
#include <helpers/ui/CompanionTransportSelectorLayout.h>
#include "../MyMesh.h"
#include "../CompanionWiFi.h"
@@ -1171,7 +1174,11 @@ public:
};
#ifndef UI_MSG_PREVIEW_SIZE
#define UI_MSG_PREVIEW_SIZE 78
#if UI_SMALL_MESSAGE_FONT == 1
#define UI_MSG_PREVIEW_SIZE 161
#else
#define UI_MSG_PREVIEW_SIZE 78
#endif
#endif
#ifndef UI_COMPACT_MESSAGE_STATUS
#define UI_COMPACT_MESSAGE_STATUS 0
@@ -1407,7 +1414,7 @@ public:
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(UIColor::corp_blue);
snprintf(tmp, sizeof(tmp), "Message %d/%d",
snprintf(tmp, sizeof(tmp), display.width() < 100 ? "%d/%d" : "Message %d/%d",
filtered_count == 0 ? 0 : view_offset + 1, filtered_count);
display.print(tmp);
@@ -1425,12 +1432,22 @@ public:
mesh::ui::formatCompanionMessageAge(
tmp, sizeof(tmp), companionMessageElapsedMillis(p->heard_millis));
if (display.width() < 100) {
char* suffix = strchr(tmp, ' ');
if (suffix != nullptr) *suffix = 0;
}
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.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, layout.origin_y + 7,
UI_MESSAGE_CHANNEL_FOOTER == 1
? display.height() - layout.filter_height : display.height());
#else
display.setCursor(0, layout.origin_y);
display.setColor(UIColor::secondary_txt);
char filtered_origin[sizeof(p->origin)];
@@ -1442,6 +1459,7 @@ public:
char filtered_msg[sizeof(p->message)];
display.translateUTF8ToBlocks(filtered_msg, p->message, sizeof(filtered_msg));
display.printWordWrap(filtered_msg, display.width());
#endif
renderChannelFilter(display);
@@ -1,4 +1,7 @@
#include "UITask.h"
#if UI_SMALL_MESSAGE_FONT == 1
#include <helpers/ui/Pixel5Text.h>
#endif
#include <Arduino.h>
#include <helpers/TxtDataHelpers.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
@@ -242,6 +245,9 @@ void UITask::renderCurrScreen() {
_display->setColor(UIColor::primary_txt);
_display->print(_node_prefs->node_name);
#if UI_SMALL_MESSAGE_FONT == 1
mesh::ui::drawSmallMessageBody(*_display, _origin, _msg, 12, 19);
#else
_display->setCursor(0, 12);
_display->setColor(UIColor::secondary_txt);
_display->print(_origin);
@@ -253,6 +259,7 @@ void UITask::renderCurrScreen() {
_display->setColor(UIColor::primary_txt);
sprintf(tmp, "%d", _msgcount);
_display->print(tmp);
#endif
_display->setColor(UIColor::secondary_txt); // last color will be kept on T114
} else if (!pairing_screen_active && (millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
@@ -31,7 +31,11 @@ class UITask : public AbstractUITask {
CompanionNodePrefs* _node_prefs;
char _version_info[32];
char _origin[62];
#if UI_SMALL_MESSAGE_FONT == 1
char _msg[161];
#else
char _msg[80];
#endif
char _alert[80];
int _msgcount;
bool _need_refresh = true;
@@ -1,4 +1,7 @@
#include "UITask.h"
#if UI_SMALL_MESSAGE_FONT == 1
#include <helpers/ui/Pixel5Text.h>
#endif
#include <helpers/TxtDataHelpers.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include "../MyMesh.h"
@@ -439,6 +442,38 @@ public:
};
#if UI_SMALL_MESSAGE_FONT == 1
class TinyMessageScreen : public UIScreen {
UITask* _task;
char _origin[62] = {};
char _message[161] = {};
public:
explicit TinyMessageScreen(UITask* task) : _task(task) {}
void setMessage(uint8_t path_len, const char* from, const char* message) {
if (path_len == 0xFF) {
snprintf(_origin, sizeof(_origin), "%s [direct]:", from);
} else {
snprintf(_origin, sizeof(_origin), "%s [%uh]:", from,
(unsigned int)path_len);
}
StrHelper::strncpy(_message, message, sizeof(_message));
}
int render(DisplayDriver& display) override {
// The 72x40 interface reserves its top 8px for the scrolling status bar.
mesh::ui::drawSmallMessageBody(display, _origin, _message, 10, 17);
return 1000;
}
bool handleInput(char c) override {
if (c == KEY_NEXT || c == KEY_PREV || c == KEY_LEFT || c == KEY_RIGHT
|| c == KEY_ENTER) {
_task->gotoHomeScreen();
return true;
}
return false;
}
};
#endif
void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
@@ -478,6 +513,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
#if UI_SMALL_MESSAGE_FONT == 1
msg_preview = new TinyMessageScreen(this);
#endif
setCurrScreen(splash);
}
@@ -519,6 +557,11 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
#if UI_SMALL_MESSAGE_FONT == 1
if (curr == msg_preview
&& static_cast<int32_t>(millis() - _msg_preview_until) < 0) return;
_deferred_msg_preview = false;
#endif
gotoHomeScreen();
}
}
@@ -529,6 +572,12 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
(void)channel_idx;
(void)channel_name;
_msgcount = msgcount;
#if UI_SMALL_MESSAGE_FONT == 1
static_cast<TinyMessageScreen*>(msg_preview)->setMessage(path_len, from_name, text);
if (isPairingScreenActive()) _deferred_msg_preview = true;
else setCurrScreen(msg_preview);
_msg_preview_until = millis() + 15000UL;
#endif
if (_display != NULL) {
if (!_display->isOn() && shouldWakeDisplayForMessage()) {
@@ -591,6 +640,12 @@ void UITask::showPairingPin() {
void UITask::finishPairingScreen(bool timed_out) {
_pairing_screen_until = 0;
_next_refresh = 0;
#if UI_SMALL_MESSAGE_FONT == 1
if (_deferred_msg_preview) {
_deferred_msg_preview = false;
setCurrScreen(msg_preview);
}
#endif
if (_display == NULL) return;
if (timed_out) {
+5 -1
View File
@@ -56,7 +56,11 @@ class UITask : public AbstractUITask {
UIScreen* splash;
UIScreen* home;
// UIScreen* msg_preview;
#if UI_SMALL_MESSAGE_FONT == 1
UIScreen* msg_preview = nullptr;
bool _deferred_msg_preview = false;
unsigned long _msg_preview_until = 0;
#endif
UIScreen* curr;
+1 -1
View File
@@ -23,7 +23,7 @@ lib_ignore = ${companion_nimble_trial.lib_ignore}
extends = env:heltec_v4_2_v4_3_companion_radio_full_femon_nimble
build_flags =
${env:heltec_v4_2_v4_3_companion_radio_full_femon_nimble.build_flags}
-D UI_SSD1306_PICOPIXEL_MESSAGES=1
-D UI_SMALL_MESSAGE_FONT=1
-D UI_MSG_PREVIEW_SIZE=161
[env:Xiao_S3_WIO_companion_radio_full_nimble]
+6 -1
View File
@@ -88,7 +88,12 @@ def requirements(platform, defines, target):
# ui-new retains 32 previews in one heap allocation. The baseline
# covers 78 bytes per message; budget larger buffers explicitly,
# including worst-case 8-byte Entry alignment.
extra = max(0, integer(defines, "UI_MSG_PREVIEW_SIZE", 78) - 78)
small_display = display in {
"SSD1306Display", "SH1106Display", "ST7735Display", "U8g2Display",
}
small_font = integer(defines, "UI_SMALL_MESSAGE_FONT", int(small_display))
default_preview = 161 if small_font else 78
extra = max(0, integer(defines, "UI_MSG_PREVIEW_SIZE", default_preview) - 78)
if extra:
parts["expanded_message_previews"] = 32 * ((extra + 7) // 8) * 8
parts["allocation_and_transient_margin"] = 16384 if platform == "ESP32_PLATFORM" else 4096
+8
View File
@@ -26,6 +26,14 @@
#define MESHCORE_HAS_SMALL_DISPLAY 1
#endif
#ifndef UI_SMALL_MESSAGE_FONT
#if defined(MESHCORE_HAS_SMALL_DISPLAY)
#define UI_SMALL_MESSAGE_FONT 1
#else
#define UI_SMALL_MESSAGE_FONT 0
#endif
#endif
#undef MESHCORE_DISPLAY_TOKEN_JOIN
#undef MESHCORE_DISPLAY_TOKEN_JOIN_INNER
#undef MESHCORE_SMALL_DISPLAY_CLASS_U8g2Display
+159
View File
@@ -0,0 +1,159 @@
#pragma once
// Picopixel by Sebastian Weber, from Adafruit GFX Library 1.12.6.
// Data is unchanged; the type names are independent of a display library.
// Source: https://github.com/adafruit/Adafruit-GFX-Library/blob/1.12.6/Fonts/Picopixel.h
/*
Software License Agreement (BSD License)
Copyright (c) 2012 Adafruit Industries. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
*/
#include <stdint.h>
namespace mesh {
namespace ui {
struct Pixel5Glyph {
uint16_t bitmap_offset;
uint8_t width, height, advance;
int8_t x_offset, y_offset;
};
static const uint8_t pixel5Bitmaps[] = {
0xE8, 0xB4, 0x57, 0xD5, 0xF5, 0x00, 0x4E, 0x3E, 0x80, 0xA5, 0x4A, 0x4A,
0x5A, 0x50, 0xC0, 0x6A, 0x40, 0x95, 0x80, 0xAA, 0x80, 0x5D, 0x00, 0x60,
0xE0, 0x80, 0x25, 0x48, 0x56, 0xD4, 0x75, 0x40, 0xC5, 0x4E, 0xC5, 0x1C,
0x97, 0x92, 0xF3, 0x1C, 0x53, 0x54, 0xE5, 0x48, 0x55, 0x54, 0x55, 0x94,
0xA0, 0x46, 0x64, 0xE3, 0x80, 0x98, 0xC5, 0x04, 0x56, 0xC6, 0x57, 0xDA,
0xD7, 0x5C, 0x72, 0x46, 0xD6, 0xDC, 0xF3, 0xCE, 0xF3, 0x48, 0x72, 0xD4,
0xB7, 0xDA, 0xF8, 0x24, 0xD4, 0xBB, 0x5A, 0x92, 0x4E, 0x8E, 0xEB, 0x58,
0x80, 0x9D, 0xB9, 0x90, 0x56, 0xD4, 0xD7, 0x48, 0x56, 0xD4, 0x40, 0xD7,
0x5A, 0x71, 0x1C, 0xE9, 0x24, 0xB6, 0xD4, 0xB6, 0xA4, 0x8C, 0x6B, 0x55,
0x00, 0xB5, 0x5A, 0xB5, 0x24, 0xE5, 0x4E, 0xEA, 0xC0, 0x91, 0x12, 0xD5,
0xC0, 0x54, 0xF0, 0x90, 0xC7, 0xF0, 0x93, 0x5E, 0x71, 0x80, 0x25, 0xDE,
0x5E, 0x30, 0x6E, 0x80, 0x77, 0x9C, 0x93, 0x5A, 0xB8, 0x45, 0x60, 0x92,
0xEA, 0xAA, 0x40, 0xD5, 0x6A, 0xD6, 0x80, 0x55, 0x00, 0xD7, 0x40, 0x75,
0x90, 0xE8, 0x71, 0xE0, 0xBA, 0x40, 0xB5, 0x80, 0xB5, 0x00, 0x8D, 0x54,
0xAA, 0x80, 0xAC, 0xE0, 0xE5, 0x70, 0x6A, 0x26, 0xFC, 0xC8, 0xAC, 0x5A};
static const Pixel5Glyph pixel5Glyphs[] = {{0, 0, 0, 2, 0, 1}, // 0x20 ' '
{0, 1, 5, 2, 0, -4}, // 0x21 '!'
{1, 3, 2, 4, 0, -4}, // 0x22 '"'
{2, 5, 5, 6, 0, -4}, // 0x23 '#'
{6, 3, 6, 4, 0, -4}, // 0x24 '$'
{9, 3, 5, 4, 0, -4}, // 0x25 '%'
{11, 4, 5, 5, 0, -4}, // 0x26 '&'
{14, 1, 2, 2, 0, -4}, // 0x27 '''
{15, 2, 5, 3, 0, -4}, // 0x28 '('
{17, 2, 5, 3, 0, -4}, // 0x29 ')'
{19, 3, 3, 4, 0, -3}, // 0x2A '*'
{21, 3, 3, 4, 0, -3}, // 0x2B '+'
{23, 2, 2, 3, 0, 0}, // 0x2C ','
{24, 3, 1, 4, 0, -2}, // 0x2D '-'
{25, 1, 1, 2, 0, 0}, // 0x2E '.'
{26, 3, 5, 4, 0, -4}, // 0x2F '/'
{28, 3, 5, 4, 0, -4}, // 0x30 '0'
{30, 2, 5, 3, 0, -4}, // 0x31 '1'
{32, 3, 5, 4, 0, -4}, // 0x32 '2'
{34, 3, 5, 4, 0, -4}, // 0x33 '3'
{36, 3, 5, 4, 0, -4}, // 0x34 '4'
{38, 3, 5, 4, 0, -4}, // 0x35 '5'
{40, 3, 5, 4, 0, -4}, // 0x36 '6'
{42, 3, 5, 4, 0, -4}, // 0x37 '7'
{44, 3, 5, 4, 0, -4}, // 0x38 '8'
{46, 3, 5, 4, 0, -4}, // 0x39 '9'
{48, 1, 3, 2, 0, -3}, // 0x3A ':'
{49, 2, 4, 3, 0, -3}, // 0x3B ';'
{50, 2, 3, 3, 0, -3}, // 0x3C '<'
{51, 3, 3, 4, 0, -3}, // 0x3D '='
{53, 2, 3, 3, 0, -3}, // 0x3E '>'
{54, 3, 5, 4, 0, -4}, // 0x3F '?'
{56, 3, 5, 4, 0, -4}, // 0x40 '@'
{58, 3, 5, 4, 0, -4}, // 0x41 'A'
{60, 3, 5, 4, 0, -4}, // 0x42 'B'
{62, 3, 5, 4, 0, -4}, // 0x43 'C'
{64, 3, 5, 4, 0, -4}, // 0x44 'D'
{66, 3, 5, 4, 0, -4}, // 0x45 'E'
{68, 3, 5, 4, 0, -4}, // 0x46 'F'
{70, 3, 5, 4, 0, -4}, // 0x47 'G'
{72, 3, 5, 4, 0, -4}, // 0x48 'H'
{74, 1, 5, 2, 0, -4}, // 0x49 'I'
{75, 3, 5, 4, 0, -4}, // 0x4A 'J'
{77, 3, 5, 4, 0, -4}, // 0x4B 'K'
{79, 3, 5, 4, 0, -4}, // 0x4C 'L'
{81, 5, 5, 6, 0, -4}, // 0x4D 'M'
{85, 4, 5, 5, 0, -4}, // 0x4E 'N'
{88, 3, 5, 4, 0, -4}, // 0x4F 'O'
{90, 3, 5, 4, 0, -4}, // 0x50 'P'
{92, 3, 6, 4, 0, -4}, // 0x51 'Q'
{95, 3, 5, 4, 0, -4}, // 0x52 'R'
{97, 3, 5, 4, 0, -4}, // 0x53 'S'
{99, 3, 5, 4, 0, -4}, // 0x54 'T'
{101, 3, 5, 4, 0, -4}, // 0x55 'U'
{103, 3, 5, 4, 0, -4}, // 0x56 'V'
{105, 5, 5, 6, 0, -4}, // 0x57 'W'
{109, 3, 5, 4, 0, -4}, // 0x58 'X'
{111, 3, 5, 4, 0, -4}, // 0x59 'Y'
{113, 3, 5, 4, 0, -4}, // 0x5A 'Z'
{115, 2, 5, 3, 0, -4}, // 0x5B '['
{117, 3, 5, 4, 0, -4}, // 0x5C '\'
{119, 2, 5, 3, 0, -4}, // 0x5D ']'
{121, 3, 2, 4, 0, -4}, // 0x5E '^'
{122, 4, 1, 4, 0, 1}, // 0x5F '_'
{123, 2, 2, 3, 0, -4}, // 0x60 '`'
{124, 3, 4, 4, 0, -3}, // 0x61 'a'
{126, 3, 5, 4, 0, -4}, // 0x62 'b'
{128, 3, 3, 4, 0, -2}, // 0x63 'c'
{130, 3, 5, 4, 0, -4}, // 0x64 'd'
{132, 3, 4, 4, 0, -3}, // 0x65 'e'
{134, 2, 5, 3, 0, -4}, // 0x66 'f'
{136, 3, 5, 4, 0, -3}, // 0x67 'g'
{138, 3, 5, 4, 0, -4}, // 0x68 'h'
{140, 1, 5, 2, 0, -4}, // 0x69 'i'
{141, 2, 6, 3, 0, -4}, // 0x6A 'j'
{143, 3, 5, 4, 0, -4}, // 0x6B 'k'
{145, 2, 5, 3, 0, -4}, // 0x6C 'l'
{147, 5, 3, 6, 0, -2}, // 0x6D 'm'
{149, 3, 3, 4, 0, -2}, // 0x6E 'n'
{151, 3, 3, 4, 0, -2}, // 0x6F 'o'
{153, 3, 4, 4, 0, -2}, // 0x70 'p'
{155, 3, 4, 4, 0, -2}, // 0x71 'q'
{157, 2, 3, 3, 0, -2}, // 0x72 'r'
{158, 3, 4, 4, 0, -3}, // 0x73 's'
{160, 2, 5, 3, 0, -4}, // 0x74 't'
{162, 3, 3, 4, 0, -2}, // 0x75 'u'
{164, 3, 3, 4, 0, -2}, // 0x76 'v'
{166, 5, 3, 6, 0, -2}, // 0x77 'w'
{168, 3, 3, 4, 0, -2}, // 0x78 'x'
{170, 3, 4, 4, 0, -2}, // 0x79 'y'
{172, 3, 4, 4, 0, -3}, // 0x7A 'z'
{174, 3, 5, 4, 0, -4}, // 0x7B '{'
{176, 1, 6, 2, 0, -4}, // 0x7C '|'
{177, 3, 5, 4, 0, -4}, // 0x7D '}'
{179, 4, 2, 5, 0, -3}}; // 0x7E '~'
} // namespace ui
} // namespace mesh
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "DisplayDriver.h"
#include "DisplayTextLayout.h"
#include "Pixel5FontData.h"
namespace mesh {
namespace ui {
// Draw through the common pixel interface so OLED, TFT and U8g2 panels use
// identical glyphs. This stack wrapper leaves the driver's normal font alone.
class Pixel5Text : public DisplayDriver {
DisplayDriver& _display;
int _x = 0, _y = 0;
static const Pixel5Glyph& glyph(uint8_t c) {
return pixel5Glyphs[(c >= 32 && c <= 126 ? c : '?') - 32];
}
public:
explicit Pixel5Text(DisplayDriver& display)
: DisplayDriver(display.width(), display.height()), _display(display) {}
static constexpr int line_height = 7;
static constexpr int glyph_height = 6; // 5px capitals plus descenders
bool isOn() override { return _display.isOn(); }
void turnOn() override { _display.turnOn(); }
void turnOff() override { _display.turnOff(); }
void clear() override { _display.clear(); }
void startFrame(ColorVal bkg) override { _display.startFrame(bkg); }
void endFrame() override { _display.endFrame(); }
void setTextSize(int) override {} // This font deliberately has one size.
void setColor(ColorVal color) override { _display.setColor(color); }
void setCursor(int x, int y) override { _x = x; _y = y; }
void fillRect(int x, int y, int w, int h) override {
_display.fillRect(x, y, w, h);
}
void drawRect(int x, int y, int w, int h) override {
_display.drawRect(x, y, w, h);
}
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {
_display.drawXbm(x, y, bits, w, h);
}
uint16_t getTextWidth(const char* str) override {
uint16_t result = 0;
while (*str) result += glyph((uint8_t)*str++).advance;
return result;
}
void print(const char* str) override {
while (*str) {
const Pixel5Glyph& g = glyph((uint8_t)*str++);
for (int row = 0; row < g.height; ++row) {
const int y = _y + 4 + g.y_offset + row;
if (y < 0 || y >= height()) continue;
for (int col = 0; col < g.width; ++col) {
const int bit = row * g.width + col;
if (!(pixel5Bitmaps[g.bitmap_offset + bit / 8]
& (0x80 >> (bit % 8)))) continue;
const int x = _x + g.x_offset + col;
if (x >= 0 && x < width()) _display.fillRect(x, y, 1, 1);
}
}
_x += g.advance;
}
}
void printWordWrap(const char* str, int max_width) override {
if (max_width > width() - _x) max_width = width() - _x;
const int lines = (height() - _y + line_height - glyph_height) / line_height;
drawTextWrapped(*this, _x, _y, max_width, line_height, lines, str);
}
};
inline int smallMessageLineCount(int height, int message_y) {
const int remaining = height - message_y;
return remaining < Pixel5Text::glyph_height ? 0
: (remaining + Pixel5Text::line_height - Pixel5Text::glyph_height)
/ Pixel5Text::line_height;
}
inline void drawSmallMessageBody(DisplayDriver& display, const char* origin,
const char* message, int origin_y = 14,
int message_y = 21, int bottom = -1) {
Pixel5Text text(display);
char translated[161];
text.translateUTF8ToBlocks(translated, origin, sizeof(translated));
text.setColor(UIColor::secondary_txt);
text.drawTextEllipsized(0, origin_y, display.width(), translated);
text.translateUTF8ToBlocks(translated, message, sizeof(translated));
text.setColor(UIColor::primary_txt);
const int height = bottom < 0 || bottom > display.height()
? display.height() : bottom;
drawTextWrapped(text, 0, message_y, display.width(), Pixel5Text::line_height,
smallMessageLineCount(height, message_y), translated);
}
} // namespace ui
} // namespace mesh
-52
View File
@@ -1,16 +1,5 @@
#include "SSD1306Display.h"
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
#include <Fonts/Picopixel.h>
#include "DisplayTextLayout.h"
// The message UI has already replaced unsupported UTF-8 with CP437 blocks.
// Picopixel is ASCII-only; retain a visible placeholder for those characters.
static uint8_t picopixelCharacter(uint8_t c) {
return c >= 0x20 && c <= 0x7E ? c : '?';
}
#endif
bool SSD1306Display::i2c_probe(TwoWire& wire, uint8_t addr) {
wire.beginTransmission(addr);
uint8_t error = wire.endTransmission();
@@ -123,43 +112,13 @@ void SSD1306Display::setColor(ColorVal c) {
}
void SSD1306Display::setCursor(int x, int y) {
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
// GFX custom fonts take a baseline; the shared UI takes a top coordinate.
if (_picopixel_message) y += 4;
#endif
display.setCursor(x, y);
}
void SSD1306Display::print(const char* str) {
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
if (_picopixel_message) {
while (*str) display.write(picopixelCharacter((uint8_t)*str++));
return;
}
#endif
display.print(str);
}
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
void SSD1306Display::printWordWrap(const char* str, int max_width) {
const int x = display.getCursorX();
const int y = display.getCursorY();
if (max_width > width() - x) max_width = width() - x;
// Capitals are 5px high; descenders can use a sixth pixel. Leave one blank
// row between lines and never start a partially visible final line.
const int max_lines = (height() - y + 1) / 7;
display.setFont(&Picopixel);
display.setTextSize(1);
display.setTextWrap(false);
_picopixel_message = true;
mesh::ui::drawTextWrapped(*this, x, y, max_width, 7, max_lines, str);
_picopixel_message = false;
display.setFont(nullptr);
display.setTextWrap(true);
display.setCursor(x, y);
}
#endif
void SSD1306Display::fillRect(int x, int y, int w, int h) {
display.fillRect(x, y, w, h, _color);
}
@@ -173,17 +132,6 @@ void SSD1306Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
}
uint16_t SSD1306Display::getTextWidth(const char* str) {
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
if (_picopixel_message) {
// Include advances for spaces; GFX's ink bounds omit trailing whitespace.
uint16_t width = 0;
while (*str) {
const uint8_t c = picopixelCharacter((uint8_t)*str++);
width += pgm_read_byte(&PicopixelGlyphs[c - 0x20].xAdvance);
}
return width;
}
#endif
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h);
-6
View File
@@ -27,9 +27,6 @@ class SSD1306Display : public DisplayDriver {
#endif
uint8_t _color;
RefCountedDigitalPin* _peripher_power;
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
bool _picopixel_message = false;
#endif
bool i2c_probe(TwoWire& wire, uint8_t addr);
void applyRotation();
@@ -54,9 +51,6 @@ public:
void setColor(ColorVal c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
void printWordWrap(const char* str, int max_width) override;
#endif
void fillRect(int x, int y, int w, int h) override;
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
+4 -1
View File
@@ -632,7 +632,10 @@ void ST7735Display::print(const char* str) {
void ST7735Display::fillRect(int x, int y, int w, int h) {
if (!spriteReady()) return;
sprite->fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color);
// Scale the edges, so adjacent 1px font cells meet at fractional scaling.
const int left = x*SCALE_X, top = y*SCALE_Y;
sprite->fillRect(left, top, int((x+w)*SCALE_X)-left,
int((y+h)*SCALE_Y)-top, curr_color);
}
void ST7735Display::drawRect(int x, int y, int w, int h) {
+21 -42
View File
@@ -1,9 +1,11 @@
#include <helpers/ui/SSD1306Display.h>
#include <helpers/ui/Pixel5Text.h>
#include <Fonts/Picopixel.h>
#include <cassert>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
void save(const char* file) {
auto& c=*Adafruit_SSD1306::last;
std::ofstream f(file);
@@ -15,56 +17,33 @@ int main(int argc, char** argv) {
assert(d.begin());
auto& c=*Adafruit_SSD1306::last;
const char* message="GIANT KILLER: Out on the Mercerwood mesh today, just the V4 and a small battery Checking the smaller font so the rest of this message is visible. 0123456789 END";
assert(std::string(message).size()==160);
d.startFrame();
d.setCursor(0,0); d.print("Message 3/29");
d.setCursor(86,0); d.print("1m ago");
d.drawRect(0,11,128,1);
d.setCursor(0,14); d.print("Ch 0 Public [4h]:");
d.setCursor(0,25);
#if UI_SSD1306_PICOPIXEL_MESSAGES == 1
d.printWordWrap(message,128);
#if UI_SMALL_MESSAGE_FONT == 1
mesh::ui::drawSmallMessageBody(d,"Ch 0 Public [4h]:",message);
#else
d.print(std::string(message,77).c_str());
d.setCursor(0,14); d.print("Ch 0 Public [4h]:");
d.setCursor(0,25); d.print(std::string(message,77).c_str());
#endif
if (argc > 1) save(argv[1]);
#if UI_SSD1306_PICOPIXEL_MESSAGES == 1
std::string shown;
for (const auto& g:c.glyphs) {
shown += g.c;
if (g.w && g.h) assert(g.x>=0 && g.x+g.w<=128 && g.y>=25 && g.y+g.h<=64);
}
assert(shown==message);
#if UI_SMALL_MESSAGE_FONT == 1
assert(c.outside==0);
assert(d.getTextWidth("ABC")==18); // regular font restored
d.setCursor(0,0); d.print("Regular font");
assert(d.getTextWidth("ABC")==18);
int checks=1;
for (int rotation:{0,90,180,270}) for (int top:{0,25,55,58,59,63,64}) {
d.setRotationDegrees(rotation);
d.startFrame(); d.setCursor(0,top);
d.printWordWrap(std::string(160,'W').c_str(),d.width());
for (const auto& g:c.glyphs) {
if (g.w && g.h) assert(g.x>=0 && g.x+g.w<=d.width() && g.y>=top && g.y+g.h<=d.height());
}
if (!c.glyphs.empty()) {
std::string row;
for (const auto& g:c.glyphs) row += g.c;
while (!row.empty() && row.back()==' ') row.pop_back();
assert(row==std::string(160, 'W') || (row.size()>=3 && row.substr(row.size()-3)=="..."));
}
assert(c.outside==0);
++checks;
}
d.setRotationDegrees(0);
assert(d.getTextWidth("ABC")==18); // regular driver font untouched
mesh::ui::Pixel5Text text(d);
GFXcanvas1 reference(128,64);
reference.setFont(&Picopixel);
reference.setTextColor(1);
for (int ch=32;ch<=126;ch++) {
d.startFrame(); d.setCursor(0,25);
d.printWordWrap(std::string(160,char(ch)).c_str(),128);
assert(c.outside==0);
++checks;
d.startFrame(); text.setCursor(3,25);
text.print(std::string(1,char(ch)).c_str());
reference.fillScreen(0); reference.setCursor(3,29); reference.write(ch);
for (int y=0;y<64;y++) for (int x=0;x<128;x++)
assert(c.getPixel(x,y)==reference.getPixel(x,y));
assert(text.getTextWidth(std::string(1,char(ch)).c_str())==PicopixelGlyphs[ch-32].xAdvance);
}
d.startFrame(); d.setCursor(0,25);
d.printWordWrap("A\xDB" "B",128);
assert(c.glyphs.size()==3 && c.glyphs[1].c=='?');
std::cout << "Checks: " << checks+1 << "; sample characters: " << shown.size() << "; sample: " << shown << '\n';
std::cout << "95 glyphs match Adafruit GFX Picopixel pixel for pixel; normal font preserved\n";
#endif
}
@@ -3,6 +3,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/Pixel5Text.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
#include <helpers/ui/WiFiSetupQrPayload.h>
@@ -133,6 +134,66 @@ TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) {
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
}
TEST(Pixel5Text, CapitalsAreFivePixelsAndNormalFontIsUntouched) {
TestDisplay display(128, 64, 6);
mesh::ui::Pixel5Text text(display);
text.setCursor(0, 0);
text.print("A");
const char* rows[] = {"010", "101", "111", "101", "101"};
bool pixels[5][3] = {};
for (const auto& pixel : display.fills) {
ASSERT_GE(pixel.x, 0); ASSERT_LT(pixel.x, 3);
ASSERT_GE(pixel.y, 0); ASSERT_LT(pixel.y, 5);
pixels[pixel.y][pixel.x] = true;
}
for (int y = 0; y < 5; ++y) for (int x = 0; x < 3; ++x)
EXPECT_EQ(rows[y][x] == '1', pixels[y][x]);
EXPECT_EQ(12, text.getTextWidth("ABC"));
EXPECT_EQ(18, display.getTextWidth("ABC"));
}
TEST(Pixel5Text, CompactOriginAllowsSixCompleteMessageRows) {
TestDisplay display(128, 64);
mesh::ui::drawSmallMessageBody(display, std::string(61, 'W').c_str(),
std::string(160, 'W').c_str());
ASSERT_FALSE(display.fills.empty());
bool final_dots[3] = {};
for (const auto& pixel : display.fills) {
EXPECT_GE(pixel.x, 0); EXPECT_LT(pixel.x, 128);
EXPECT_GE(pixel.y, 14); EXPECT_LT(pixel.y, 64);
// The origin stays in its own line; its long name cannot cover the body.
EXPECT_TRUE(pixel.y < 20 || pixel.y >= 21);
for (int i = 0; i < 3; ++i)
if (pixel.y == 60 && pixel.x == 120 + 2 * i) final_dots[i] = true;
}
for (bool dot : final_dots) EXPECT_TRUE(dot);
EXPECT_EQ(5, mesh::ui::smallMessageLineCount(64, 25));
EXPECT_EQ(6, mesh::ui::smallMessageLineCount(64, 21));
EXPECT_EQ(3, mesh::ui::smallMessageLineCount(40, 17));
EXPECT_EQ(0, mesh::ui::smallMessageLineCount(64, 59));
}
TEST(Pixel5Text, GlyphsStayInsideTinyAndRotatedScreens) {
for (auto dimensions : {std::pair<int, int>{72, 40}, {64, 128}, {128, 64}}) {
TestDisplay display(dimensions.first, dimensions.second);
mesh::ui::Pixel5Text text(display);
for (int c = 32; c <= 126; ++c) {
display.fills.clear();
text.setCursor(0, display.height() - 6);
text.printWordWrap(std::string(160, char(c)).c_str(), display.width());
for (const auto& pixel : display.fills) {
EXPECT_GE(pixel.x, 0); EXPECT_LT(pixel.x, display.width());
EXPECT_GE(pixel.y, display.height() - 6);
EXPECT_LT(pixel.y, display.height());
}
}
display.fills.clear();
text.setCursor(0, display.height() - 5);
text.printWordWrap("must not start a clipped final line", display.width());
EXPECT_TRUE(display.fills.empty());
}
}
TEST(DisplayDriver, QrCodeIsOptionalByDefault) {
TestDisplay display;
EXPECT_FALSE(display.drawQrCode("WIFI:T:nopass;S:MC-Set-90DF;;",
+7 -2
View File
@@ -67,7 +67,7 @@ def esp_fixture(path, modern=False, fragmented=False):
class FirmwareRamTest(unittest.TestCase):
def test_longer_display_previews_reserve_heap_and_contiguous_history(self):
defines = {"DISPLAY_CLASS": "SSD1306Display"}
defines = {"DISPLAY_CLASS": "SSD1306Display", "UI_SMALL_MESSAGE_FONT": 0}
base = ram.requirements("ESP32_PLATFORM", defines, "v4_companion")
expanded = ram.requirements("ESP32_PLATFORM", {
**defines, "UI_MSG_PREVIEW_SIZE": 161,
@@ -80,6 +80,10 @@ class FirmwareRamTest(unittest.TestCase):
**defines, "UI_MSG_PREVIEW_SIZE": 32,
}, "v4_companion")
self.assertEqual(smaller, base)
default = ram.requirements("ESP32_PLATFORM", {
"DISPLAY_CLASS": "SSD1306Display",
}, "v4_companion")
self.assertEqual(default, expanded)
def test_published_image_tables_match_elf_and_use_its_own_reservations(self):
with tempfile.TemporaryDirectory() as temp:
@@ -166,7 +170,8 @@ int main() {
subprocess.run([str(binary)], check=True)
def test_t096_release_fails_and_exact_boundary_passes(self):
flags = {"COMPANION_RADIO_FULL": 1, "DISPLAY_CLASS": "ST7735Display", "BLE_PIN_CODE": 123456}
flags = {"COMPANION_RADIO_FULL": 1, "DISPLAY_CLASS": "ST7735Display",
"BLE_PIN_CODE": 123456, "UI_SMALL_MESSAGE_FONT": 0}
with tempfile.TemporaryDirectory() as temp, contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
path = Path(temp) / "firmware.elf"
for available, accepted in ((54724, False), (73727, False), (73728, True), (74060, True)):
+2 -3
View File
@@ -3,8 +3,7 @@
Build a V4 environment first to install Adafruit GFX, or point
MESHCORE_GFX_LIBRARY to that library's directory. Only the hardware panel,
Arduino strings and Print glue are replaced; font metrics and drawing come
from the library used by the firmware.
Arduino strings and Print glue are replaced; the shared pixel renderer is compared with the original Adafruit font.
"""
import os
@@ -35,7 +34,7 @@ class SSD1306PicopixelTest(unittest.TestCase):
result = subprocess.run([
"c++", "-std=c++17", "-g",
"-fsanitize=address,undefined", "-DARDUINO=10819",
f"-DUI_SSD1306_PICOPIXEL_MESSAGES={enabled}",
f"-DUI_SMALL_MESSAGE_FONT={enabled}",
"-I", str(FIXTURE / "mocks"),
"-I", str(ROOT / "src"), "-I", str(library),
str(ROOT / "src/helpers/ui/SSD1306Display.cpp"),