diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 71cec480..43db96a2 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -335,6 +335,11 @@ static void sanitize(NodePrefs& p) { flag(p.ble_off, 0); if (p.msg_alert > NodePrefs::MSG_ALERT_OFF) p.msg_alert = NodePrefs::MSG_ALERT_NORMAL; if (p.advert_loc_prec >= NodePrefs::ADV_LOC_PREC_COUNT) p.advert_loc_prec = 0; + if (p.ui_text_scale > NodePrefs::UI_TEXT_SCALE_MAX) p.ui_text_scale = 0; +#ifdef EINK_FONT_SCALE // the Text size row shows the scale in use: start at the build's + if (p.ui_text_scale == 0) p.ui_text_scale = EINK_FONT_SCALE; +#endif + if (p.ui_icon_scale > NodePrefs::UI_ICON_SCALE_MAX) p.ui_icon_scale = 0; if (!p.ch_scope_follow) { for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) if (p.ch_scope_idx[i] == 0) p.ch_scope_idx[i] = NodePrefs::CH_SCOPE_DEFAULT; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index f5f7d834..712f6364 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -544,6 +544,15 @@ struct NodePrefs { // persisted to file static const uint8_t ADV_LOC_PREC_COUNT = 4; uint8_t advert_loc_prec; + // ── Text and icon size (ui-new, big e-ink panels) ────────────────────── + // Settings > Display, only on builds that opt in (EINK_FONT_SCALE / + // STATUS_ICON_SCALE). ui_text_scale: the font's pixel multiplier, 1-5; 0 is + // the build's own (DataStore sanitize sets EINK_FONT_SCALE there). + // ui_icon_scale: Home's status icons, 0 = Auto (1.5x the text), else 1-8. + static const uint8_t UI_TEXT_SCALE_MAX = 5, UI_ICON_SCALE_MAX = 8; + uint8_t ui_text_scale; + uint8_t ui_icon_scale; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; diff --git a/examples/companion_radio/ui-core/SettingsCli.h b/examples/companion_radio/ui-core/SettingsCli.h index 7ba1abeb..e63e49a2 100644 --- a/examples/companion_radio/ui-core/SettingsCli.h +++ b/examples/companion_radio/ui-core/SettingsCli.h @@ -24,6 +24,7 @@ static const Key KEYS[] = { K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("dm.resend", dm_resend_count), K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake), K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact), + K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale), K("batt.display", batt_display_mode), K("batt.shutdown", low_batt_mv), K("gps.interval", gps_interval), K("clock.12h", clock_12h), K("clock.seconds", clock_hide_seconds), K("units.imperial", units_imperial), K("sound.volume", buzzer_volume), K("quiet", quiet_hours), K("quiet.from", quiet_from), K("quiet.until", quiet_to), diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 4ba39a72..7d01e001 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -144,6 +144,15 @@ static void optMsgAlert(uint8_t v, char* b, int n, const NodePrefs&) { static const char* const L[] = { "Normal", "Compact", "Off" }; snprintf(b, n, "%s", L[v < 3 ? v : 0]); } +#ifdef EINK_FONT_SCALE +static const int32_t TEXT_SCALE[] = { 1, 2, 3, 4, 5 }; +static void optTextScale(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%ldx", (long)TEXT_SCALE[v]); } +#endif +#ifdef STATUS_ICON_SCALE +static void optIconScale(uint8_t v, char* b, int n, const NodePrefs&) { + if (v == 0) snprintf(b, n, "Auto"); else snprintf(b, n, "%ux", (unsigned)v); +} +#endif static void optLockLook(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v ? "Compact" : "Big"); } static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); @@ -247,6 +256,15 @@ static const Setting ALL[] = { SW("Lock screen", "Slide to unlock after sleep", SEC_DISPLAY, auto_lock, nullptr), IDX("Lock clock", "Big, or compact with the clock fields", SEC_DISPLAY, lock_compact, 2, optLockLook, nullptr), IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr), + // Only the builds that ask for them (big e-ink panels): each screen elsewhere + // is laid out for one cell size. +#ifdef EINK_FONT_SCALE + MAP("Text size", "Bigger letters on a big panel", SEC_DISPLAY, ui_text_scale, TEXT_SCALE, optTextScale, applyDisplay), +#endif +#ifdef STATUS_ICON_SCALE + IDX("Icon size", "Home's status icons", SEC_DISPLAY, ui_icon_scale, NodePrefs::UI_ICON_SCALE_MAX + 1, + optIconScale, applyDisplay), +#endif MAP("Battery shutdown", "Power off below this voltage", SEC_POWER, low_batt_mv, LOW_BATT, optLowBatt, nullptr), MAP("GPS power saving", "Sleep between fixes", SEC_POWER, gps_interval, GPS_DUTY, optGpsDuty, applyGpsDuty), diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 91611c19..fdb11d16 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -352,8 +352,8 @@ class HomeScreen : public UIScreen { // First row under the header and the page-icon row (see render()). static int contentTop(DisplayDriver& d) { - const int pg_half = (PAGE_ICON_PX * miniIconScale(d) + 1) / 2; - return d.getLineHeight() + 2 * pg_half + 4; + const int pg_half = (PAGE_ICON_PX * statusIconScale(d) + 1) / 2; + return statusBarH(d) + 2 * pg_half + 4; } // 0..1 eased progress of the running slide, or -1 when none. float slideProgress() { @@ -739,8 +739,11 @@ class HomeScreen : public UIScreen { const int lh = display.getLineHeight(); const int cw = display.getCharWidth(); - const int ind = cw + 2; // single-char indicator width - const int ind_h = display.isSingleFont() ? lh - 2 : lh; + const int sis = statusIconScale(display); // STATUS_ICON_SCALE, or the font's + const int bar_h = statusBarH(display); + const int ind = STATUS_ICONS_SCALED ? 7 * sis + 2 : cw + 2; // indicator slot width + const int ind_h = (bar_h > lh) ? bar_h - 2 : (display.isSingleFont() ? lh - 2 : lh); + const int text_y = (bar_h > lh) ? (bar_h - lh) / 2 : 0; // % / V text centred in a tall bar const int ind_gap = display.pixelScale() > 1 ? 3 : 1; // gap between indicator boxes int battLeftX; @@ -748,17 +751,17 @@ class HomeScreen : public UIScreen { char buf[6]; snprintf(buf, sizeof(buf),"%d%%", pct); battLeftX = display.width() - display.getTextWidth(buf) - 1; - display.setCursor(battLeftX, 0); + display.setCursor(battLeftX, text_y); display.print(buf); } else if (mode == battery::VOLTAGE) { char buf[8]; snprintf(buf, sizeof(buf),"%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10); battLeftX = display.width() - display.getTextWidth(buf) - 1; - display.setCursor(battLeftX, 0); + display.setCursor(battLeftX, text_y); display.print(buf); } else { // icon — scales with lh, same box height as the status icons beside it (ind_h) const int iconH = ind_h; - const int iconW = lh * 2; + const int iconW = (bar_h > lh) ? ind_h * 2 : lh * 2; const int bm = display.pixelScale() > 1 ? 3 : 2; // inner margin: 3px on a doubled panel, else 2px battLeftX = display.width() - iconW - 3; // The outline with its corner pixels left out, softly rounded. @@ -824,7 +827,8 @@ class HomeScreen : public UIScreen { if (!s.active) continue; int ix = x - ind - ind_gap; if (ix < name_min) break; // out of room — drop this + all lower priority - drawSlotIcon(display, ix, ind, ind_h, *s.icon); + if (STATUS_ICONS_SCALED) drawSlotIconScaled(display, ix, ind, ind_h, *s.icon, sis); + else drawSlotIcon(display, ix, ind, ind_h, *s.icon); x = ix; } return x; @@ -1255,7 +1259,7 @@ public: const int W = d.width(), H = d.height(); d.setColor(DisplayDriver::LIGHT); d.setTextSize(1); - const int lh = d.getLineHeight(), top = lh + 3; + const int lh = d.getLineHeight(), top = statusBarH(d) + 3; const bool hint = Features::IS_EINK || _task->lockHintShown(); const int pill_y = H - lh - 3; // the bottom pill, clear of what's above const int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); @@ -1491,8 +1495,8 @@ public: // Page-indicator row: small page icons (PAGE_ICON_PX) replace the old dots. Centre and // gap scale with the font so the band clears the header above and content // below (identical to the old lh+4 / +6 dots layout at 1x). - const int pg_half = (PAGE_ICON_PX * miniIconScale(display) + 1) / 2; - const int dots_y = lh + pg_half + 1; // icon-row centre, below the header + const int pg_half = (PAGE_ICON_PX * statusIconScale(display) + 1) / 2; // page icons follow STATUS_ICON_SCALE + const int dots_y = statusBarH(display) + pg_half + 1; // icon-row centre, below the header const int content_y = contentTop(display); // first content row, below the icons const float slide = slideProgress(); // -1, or how far a page turn has got @@ -1507,6 +1511,7 @@ public: display.setColor(DisplayDriver::LIGHT); if (_page != LOCK) { + const int bar_ty = (statusBarH(display) - lh) / 2; // text centred in a tall bar (STATUS_ICON_SCALE) // The time, once the clock is set (the node name before that): the // name is what others see, the time is what you glance at here. char filtered_name[sizeof(_node_prefs->node_name)]; @@ -1532,10 +1537,10 @@ public: char pwr_buf[8]; snprintf(pwr_buf, sizeof(pwr_buf), "%ddB", (int)radio_driver.getTxPower()); int pwr_w = display.getTextWidth(pwr_buf); - display.drawTextEllipsized(0, 0, rightEdge - 2 - pwr_w - 2, filtered_name); - display.drawTextRightAlign(rightEdge - 2, 0, pwr_buf); + display.drawTextEllipsized(0, bar_ty, rightEdge - 2 - pwr_w - 2, filtered_name); + display.drawTextRightAlign(rightEdge - 2, bar_ty, pwr_buf); } else { - display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name); + display.drawTextEllipsized(0, bar_ty, rightEdge - 2, filtered_name); } } } @@ -1550,7 +1555,7 @@ public: int order[(int)Count]; int n = buildVisibleOrder(order); int curr_vis = 0; for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; } - const int s = miniIconScale(display); + const int s = statusIconScale(display); // STATUS_ICON_SCALE, or the font's const int icon_w = PAGE_ICON_PX * s; int pitch = icon_w + 5 * s; // comfortable spacing if (n > 1) { // shrink to fit if many pages @@ -1575,7 +1580,7 @@ public: if (d2 <= pw - icon_w) display.setColor(DisplayDriver::DARK); // inside the pill else if (d2 < pw + icon_w) { x += pitch; continue; } // half under the gliding pill else display.setColor(DisplayDriver::LIGHT); - if (ic) miniIconDrawCentered(display, x, dots_y, *ic); + if (ic) miniIconDrawCenteredScaled(display, x, dots_y, *ic, s); x += pitch; } display.setColor(DisplayDriver::LIGHT); @@ -2058,6 +2063,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no applyBrightness(); applyRotation(); applyFullRefreshInterval(); + applyUiScale(); // Settings > Display > Text size / Icon size applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use // Locked above (a PIN, or the cover closed) before Home existed to be told. if (_locked) syncLockToHome(); @@ -4157,6 +4163,19 @@ void UITask::applyFullRefreshInterval() { } } +// Text size and Icon size (Settings > Display, NodePrefs ui_text_scale / +// ui_icon_scale), on the builds that offer them. Elsewhere the fields stay 0: +// the build's font scale and Auto icons, as stock. +void UITask::applyUiScale() { + if (!_node_prefs) return; +#ifdef EINK_FONT_SCALE + if (_display != NULL) _display->setUiScale(_node_prefs->ui_text_scale); +#endif +#if STATUS_ICONS_SCALED + statusIconScaleSetting() = _node_prefs->ui_icon_scale; +#endif +} + void UITask::applySoundPrefs() { #ifdef PIN_BUZZER if (_node_prefs) setBuzzerVolumeLevel(_node_prefs->buzzer_volume); // with a sample at the new level diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 928c8a93..86fe04e5 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -528,7 +528,7 @@ public: void applyAllGpioModes(); // boot-time restore from NodePrefs, called from begin() void applyBrightness(); // Settings changed through the schema (ui-core/SettingsSchema.h). - void applyDisplayPrefs() override { applyBrightness(); _next_refresh = 0; } + void applyDisplayPrefs() override { applyBrightness(); applyUiScale(); _next_refresh = 0; } void applySoundPrefs() override; bool cliCommand(const char* command, char* reply, int n) override; void setBuzzerVolumeLevel(uint8_t level); @@ -543,6 +543,7 @@ public: bool savePrefsIfDirty(bool& dirty); void applyRotation(); void applyFullRefreshInterval(); + void applyUiScale(); // Settings > Display > Text size / Icon size, from the prefs uint32_t autoOffMillis() const { if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0; return (uint32_t)_node_prefs->auto_off_secs * 1000UL; diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index fd479f57..d9433bf3 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -91,6 +91,54 @@ inline void miniIconDrawCentered(DisplayDriver& d, int cx, int cy, const MiniIco miniIconDrawTop(d, cx - (ic.w * s) / 2, cy - (ic.h * s) / 2, ic); } +// Home status bar icon scale. Defining STATUS_ICON_SCALE (build flag) turns on +// scalable status icons: the bar's icons, battery and the page-icon row below it +// are drawn at n x their pixel grid, and the bar grows tall enough to hold them -- +// for big panels whose 1-line bar leaves them tiny -- and adds Settings > Display > +// Icon size (NodePrefs::ui_icon_scale): 0 = Auto (1.5x the text's pixel scale), +// else n. Unset: icons follow the font, as stock. +#ifdef STATUS_ICON_SCALE + #define STATUS_ICONS_SCALED 1 +#else + #define STATUS_ICONS_SCALED 0 +#endif +static constexpr int STATUS_ICON_ROWS = MINI_ICONS_LARGE == 2 ? 9 : 7; // tallest status icon +// The Icon size setting (0 = Auto), copied from the prefs by UITask::applyUiScale(). +// One instance across translation units. +inline uint8_t& statusIconScaleSetting() { + static uint8_t v = 0; + return v; +} +inline int statusIconScale(DisplayDriver& d) { + if (!STATUS_ICONS_SCALED) return miniIconScale(d); + int s = statusIconScaleSetting(); + if (s == 0) s = (3 * d.pixelScale() + 1) / 2; // Auto: 1.5x the text + return s < 1 ? 1 : s; +} +// Height of the home status bar: one text line, or the scaled icons if taller. +inline int statusBarH(DisplayDriver& d) { + const int lh = d.getLineHeight(); + const int ih = STATUS_ICON_ROWS * statusIconScale(d) + 4; + return (STATUS_ICONS_SCALED && ih > lh) ? ih : lh; +} +// miniIconDrawCentered at an explicit scale (the home page-icon row's). +inline void miniIconDrawCenteredScaled(DisplayDriver& d, int cx, int cy, const MiniIcon& ic, int s) { + const int x = cx - (ic.w * s) / 2, y = cy - (ic.h * s) / 2; + for (int r = 0; r < ic.h; r++) + for (int c = 0; c < ic.w; c++) + if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s); +} +// drawSlotIcon at an explicit scale (the status bar's). +inline void drawSlotIconScaled(DisplayDriver& d, int x, int box_w, int box_h, const MiniIcon& ic, int s) { + int ix = x + (box_w - ic.w * s) / 2; + int iy = (box_h - ic.h * s) / 2; + if (ix < x) ix = x; + if (iy < 0) iy = 0; + for (int r = 0; r < ic.h; r++) + for (int c = 0; c < ic.w; c++) + if (ic.rows[r] & (1 << c)) d.fillRect(ix + c * s, iy + r * s, s, s); +} + // Centre a mini-icon inside the slot [x, 0, box_w, box_h] using the current ink // colour (no background). Centres on the box itself, not the text line, so the // glyph sits dead-centre regardless of font line height. diff --git a/solo/wio-tracker-l1-gdeq0426/platformio.ini b/solo/wio-tracker-l1-gdeq0426/platformio.ini new file mode 100644 index 00000000..7bf26f25 --- /dev/null +++ b/solo/wio-tracker-l1-gdeq0426/platformio.ini @@ -0,0 +1,72 @@ +; Solo build for Seeed Wio Tracker L1 (Lite) + Good Display GDEQ0426T82 +; 4.26" 800x480 e-ink, plugged into the board's 24-pin e-ink FPC connector. +; Build config only -- no source changes. Extends WioTrackerL1Eink +; (variants/wio-tracker-l1-eink/platformio.ini) for its source files and +; libraries, but carries its own full build_flags list, so the stock panel's +; -D EINK_DISPLAY_MODEL / DISPLAY_ROTATION never reach the compiler too. +; +; - EINK_DISPLAY_MODEL GxEPD2 driver for this panel (GxEPD2 1.6.2+) +; - DISPLAY_ROTATION 0: the panel's native landscape +; - RAM: the 800x480 framebuffer is 48 KB (the stock 2.13" needs ~4 KB), +; and UiCore plus every screen is allocated from the heap at startup -- +; a failed allocation there crashes into a boot loop. So: +; OFFLINE_QUEUE_SIZE 32 (stock e-ink: 128) frees ~17 KB +; MAX_CONTACTS 250 (stock: 350) frees ~18 KB +; - EINK_LARGE_FONT 2: the 4.2" build's 9x15 font (and its 9 px icons) -- +; on hardware it reads much better here than the 6x9 scaled up, which goes +; blocky at 4x. +; - EINK_FONT_SCALE 2: that font at 2x (18x30 cells, ~44 characters x 16 +; lines). Also adds Settings > Display > Text size (1x-5x), which starts here. +; - STATUS_ICON_SCALE turns on scalable Home status/page icons and adds +; Settings > Display > Icon size (Auto = 1.5x the text: 3x at 2x text, 1x-8x). +; +; Build: pio run -e WioTrackerL1_GDEQ0426T82_companion_solo_dual +[env:WioTrackerL1_GDEQ0426T82_companion_solo_dual] +extends = WioTrackerL1Eink +build_unflags = -Ofast +build_flags = ${nrf52_base.build_flags} + ${sensor_base.build_flags} + -I lib/nrf52/s140_nrf52_7.3.0_API/include + -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 + -I variants/wio-tracker-l1 + -I examples/companion_radio/ui-new + -I lib/GxEPD2-patch/src + -D WIO_TRACKER_L1 + -D WIO_TRACKER_L1_EINK + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D EINK_DISPLAY_MODEL=GxEPD2_426_GDEQ0426T82 + -D DISPLAY_ROTATION=0 + -D JOYSTICK_UPRIGHT_ROTATION=0 + -D EINK_LARGE_FONT=2 + -D EINK_FONT_SCALE=2 + -D STATUS_ICON_SCALE=0 + -D GPS_BAUD_RATE=9600 + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL + -D DISABLE_DIAGNOSTIC_OUTPUT + -D AUTO_OFF_MILLIS=0 + -D MAX_CONTACTS=250 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=32 + -D DISPLAY_CLASS=GxEPDDisplay + -D BUTTON_USE_INTERRUPTS=1 + -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 + -D PIN_BUZZER=12 + -D QSPIFLASH=1 + -D FIRMWARE_SOLO_BUILD=1 + -D MESHCORE_VERSION='"1.17.1"' + -D UI_SENSORS_PAGE=1 + -D BLE_PIN_CODE=123456 + -D DUAL_SERIAL=1 + -D ENABLE_SCREENSHOT + -D KEEP_DISPLAY_ON_USB + -D EINK_FAST_WRITES=1 + -D EINK_SPI_HZ=8000000 + -Os +extra_scripts = post:create-uf2.py diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 0cb3709b..e2578ae6 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -536,6 +536,7 @@ public: virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never) + virtual void setUiScale(uint8_t font_scale) { } // e-ink: text pixel multiplier, 0 = build default // Sideways slide between two frames, for screens that page left/right. // slideBegin() keeps rows y0.. of the frame on screen now; call it before the // next frame is drawn. slideCompose(dx), at the end of drawing that frame, diff --git a/src/helpers/ui/EinkGfxDisplay.h b/src/helpers/ui/EinkGfxDisplay.h index e52fb1a1..822a61f1 100644 --- a/src/helpers/ui/EinkGfxDisplay.h +++ b/src/helpers/ui/EinkGfxDisplay.h @@ -29,7 +29,8 @@ class EinkGfxDisplay : public DisplayDriver { uint16_t _curr; const GFXfont* _font; int _cell_w = 6, _cell_h = 9; // the font's cell, before _px and the text size - int _px = 1; // pixel doubling (landscape without EINK_LARGE_FONT) + int _px = 1; // pixel multiplier (landscape: EINK_FONT_PX, or Text size) + uint8_t _px_override = 0; // Settings > Display > Text size (0 = build default) int _text_sz = 1; int _cx = 0, _cy = 0; // cursor, top of the text row uint32_t _hash = 0, _shown_hash = 0; @@ -74,6 +75,13 @@ public: dest[dest_size - 1] = '\0'; } bool isEink() override { return true; } + // Runtime text scale (Settings > Display > Text size): re-lays out and forces + // a full redraw. 0 = the build default (EINK_FONT_SCALE). + void setUiScale(uint8_t font_scale) override { + if (font_scale == _px_override) return; // unchanged: no full redraw + _px_override = font_scale; + updateLayout(); + } void startFrame(Color bkg = DARK) override; void setTextSize(int sz) override; diff --git a/src/helpers/ui/EinkGfxDisplayImpl.h b/src/helpers/ui/EinkGfxDisplayImpl.h index 884e05a9..16b3aee4 100644 --- a/src/helpers/ui/EinkGfxDisplayImpl.h +++ b/src/helpers/ui/EinkGfxDisplayImpl.h @@ -3,6 +3,16 @@ // the font tables below are static arrays. #include "EinkGfxDisplay.h" +// EINK_FONT_SCALE=: pixel multiplier for the landscape font -- the 6x9, or +// the EINK_LARGE_FONT one if the build picks it. 4 on an 800x480 panel with the +// 6x9 = 24x36 cells, ~33 characters x 13 lines. Setting it also adds Settings > +// Display > Text size, which overrides it at run time. Unset: stock, the 6x9 at +// 2x or the large font at its own size. +#ifdef EINK_FONT_SCALE + #define EINK_FONT_PX EINK_FONT_SCALE +#else + #define EINK_FONT_PX (EINK_LARGE_FONT ? 1 : 2) +#endif #include "MiscFixedRenderer.h" #if EINK_LARGE_FONT == 2 #include "MiscFixed9x15Font.h" @@ -20,7 +30,8 @@ EinkGfxDisplay::EinkGfxDisplay(int w, int h, Adafruit_GFX& gfx, uint16_t ink, ui void EinkGfxDisplay::updateLayout() { const bool landscape = width() >= height(); _font = (EINK_LARGE_FONT && landscape) ? &EINK_LARGE_GFXFONT : &MiscFixed; - _px = (!EINK_LARGE_FONT && landscape) ? 2 : 1; + _px = landscape ? EINK_FONT_PX : 1; + if (_px_override && landscape) _px = _px_override; // portrait screens are laid out for 1x _cell_w = miscFixedCellW(*_font); _cell_h = _font->yAdvance; _vw_dirty = true; @@ -87,7 +98,7 @@ void EinkGfxDisplay::fillRect(int x, int y, int w, int h) { void EinkGfxDisplay::drawRect(int x, int y, int w, int h) { mix(x); mix(y); mix(w); mix(h); _gfx.drawRect(x, y, w, h, _curr); - if (_px == 2 && w > 2 && h > 2) _gfx.drawRect(x + 1, y + 1, w - 2, h - 2, _curr); + if (_px >= 2 && w > 2 && h > 2) _gfx.drawRect(x + 1, y + 1, w - 2, h - 2, _curr); } void EinkGfxDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { diff --git a/src/helpers/ui/GxEPD2_FastWrite.h b/src/helpers/ui/GxEPD2_FastWrite.h new file mode 100644 index 00000000..8bc74838 --- /dev/null +++ b/src/helpers/ui/GxEPD2_FastWrite.h @@ -0,0 +1,110 @@ +#pragma once +// Faster image writes for large GxEPD2 panels. +// +// GxEPD2 sends the framebuffer to the panel one byte at a time: every byte is +// its own SPI.transfer(uint8_t) call, and on nRF52 each of those is a complete +// EasyDMA transaction with several microseconds of fixed setup overhead -- +// independent of the SPI clock. A partial refresh here writes the full screen +// five times (GxEPDDisplay::endFrame's inverse-redrive pass, then +// GxEPD2_BW::display()), so on an 800x480 panel (48 KB a screen) that overhead +// alone blocks the main loop for well over a second per refresh. +// +// These subclasses override only the image writes, sending each row as one +// bulk SPI.transfer(buf, nullptr, n) -- the same bytes, in the same order, to +// the same RAM window -- so the cost becomes just the wire time. Everything +// else (init, refresh, power) is the stock driver. The first write (panel init +// and the initial screen clear) and the rarely used mirror/PGM variants go +// through the stock path unchanged. +// +// Use by naming the subclass as the panel: -D EINK_DISPLAY_MODEL=GxEPD2_426_GDEQ0426T82_Fast + +#include + +#ifdef _GxEPD2_426_GDEQ0426T82_H_ +class GxEPD2_426_GDEQ0426T82_Fast : public GxEPD2_426_GDEQ0426T82 { + typedef GxEPD2_426_GDEQ0426T82 Base; + uint8_t _row[WIDTH / 8]; // one panel row, in RAM (EasyDMA can't read flash) + + bool canFast(bool mirror_y, bool pgm) const { + return _init_display_done && !_initial_write && !mirror_y && !pgm; + } + + // Same RAM window as the stock driver's (private) _setPartialRamArea(): + // the gates are reversed on this panel, so y is mirrored and entered decreasing. + // Copied from GxEPD2 1.6.2 (src/gdeq/GxEPD2_426_GDEQ0426T82.cpp) -- when + // bumping GxEPD2, check the library's version still matches this one. + void setRamArea(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + y = HEIGHT - y - h; + _writeCommand(0x11); + _writeData(0x01); + _writeCommand(0x44); + _writeData(x % 256); + _writeData(x / 256); + _writeData((x + w - 1) % 256); + _writeData((x + w - 1) / 256); + _writeCommand(0x45); + _writeData((y + h - 1) % 256); + _writeData((y + h - 1) / 256); + _writeData(y % 256); + _writeData(y / 256); + _writeCommand(0x4e); + _writeData(x % 256); + _writeData(x / 256); + _writeCommand(0x4f); + _writeData((y + h - 1) % 256); + _writeData((y + h - 1) / 256); + } + + // The stock _writeImage(), with the per-byte loop replaced by one bulk + // transfer per row (clipping and indexing unchanged). Also from GxEPD2 1.6.2. + void fastWrite(uint8_t command, const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, bool invert) { + delay(1); // as the stock driver: yield + int16_t wb = (w + 7) / 8; + x -= x % 8; + w = wb * 8; + int16_t x1 = x < 0 ? 0 : x; + int16_t y1 = y < 0 ? 0 : y; + int16_t w1 = x + w < int16_t(WIDTH) ? w : int16_t(WIDTH) - x; + int16_t h1 = y + h < int16_t(HEIGHT) ? h : int16_t(HEIGHT) - y; + int16_t dx = x1 - x; + int16_t dy = y1 - y; + w1 -= dx; + h1 -= dy; + if ((w1 <= 0) || (h1 <= 0)) return; + setRamArea(x1, y1, w1, h1); + _writeCommand(command); + _startTransfer(); + const int16_t nb = w1 / 8; + for (int16_t i = 0; i < h1; i++) { + const uint8_t* src = &bitmap[dx / 8 + int32_t(i + dy) * wb]; + if (invert) { for (int16_t j = 0; j < nb; j++) _row[j] = ~src[j]; } + else { memcpy(_row, src, nb); } + _pSPIx->transfer(_row, nullptr, nb); + } + _endTransfer(); + delay(1); + } + +public: + GxEPD2_426_GDEQ0426T82_Fast(int16_t cs, int16_t dc, int16_t rst, int16_t busy) : Base(cs, dc, rst, busy) {} + using Base::writeImage; // keep the other overloads visible + + void writeImage(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, + bool invert = false, bool mirror_y = false, bool pgm = false) override { + if (!canFast(mirror_y, pgm)) { Base::writeImage(bitmap, x, y, w, h, invert, mirror_y, pgm); return; } + fastWrite(0x24, bitmap, x, y, w, h, invert); + } + void writeImageForFullRefresh(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, + bool invert = false, bool mirror_y = false, bool pgm = false) override { + if (!canFast(mirror_y, pgm)) { Base::writeImageForFullRefresh(bitmap, x, y, w, h, invert, mirror_y, pgm); return; } + fastWrite(0x26, bitmap, x, y, w, h, invert); // set previous + fastWrite(0x24, bitmap, x, y, w, h, invert); // set current + } + void writeImageAgain(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, + bool invert = false, bool mirror_y = false, bool pgm = false) override { + if (!canFast(mirror_y, pgm)) { Base::writeImageAgain(bitmap, x, y, w, h, invert, mirror_y, pgm); return; } + fastWrite(0x26, bitmap, x, y, w, h, invert); // set previous + fastWrite(0x24, bitmap, x, y, w, h, invert); // set current + } +}; +#endif diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 7548a69e..510dfd84 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -14,8 +14,16 @@ SPIClass SPI1 = SPIClass(FSPI); #endif +// SPI clock for the panel. 4 MHz is the conservative default every board has +// always used; a short FPC run can go faster (SSD16xx-family controllers are +// rated ~20 MHz for writes). On nRF52840 the Adafruit core puts SPI1 on SPIM2, +// which caps at 8 MHz -- higher requests are silently clamped to 8 MHz there. +#ifndef EINK_SPI_HZ + #define EINK_SPI_HZ 4000000 +#endif + bool GxEPDDisplay::begin() { - display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); + display.epd2.selectSPI(SPI1, SPISettings(EINK_SPI_HZ, MSBFIRST, SPI_MODE0)); #ifdef ESP32 SPI1.begin(PIN_DISPLAY_SCLK, PIN_DISPLAY_MISO, PIN_DISPLAY_MOSI, PIN_DISPLAY_CS); #else diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index ffb5e96b..89f58610 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -18,6 +18,18 @@ #include #include "EinkGfxDisplay.h" +#include "GxEPD2_FastWrite.h" + +// EINK_FAST_WRITES=1: drive the panel through its _Fast subclass +// (GxEPD2_FastWrite.h) -- the same bytes, sent a row at a time in bulk instead +// of one SPI call per byte. Only for panels that have one. +#define EINK_CAT_(a, b) a##b +#define EINK_CAT(a, b) EINK_CAT_(a, b) +#if defined(EINK_DISPLAY_MODEL) && defined(EINK_FAST_WRITES) && EINK_FAST_WRITES + #define EINK_PANEL_TYPE EINK_CAT(EINK_DISPLAY_MODEL, _Fast) +#elif defined(EINK_DISPLAY_MODEL) + #define EINK_PANEL_TYPE EINK_DISPLAY_MODEL +#endif // This driver calls callBusyPump() during its BUSY-pin waits; app code keys // its setBusyPumpFn() wiring off this so it isn't compiled for displays (or @@ -53,8 +65,8 @@ // The panel object, as a base so it's built before EinkGfxDisplay draws on it. struct GxEPDPanel { #if defined(EINK_DISPLAY_MODEL) - GxEPD2_BW display; - GxEPDPanel() : display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {} + GxEPD2_BW display; + GxEPDPanel() : display(EINK_PANEL_TYPE(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {} #else GxEPD2_BW display; GxEPDPanel() : display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {}