From 061e5ba36b63dfc9b82e98200e5e57b5f446872f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:08:40 +0200 Subject: [PATCH] feat(l1): Home pages slide sideways, the page pill glides along DisplayDriver gains slideBegin()/slideCompose(): the driver keeps the frame on screen and, in the next one, moves it out while the new rows follow. SH1106 does it on its 1 KB buffer (+1 KB RAM for the copy), the sim on its canvas; drivers that can't read their frame back keep cutting over (e-ink). Home slides the content below the page-icon row in 220 ms with an ease-out (the whole screen to and from the header-less clock), and the current-page pill glides between icons. Co-Authored-By: Claude Opus 5.5 --- examples/companion_radio/ui-new/UITask.cpp | 70 ++++++++++++++++++---- src/helpers/ui/DisplayDriver.h | 8 +++ src/helpers/ui/SH1106Display.cpp | 31 ++++++++++ src/helpers/ui/SH1106Display.h | 5 ++ variants/sim/SimDisplayDriver.h | 22 +++++++ 5 files changed, 124 insertions(+), 12 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c57a1752..394594a4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -331,6 +331,38 @@ class HomeScreen : public UIScreen { uint8_t _prev_page; // home page restored when the device unlocks bool _shutdown_init; + // Sideways slide between pages (OLED; drivers that can't read their frame + // back just cut over). _slide_dir is +1 (new page from the right), -1, or 0. + static const unsigned long SLIDE_MS = 220; + int8_t _slide_dir = 0; + uint8_t _slide_from = 0; + unsigned long _slide_t0 = 0; + + // First row under the header and the page-icon row (see render()). + static int contentTop(DisplayDriver& d) { + const int pg_half = (5 * miniIconScale(d) + 1) / 2; + return d.getLineHeight() + 2 * pg_half + 4; + } + // 0..1 eased progress of the running slide, or -1 when none. + float slideProgress() { + if (!_slide_dir) return -1; + unsigned long el = millis() - _slide_t0; + if (el >= SLIDE_MS) { _slide_dir = 0; return -1; } + float t = 1.0f - (float)el / SLIDE_MS; + return 1.0f - t * t * t; // ease-out: quick start, soft landing + } + void turnPage(int dir) { + const uint8_t from = _page; + _page = navPage(_page, dir); + if (_page == from) return; + DisplayDriver* d = _task->getDisplay(); + // Pages with the header slide below it; the full-screen clock slides whole. + const bool whole = from == CLOCK || _page == CLOCK; + if (d && d->slideBegin(whole ? 0 : contentTop(*d))) { + _slide_dir = (int8_t)dir; _slide_from = from; _slide_t0 = millis(); + } + } + int pageBit(int page) const { if (page == CLOCK) return NodePrefs::HPB_CLOCK; if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES; @@ -720,7 +752,7 @@ public: int render(DisplayDriver& display) override { char tmp[80]; int mq_delay = 0; // >0 while a selected row's name is marquee-scrolling - int anim_ms = 0; // >0 while a hovering page icon is on screen + int anim_ms = 0; // >0 while something animates: a hovering page icon, a page turn display.setTextSize(1); const int lh = display.getLineHeight(); // line height at sz1 const int step = display.lineStep(); // lh + 2 @@ -729,7 +761,8 @@ public: // below (identical to the old lh+4 / +6 dots layout at 1x). const int pg_half = (5 * miniIconScale(display) + 1) / 2; const int dots_y = lh + pg_half + 1; // icon-row centre, below the header - const int content_y = dots_y + pg_half + 3; // first content row, below the icons + const int content_y = dots_y + pg_half + 3; // first content row, below the icons (= contentTop()) + const float slide = slideProgress(); // -1, or how far a page turn has got // Title bar displaying node name (except on lock screen), status icons and battery. // Hidden on fullscreen pages (CLOCK). @@ -799,19 +832,27 @@ public: if (fit < pitch) pitch = fit; } int x = display.width() / 2 - pitch * (n - 1) / 2; + // The current page: its icon knocked out of a soft pill. During a page + // turn the pill glides over from the page being left. + int pw = icon_w + 4; + if (pw > pitch - 1) pw = pitch - 1; + int pill_x = x + pitch * curr_vis; + if (slide >= 0) { + for (int i = 0; i < n; i++) + if (order[i] == _slide_from) { pill_x = x + pitch * i + (int)((pill_x - x - pitch * i) * slide + 0.5f); break; } + } + display.setColor(DisplayDriver::LIGHT); + display.fillSoftRect(pill_x - pw / 2, dots_y - pg_half - 1, pw, pg_half * 2 + 3); for (int i = 0; i < n; i++) { const MiniIcon* ic = pageIcon(order[i]); - if (i == curr_vis) { // the current page: icon knocked out of a soft pill - int pw = icon_w + 4; - if (pw > pitch - 1) pw = pitch - 1; - display.setColor(DisplayDriver::LIGHT); - display.fillSoftRect(x - pw / 2, dots_y - pg_half - 1, pw, pg_half * 2 + 3); - display.setColor(DisplayDriver::DARK); - } + const int d2 = 2 * (x - pill_x < 0 ? pill_x - x : x - pill_x); + 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); - display.setColor(DisplayDriver::LIGHT); x += pitch; } + display.setColor(DisplayDriver::LIGHT); } if (_page == HomePage::CLOCK) { @@ -1276,6 +1317,10 @@ public: display.drawTextCentered(display.width() / 2, text_y, "Hibernate"); } } + if (slide >= 0) { // page turn: the page left behind slides out, this one in + display.slideCompose(_slide_dir * (int)(display.width() * slide + 0.5f)); + anim_ms = 15; + } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; // Any blinking status-bar indicator needs a 1 s refresh to animate evenly — // but the status bar (and its icons) is hidden on the CLOCK page, so don't @@ -1291,6 +1336,7 @@ public: if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; int ret = need_blink ? 1000 : (show_sec ? 1000 : 60000); + if (anim_ms > 0 && anim_ms < ret) ret = anim_ms; return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } int ret = need_blink ? 1000 : 5000; @@ -1367,11 +1413,11 @@ public: } if (c == KEY_LEFT || c == KEY_PREV) { - _page = navPage(_page, -1); + turnPage(-1); return true; } if (c == KEY_NEXT || c == KEY_RIGHT) { - _page = navPage(_page, +1); + turnPage(+1); return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index f93436ae..098efc5a 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -533,6 +533,14 @@ 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) + // 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, + // moves the kept rows dx px left (right when dx < 0) and fills the gap with + // the new frame's rows sliding in behind them. A driver that can't read its + // frame back returns false and the screen just cuts over. + virtual bool slideBegin(int y0) { return false; } + virtual void slideCompose(int dx) { } virtual void endFrame() = 0; // Called from board setup to run safe background work during a blocking diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 2c827dfd..8f2c3555 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -1,4 +1,5 @@ #include "SH1106Display.h" +#include #include #include "Adafruit_SH110X.h" #include "MiscFixedRenderer.h" @@ -64,6 +65,36 @@ void SH1106Display::startFrame(Color bkg) display.cp437(true); // Use full 256 char 'Code Page 437' font } +// The GFX buffer still holds the last frame sent until the next startFrame(). +bool SH1106Display::slideBegin(int y0) +{ + _slide_y0 = y0 < 0 ? 0 : (y0 > height() ? height() : y0); + memcpy(_slide_old, display.getBuffer(), sizeof(_slide_old)); + return true; +} + +// Buffer layout: one byte per column per 8-row page, bit n = row page*8+n. +void SH1106Display::slideCompose(int dx) +{ + uint8_t* buf = display.getBuffer(); + const int W = width(); + int off = dx < 0 ? -dx : dx; + if (off > W) off = W; + uint8_t fresh[128]; + for (int p = _slide_y0 / 8; p < height() / 8; p++) { + const uint8_t keep = (p == _slide_y0 / 8) ? (uint8_t)~(0xFF << (_slide_y0 & 7)) : 0; + uint8_t* cur = buf + p * W; + const uint8_t* old = _slide_old + p * W; + memcpy(fresh, cur, W); + for (int x = 0; x < W; x++) { + uint8_t v; + if (dx >= 0) v = x < W - off ? old[x + off] : fresh[x - (W - off)]; + else v = x >= off ? old[x - off] : fresh[x + (W - off)]; + cur[x] = (cur[x] & keep) | (v & ~keep); + } + } +} + void SH1106Display::setTextSize(int sz) { _text_sz = sz; diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 431d71ed..7ccd0654 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -44,6 +44,9 @@ class SH1106Display : public DisplayDriver static const uint8_t BLOCKS = (128 / BLOCK_W) * (64 / 8); uint32_t _block_hash[BLOCKS] = {}; bool _force_redraw = true; + // slideBegin()'s copy of the frame on screen (only rows >= _slide_y0 are used). + uint8_t _slide_old[128 * 64 / 8]; + int _slide_y0 = 0; bool i2c_probe(TwoWire &wire, uint8_t addr); // Thin wrapper over the shared misc-fixed renderer (MiscFixedRenderer.h), kept @@ -61,6 +64,8 @@ public: void turnOff() override; void clear() override; void startFrame(Color bkg = DARK) override; + bool slideBegin(int y0) override; + void slideCompose(int dx) override; void setTextSize(int sz) override; void setColor(Color c) override; void setCursor(int x, int y) override; diff --git a/variants/sim/SimDisplayDriver.h b/variants/sim/SimDisplayDriver.h index f9e4d156..938d61fd 100644 --- a/variants/sim/SimDisplayDriver.h +++ b/variants/sim/SimDisplayDriver.h @@ -356,6 +356,28 @@ public: // codepoint and call getTextWidth() on it. uint16_t getCodepointWidth(uint32_t cp) override; + // The canvas is the frame (128x64 native, CSS-scaled), so the slide reads + // it back: the kept rows now, the new ones at compose time. + bool slideBegin(int y0) override { + EM_ASM({ + if (!Module.__simCtx) return; + Module.__simSlideY = $0; + Module.__simSlide = Module.__simCtx.getImageData(0, $0, 128, 64 - $0); + }, y0); + return true; + } + void slideCompose(int dx) override { + EM_ASM({ + var c = Module.__simCtx; + var old = Module.__simSlide; + if (!c || !old) return; + var y = Module.__simSlideY; + var fresh = c.getImageData(0, y, 128, 64 - y); + c.putImageData(old, -$0, y); + c.putImageData(fresh, $0 >= 0 ? 128 - $0 : -128 - $0, y); + }, dx); + } + // Every draw call above already lands directly on the visible canvas // (see the class comment) -- nothing left to flush. void endFrame() override { }