diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 051f3b31ea..3f60f882db 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -493,12 +493,17 @@ class MsgPreviewScreen : public UIScreen { struct MsgEntry { uint32_t timestamp; char origin[62]; - char msg[78]; + char msg[MAX_TEXT_LEN + 1]; }; #define MAX_UNREAD_MSGS 32 + #define MSG_SCROLL_INTERVAL_MS 2500 int num_unread; int head = MAX_UNREAD_MSGS - 1; // index of latest unread message MsgEntry unread[MAX_UNREAD_MSGS]; + int msg_scroll_offset = 0; + unsigned long msg_last_scroll_ms = 0; + int msg_total_lines = 1; + int msg_visible_lines = 1; public: MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; } @@ -506,6 +511,8 @@ class MsgPreviewScreen : public UIScreen { void addPreview(uint8_t path_len, const char* from_name, const char* msg) { head = (head + 1) % MAX_UNREAD_MSGS; if (num_unread < MAX_UNREAD_MSGS) num_unread++; + msg_scroll_offset = 0; + msg_last_scroll_ms = millis(); auto p = &unread[head]; p->timestamp = _rtc->getCurrentTime(); @@ -550,7 +557,19 @@ class MsgPreviewScreen : public UIScreen { display.setColor(UIColor::primary_txt); char filtered_msg[sizeof(p->msg)]; display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); - display.printWordWrap(filtered_msg, display.width()); + + int visible_lines = 1; + int total_lines = display.printWordWrapScrolled(filtered_msg, display.width(), + display.height() - 25, msg_scroll_offset, &visible_lines); + msg_total_lines = total_lines; + msg_visible_lines = visible_lines; + + unsigned long now = millis(); + if (total_lines > visible_lines && now - msg_last_scroll_ms >= MSG_SCROLL_INTERVAL_MS) { + msg_last_scroll_ms = now; + msg_scroll_offset += visible_lines; // page forward a full screen at a time + if (msg_scroll_offset >= total_lines) msg_scroll_offset = 0; // loop back to the start + } #if AUTO_OFF_MILLIS==0 // probably e-ink return 10000; // 10 s @@ -563,6 +582,8 @@ class MsgPreviewScreen : public UIScreen { if (c == KEY_NEXT || c == KEY_RIGHT) { head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS; num_unread--; + msg_scroll_offset = 0; + msg_last_scroll_ms = millis(); if (num_unread == 0) { _task->gotoHomeScreen(); } @@ -573,6 +594,22 @@ class MsgPreviewScreen : public UIScreen { _task->gotoHomeScreen(); return true; } + if (c == KEY_DOWN || c == KEY_UP) { + if (msg_total_lines > msg_visible_lines) { + if (c == KEY_DOWN) { + msg_scroll_offset += msg_visible_lines; + if (msg_scroll_offset >= msg_total_lines) msg_scroll_offset = 0; + } else { + msg_scroll_offset -= msg_visible_lines; + if (msg_scroll_offset < 0) { + // wrap to the last page + msg_scroll_offset = ((msg_total_lines - 1) / msg_visible_lines) * msg_visible_lines; + } + } + msg_last_scroll_ms = millis(); // manual scroll resets the auto-scroll timer + } + return true; + } return false; } }; @@ -754,6 +791,18 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_RIGHT); } + ev = joystick_up.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_UP); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_UP); + } + ev = joystick_down.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_DOWN); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_DOWN); + } ev = back_btn.check(); if (ev == BUTTON_EVENT_TRIPLE_CLICK) { c = handleTripleClick(KEY_SELECT); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index b76a1b6ca0..de693fe8ee 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -23,6 +23,7 @@ class DisplayDriver { virtual bool isOn() = 0; virtual bool isEink() { return false; } // default to non-eink, override in eink drivers + virtual bool supportsUTF8() { return false; } // true if print()/getTextWidth() can render non-ASCII UTF-8 directly virtual void turnOn() = 0; virtual void turnOff() = 0; virtual void clear() = 0; @@ -32,6 +33,20 @@ class DisplayDriver { virtual void setCursor(int x, int y) = 0; virtual void print(const char* str) = 0; virtual void printWordWrap(const char* str, int max_width) { print(str); } // fallback to basic print() if no override + + // Word-wraps str and draws only the lines that fall within [start_line, + // start_line + visible_lines) of the wrapped result, where visible_lines + // is however many lines fit in max_height (driver-specific font metrics). + // Returns the total number of wrapped lines, and if out_visible_lines is + // non-null, writes the visible-line capacity into it -- together these let + // a caller detect "there's more than fits" and page/scroll through it. + // Default falls back to plain printWordWrap() for drivers that don't + // support paging, reporting everything as fitting in one page. + virtual int printWordWrapScrolled(const char* str, int max_width, int max_height, int start_line, int* out_visible_lines = nullptr) { + printWordWrap(str, max_width); + if (out_visible_lines) *out_visible_lines = 1; + return 1; + } virtual void fillRect(int x, int y, int w, int h) = 0; virtual void drawRect(int x, int y, int w, int h) = 0; virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0; @@ -53,6 +68,13 @@ class DisplayDriver { // convert UTF-8 characters to displayable block characters for compatibility virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { + if (supportsUTF8()) { // driver can render UTF-8 directly, pass through unchanged + size_t len = strlen(src); + if (len >= dest_size) len = dest_size - 1; + memcpy(dest, src, len); + dest[len] = 0; + return; + } size_t j = 0; for (size_t i = 0; src[i] != 0 && j < dest_size - 1; i++) { unsigned char c = (unsigned char)src[i]; @@ -97,7 +119,12 @@ class DisplayDriver { int str_len = strlen(temp_str); while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) { - temp_str[--str_len] = 0; + // drop one full UTF-8 character: continuation bytes (10xxxxxx) plus the lead byte + unsigned char b; + do { + b = (unsigned char)temp_str[--str_len]; + temp_str[str_len] = 0; + } while (str_len > 0 && (b & 0xC0) == 0x80); } strcat(temp_str, ellipsis); diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 8b91d857c0..afd9dda105 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -20,6 +20,16 @@ ColorVal UIColor::popup_bkg = SH110X_BLACK; ColorVal UIColor::popup_txt = SH110X_WHITE; ColorVal UIColor::corp_blue = SH110X_WHITE; +#ifdef DISPLAY_UTF8_FONTS +static bool hasNonASCII(const char *str) +{ + for (const char *p = str; *p; p++) { + if ((unsigned char)*p >= 0x80) return true; + } + return false; +} +#endif + bool SH1106Display::begin() { // Wire must already be initialised by board.begin() before this is called. @@ -38,6 +48,9 @@ bool SH1106Display::begin() // spi_dev NULL, and UITask::begin() calls turnOn() regardless of our // return value, which then dereferences the null spi_dev. bool ok = display.begin(addr ? addr : DISPLAY_ADDRESS, true); +#ifdef DISPLAY_UTF8_FONTS + _u8f.begin(display); +#endif return addr != 0 && ok; } @@ -64,13 +77,26 @@ void SH1106Display::startFrame(ColorVal bkg) display.clearDisplay(); // TODO: apply 'bkg' _color = SH110X_WHITE; display.setTextColor(_color); - display.setTextSize(1); + setTextSize(1); display.cp437(true); // Use full 256 char 'Code Page 437' font } void SH1106Display::setTextSize(int sz) { display.setTextSize(sz); +#ifdef DISPLAY_UTF8_FONTS + // u8g2 only ships Cyrillic glyphs in a couple of fixed-size fonts; match + // the closest one to the scaled built-in 5x7 GFX font (6px/8px line at + // size 1, 12x16 at size 2+ -- 10x20 is the largest Cyrillic font available) + if (sz <= 1) { + _u8f.setFont(u8g2_font_6x12_t_cyrillic); + _fontHeight = 12; + } else { + _u8f.setFont(u8g2_font_10x20_t_cyrillic); + _fontHeight = 20; + } + _u8f.setFontMode(1); // must follow setFont(): setFont() resets to solid mode, which paints each glyph's background box +#endif } void SH1106Display::setColor(ColorVal c) @@ -86,9 +112,168 @@ void SH1106Display::setCursor(int x, int y) void SH1106Display::print(const char *str) { +#ifdef DISPLAY_UTF8_FONTS + if (hasNonASCII(str)) { + printUTF8(str); + return; + } +#endif display.print(str); } +#ifdef DISPLAY_UTF8_FONTS +// Renders str through the U8g2 overlay one UTF-8 character at a time, so it +// can wrap at the right edge the same way Adafruit_GFX::print() does for the +// ASCII path. u8g2 draws from the text baseline while Adafruit_GFX's cursor +// is top-left, so the cursor is offset by the font ascent going in and out. +void SH1106Display::printUTF8(const char *str) +{ + _u8f.setForegroundColor(_color); + int16_t ascent = _u8f.getFontAscent(); + _u8f.setCursor(display.getCursorX(), display.getCursorY() + ascent); + int16_t line_height = ascent - _u8f.getFontDescent() + 1; + + char glyph[5]; + for (const char *p = str; *p; ) { + if (*p == '\n') { + _u8f.setCursor(0, _u8f.getCursorY() + line_height); + p++; + continue; + } + int n = 1; + while (n < 4 && (p[n] & 0xC0) == 0x80) n++; // include UTF-8 continuation bytes + memcpy(glyph, p, n); + glyph[n] = 0; + + if (_u8f.getCursorX() + _u8f.getUTF8Width(glyph) > display.width()) { + _u8f.setCursor(0, _u8f.getCursorY() + line_height); + } + _u8f.print(glyph); + p += n; + } + display.setCursor(_u8f.getCursorX(), _u8f.getCursorY() - ascent); +} + +// Greedy word wrap, measured and drawn entirely through the U8g2 overlay so +// wrap metrics stay consistent whether the text is ASCII, Cyrillic, or both. +// Splits on ASCII spaces only, which is UTF-8 safe since continuation bytes +// are always >= 0x80 and can never be mistaken for a space. A single word +// wider than max_width on its own is split mid-word at a clean UTF-8 +// boundary instead of running off the edge. +// +// Only lines in [start_line, start_line + max_lines) are actually drawn, but +// every wrapped line is still counted -- the return value is always the +// *total* line count, letting callers (via printWordWrapScrolled) detect +// there's more content than fits and page/scroll through it. +int SH1106Display::wordWrapLines(const char *str, int max_width, int start_line, int max_lines) +{ + char line[256]; + size_t line_len = 0; + int x0 = display.getCursorX(); + int16_t ascent = _u8f.getFontAscent(); + int y = display.getCursorY() + ascent; // baseline for the first line + int line_index = 0; + const char *word_start = str; + + _u8f.setForegroundColor(_color); + + auto flush_line = [&]() { + if (line_len > 0) { + if (line_index >= start_line && line_index < start_line + max_lines) { + line[line_len] = 0; + _u8f.setCursor(x0, y); + _u8f.print(line); + y += _fontHeight; + } + line_index++; + line_len = 0; + } + }; + + while (true) { + const char *word_end = word_start; + while (*word_end && *word_end != ' ') word_end++; + size_t word_len = word_end - word_start; + if (word_len > sizeof(line) - 1) word_len = sizeof(line) - 1; + + if (word_len > 0) { + char word_buf[256]; + memcpy(word_buf, word_start, word_len); + word_buf[word_len] = 0; + + if ((int)_u8f.getUTF8Width(word_buf) > max_width) { + // word alone is too wide: flush, then break it across as many + // fresh lines as needed + flush_line(); + const char *wp = word_start; + size_t remaining = word_len; + while (remaining > 0) { + size_t take = remaining; + char chunk[256]; + while (take > 0) { + memcpy(chunk, wp, take); + chunk[take] = 0; + if ((int)_u8f.getUTF8Width(chunk) <= max_width) break; + size_t shrink = take - 1; + while (shrink > 0 && (((unsigned char)wp[shrink]) & 0xC0) == 0x80) shrink--; + take = shrink; + } + if (take == 0) { + // even one character doesn't fit (degenerate max_width); force + // progress but keep it on a full UTF-8 boundary + take = 1; + while (take < remaining && (((unsigned char)wp[take]) & 0xC0) == 0x80) take++; + } + + memcpy(line, wp, take); + line_len = take; + flush_line(); + + wp += take; + remaining -= take; + } + } else { + char candidate[256]; + size_t candidate_len = line_len; + memcpy(candidate, line, line_len); + if (line_len > 0) candidate[candidate_len++] = ' '; + memcpy(candidate + candidate_len, word_start, word_len); + candidate_len += word_len; + candidate[candidate_len] = 0; + + if (line_len > 0 && (int)_u8f.getUTF8Width(candidate) > max_width) { + flush_line(); + memcpy(line, word_start, word_len); + line_len = word_len; + } else { + memcpy(line, candidate, candidate_len); + line_len = candidate_len; + } + } + } + + if (*word_end == 0) break; + word_start = word_end + 1; + } + + flush_line(); + return line_index; +} + +void SH1106Display::printWordWrap(const char *str, int max_width) +{ + wordWrapLines(str, max_width, 0, 1000); // effectively unbounded line count +} + +int SH1106Display::printWordWrapScrolled(const char *str, int max_width, int max_height, int start_line, int *out_visible_lines) +{ + int max_lines = max_height / _fontHeight; + if (max_lines < 1) max_lines = 1; + if (out_visible_lines) *out_visible_lines = max_lines; + return wordWrapLines(str, max_width, start_line, max_lines); +} +#endif + void SH1106Display::fillRect(int x, int y, int w, int h) { display.fillRect(x, y, w, h, _color); @@ -106,6 +291,11 @@ void SH1106Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) uint16_t SH1106Display::getTextWidth(const char *str) { +#ifdef DISPLAY_UTF8_FONTS + if (hasNonASCII(str)) { + return _u8f.getUTF8Width(str); + } +#endif int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 4e269d5ea5..0d3dab46e8 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -6,6 +6,10 @@ #define SH110X_NO_SPLASH #include +#ifdef DISPLAY_UTF8_FONTS + #include +#endif + #ifndef PIN_OLED_RESET #define PIN_OLED_RESET -1 #endif @@ -22,11 +26,24 @@ class SH1106Display : public DisplayDriver bool i2c_probe(TwoWire &wire, uint8_t addr); +#ifdef DISPLAY_UTF8_FONTS + U8G2_FOR_ADAFRUIT_GFX _u8f; // Unicode-capable overlay renderer (Cyrillic etc), layered on the Adafruit_GFX framebuffer + uint8_t _fontHeight; + void printUTF8(const char *str); + // Word-wraps through _u8f so wrap metrics stay consistent for both ASCII and + // non-ASCII text; draws only lines in [start_line, start_line + max_lines) + // at the current cursor, but always returns the *total* wrapped line count. + int wordWrapLines(const char *str, int max_width, int start_line, int max_lines); +#endif + public: SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; } bool begin(); bool isOn() override { return _isOn; } +#ifdef DISPLAY_UTF8_FONTS + bool supportsUTF8() override { return true; } +#endif void turnOn() override; void turnOff() override; void clear() override; @@ -35,6 +52,10 @@ class SH1106Display : public DisplayDriver void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; +#ifdef DISPLAY_UTF8_FONTS + void printWordWrap(const char *str, int max_width) override; + int printWordWrapScrolled(const char *str, int max_width, int max_height, int start_line, int* out_visible_lines = nullptr) 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; diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index 8bfc4093ac..94df90b43c 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -30,6 +30,7 @@ build_flags = -D TELEM_BME280_ADDRESS=0x77 -D ENV_INCLUDE_GPS=1 -D ENV_INCLUDE_BME280=1 + -D DISPLAY_UTF8_FONTS build_src_filter = ${esp32_base.build_src_filter} +<../variants/lilygo_tbeam_supreme_SX1262> + @@ -42,6 +43,8 @@ lib_deps = ${esp32_base.lib_deps} lewisxhe/XPowersLib @ ^0.2.7 adafruit/Adafruit SH110X @ ^2.1.13 + adafruit/Adafruit GFX Library @ ^1.12.1 + olikraus/U8g2_for_Adafruit_GFX @ ^1.8.0 stevemarple/MicroNMEA @ ^2.0.6 adafruit/Adafruit BME280 Library @ ^2.3.0 diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index fc958ea2d2..22a1a836f6 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -16,6 +16,7 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_RX_BOOSTED_GAIN=1 -D PIN_OLED_RESET=-1 -D GPS_BAUD_RATE=9600 + -D DISPLAY_UTF8_FONTS build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/wio-tracker-l1> @@ -25,6 +26,7 @@ lib_deps= ${nrf52_base.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit SH110X @ ^2.1.13 adafruit/Adafruit GFX Library @ ^1.12.1 + olikraus/U8g2_for_Adafruit_GFX @ ^1.8.0 [env:WioTrackerL1_repeater] extends = WioTrackerL1 diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 7a573258e6..e03e4e1042 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -24,6 +24,8 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton user_btn(PIN_USER_BTN, 1000, true, false, false); MomentaryButton joystick_left(JOYSTICK_LEFT, 1000, true, false, false); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); + MomentaryButton joystick_up(JOYSTICK_UP, 1000, true, false, false); + MomentaryButton joystick_down(JOYSTICK_DOWN, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); #endif diff --git a/variants/wio-tracker-l1/target.h b/variants/wio-tracker-l1/target.h index 05bc7ff1ca..e1c2427dc3 100644 --- a/variants/wio-tracker-l1/target.h +++ b/variants/wio-tracker-l1/target.h @@ -27,6 +27,8 @@ extern EnvironmentSensorManager sensors; extern MomentaryButton user_btn; extern MomentaryButton joystick_left; extern MomentaryButton joystick_right; + extern MomentaryButton joystick_up; + extern MomentaryButton joystick_down; extern MomentaryButton back_btn; #endif