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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-01 23:08:40 +02:00
co-authored by Claude Opus 5.5
parent 608231ed6b
commit 061e5ba36b
5 changed files with 124 additions and 12 deletions
+58 -12
View File
@@ -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) {
+8
View File
@@ -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
+31
View File
@@ -1,4 +1,5 @@
#include "SH1106Display.h"
#include <string.h>
#include <Adafruit_GrayOLED.h>
#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;
+5
View File
@@ -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;
+22
View File
@@ -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 { }