From 61ed43bc40a6523390cb911c26ffe412e355b9bb Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 28 Jul 2026 09:36:36 -0600 Subject: [PATCH 1/3] touch: add "at a glance" idle-dim notification overlay While unlocked but idle-dimmed to off, an incoming message (DND permitting) briefly lights a plain black overlay with the sender/ channel name and message text, then auto-dims after a fixed window without opening the chat or marking the message read. Board-agnostic, distinct from the T-Deck's existing opt-in msgFlash. extras_lat_28 (accented Latin/em-dash/ellipsis at 28px) moves from Tanmatsu-only to every board, since the glance body needs that coverage everywhere. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 194 ++++++++++++++++++++++++++++++++++- src/ui-touch/extras_lat_28.c | 8 +- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..54738e2 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -439,6 +439,12 @@ extern "C" const lv_font_t cc_icons_16; extern "C" const lv_font_t extras_12; extern "C" const lv_font_t extras_14; extern "C" const lv_font_t extras_16; +// extras_lat_28 used to be compiled HAS_TANMATSU-only (Large/Huge UI-scale Latin- +// accent fallback, issue #129); it's now compiled on every board (extras_lat_28.c) +// for the "at a glance" notification's message body (atGlanceEnsureFont()) -- +// montserrat_28 was picked over 24 because only 12/14/16/28 are actually built +// into this project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't enabled). +extern "C" const lv_font_t extras_lat_28; #if defined(HAS_TANMATSU) // Compressed Latin-accent fonts at the scaled sizes so umlauts etc. match their // neighbours at Large/Huge UI scale (issue #129). Room for these was made by @@ -446,7 +452,6 @@ extern "C" const lv_font_t extras_16; // net binary SMALLER, so the P4 app-load ceiling is respected. extern "C" const lv_font_t extras_lat_20; extern "C" const lv_font_t extras_lat_24; -extern "C" const lv_font_t extras_lat_28; #endif static lv_font_t g_font_12; static lv_font_t g_font_14; @@ -43717,6 +43722,142 @@ void UITask::rebootDevice() { void UITask::msgRead(int msgcount) { _msgcount = msgcount; } +// ---- "At a glance" notification -------------------------------------------- +// New, board-agnostic feature (distinct from the T-Deck's existing opt-in +// "msgFlash", which fully wakes to the live app + pulses the keyboard +// backlight): while the device is UNLOCKED but its screen has idle-dimmed to +// off, an incoming message (DND permitting) briefly lights a plain black +// overlay showing just the channel/sender name and the message text, then +// dims back out on its own after a fixed window -- a glance, not a wake. +// Never opens the chat, so the message stays unread, and whatever tab/chat +// was open underneath is left exactly as it was. atGlanceShow() below only +// builds/updates the overlay; UITask::newMsgImpl() decides whether to fire it +// (and wakes the screen + arms the auto-hide window); UITask::loop() clears it. +static lv_obj_t* s_glance_root = nullptr; +static lv_obj_t* s_glance_title = nullptr; +static lv_obj_t* s_glance_body = nullptr; +static uint32_t s_glance_lit_ms = 0; // millis() when (re)shown, 0 = not showing -- loop() re-dims 5s later +static bool s_glance_fading_out = false; // one-shot guard: the 200ms fade-out anim has already been started + +static void atGlanceOpaCb(void* var, int32_t v) { + lv_obj_set_style_opa(static_cast(var), static_cast(v), LV_PART_MAIN); +} + +// Message-body font, built once: stock Montserrat 28 (ASCII) -> extras_lat_28 +// (accented Latin, em-dash, ellipsis -- now compiled on every board, not just the +// Tanmatsu; see extras_lat_28.c) -> extras_16 tail (Cyrillic/Greek/Arabic, baked at +// 16 px so those scripts render a bit small relative to Latin text, but never a +// missing-glyph box). 28, not 24: only 12/14/16/28 are actually enabled in this +// project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't). g_font_16 itself +// is deliberately left alone -- it's the UI-scale-driven font hundreds of OTHER +// widgets use, not something to repurpose for one feature. +static lv_font_t s_glance_body_font; +static bool s_glance_font_ready = false; +static void atGlanceEnsureFont() { + if (s_glance_font_ready) return; + s_glance_font_ready = true; + static lv_font_t s_lat28; + s_lat28 = extras_lat_28; + s_lat28.fallback = &extras_16; + s_glance_body_font = lv_font_montserrat_28; + s_glance_body_font.fallback = &s_lat28; +} + +static void atGlanceHide() { + if (s_glance_root) { + // Only the TEXT fades (see atGlanceShow()) -- the root's own opa is never + // touched, so nothing to reset on it. Just cancel any in-flight label fades. + if (s_glance_title) { lv_anim_del(s_glance_title, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); } + if (s_glance_body) { lv_anim_del(s_glance_body, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); } + lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN); + } + s_glance_lit_ms = 0; + s_glance_fading_out = false; +} + +// `fade_in`: true on the first reveal of a burst (screen was off), false when +// a later message in the same burst just updates the text on an already-lit +// overlay -- no need to re-fade something already fully visible. +static void atGlanceShow(const char* title, const char* body, bool fade_in) { + if (!g_lv.ready) return; + if (!s_glance_root) { + s_glance_root = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(s_glance_root); + lv_obj_set_style_bg_color(s_glance_root, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_glance_root, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_CLICKABLE); // absorb taps -- no UI leak underneath + // Never a keyboard/encoder nav target on any board: same reasoning as the + // lock screen's NAV_SKIP_FLAG (see lockscreenShow()) -- a CLICKABLE + // top-layer overlay with nothing to navigate to would otherwise be + // collected as the sole focus target, and navFocusCb's focus-highlight + // would invert its text to dark the instant the nav group (re)builds. + lv_obj_add_flag(s_glance_root, NAV_SKIP_FLAG); + + // Small "eyebrow" label (channel/sender) above the message, in the app's + // accent colour -- the message itself is the thing that needs to be + // readable from a few feet away (desk-mounted device), so it gets the + // dominant size/position below and this stays a secondary context cue. + s_glance_title = lv_label_create(s_glance_root); + lv_obj_set_style_text_font(s_glance_title, &g_font_16, LV_PART_MAIN); + lv_obj_set_style_text_color(s_glance_title, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + lv_obj_set_width(s_glance_title, lv_pct(85)); + lv_obj_set_style_text_align(s_glance_title, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); + lv_label_set_long_mode(s_glance_title, LV_LABEL_LONG_DOT); // single line, ellipsize -- guaranteed to fit every board's width + lv_obj_align(s_glance_title, LV_ALIGN_TOP_LEFT, 14, 30); // below the ~22 px status bar band (the real status bar renders above this overlay) + + // Message body: 28 px (bumped up from 16 -- 16 wasn't legible from a few + // feet away on a desk-mounted device) via atGlanceEnsureFont()'s own + // fallback chain, so arbitrary chat text (accents, em-dash, ellipsis) + // still never shows a missing-glyph box at this size. + atGlanceEnsureFont(); + s_glance_body = lv_label_create(s_glance_root); + lv_obj_set_style_text_font(s_glance_body, &s_glance_body_font, LV_PART_MAIN); + lv_obj_set_style_text_color(s_glance_body, lv_color_hex(0xFFFFFFu), LV_PART_MAIN); + lv_obj_set_width(s_glance_body, lv_pct(85)); + lv_obj_set_style_text_align(s_glance_body, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); + lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); // single line -- keeps the smallest panels (Pager 222px tall) safe + lv_obj_align(s_glance_body, LV_ALIGN_TOP_LEFT, 14, 60); // a bit more clearance now the title above is 16 px, not 12 + } + const lv_coord_t sw = lv_disp_get_hor_res(nullptr); + const lv_coord_t sh = lv_disp_get_ver_res(nullptr); + lv_obj_set_size(s_glance_root, sw, sh); + lv_obj_set_pos(s_glance_root, 0, 0); + lv_label_set_text(s_glance_title, title ? title : ""); + lv_label_set_text(s_glance_body, body ? body : ""); + // Cancel any fade-out already begun (by an earlier message in this burst) on + // either label. + lv_anim_del(s_glance_title, atGlanceOpaCb); + lv_anim_del(s_glance_body, atGlanceOpaCb); + lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_glance_root); + if (fade_in) { + // Only the TEXT fades in, never the root: the root's bg_opa is COVER (solid + // black) from the moment it's created and stays that way, so the very first + // frame -- forced-painted BEFORE the backlight comes on (see newMsgImpl()) -- + // is already a clean black screen, not a transparent one letting the live + // app screen show through underneath for a frame (reported: "I see the app + // screen then I see the at-glance"). Fading the whole root's opa used to fade + // the background along with the text, which is what caused that. + lv_obj_set_style_opa(s_glance_title, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_opa(s_glance_body, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; + for (lv_obj_t* t : targets) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, t); + lv_anim_set_time(&a, 180); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_exec_cb(&a, atGlanceOpaCb); + lv_anim_start(&a); + } + } else { + // Already visible -- just swap the text, no re-fade. + lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); + } +} + // Shared core for newMsg / newMsgFromPubWithMeta. Bundles the channel-vs-DM // sender parsing + thread routing in one place so the meta-aware path doesn't // drift from the plain path. `meta_flags` is 0 (no RX metadata) when called @@ -43779,6 +43920,23 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex else if (touchPrefsGetSoundMessages() && !(cmute & TOUCH_CHMUTE_MSG)) uiPlaySlot(TOUCH_SND_MSG); } } +#endif +#if defined(HAS_TOUCH_UI) + // "At a glance": device unlocked but idle-dimmed to off -- light the plain + // black glance overlay instead of the full app (see atGlanceShow() above). + // Skipped if DND is on or the device is manually locked (that case is the + // existing lock-screen reveal, untouched here). `_screen_off` covers the + // first message of a burst (wake + show); `s_glance_lit_ms` (already showing, + // from an earlier message in the same window) covers later ones -- update + // the text and restart the 5 s window instead of dropping them, since our + // own wake already cleared _screen_off by then. + if (!dndActive() && !_manual_lock && (_screen_off || s_glance_lit_ms)) { + const bool was_off = _screen_off; + atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst + if (was_off) { lv_refr_now(nullptr); wakeScreen(); } // paint before the backlight comes on -- no stale-frame flash + s_glance_lit_ms = millis(); + s_glance_fading_out = false; // a new message cancels any fade-out already begun + } #endif // Inbound route (repeater hashes) for the Info popup — flood RX only; read // synchronously from MyMesh, which stashed it just before this call. @@ -44347,6 +44505,40 @@ void UITask::loop() { } #endif // HAS_TDECK_KEYBOARD (notify-wake re-dim) + // "At a glance" auto-hide: same re-dim shape as the msgflash window above, + // but this feature's own fixed 5 s window (the last 200 ms of it spent + // fading out, not a hard cut) and unconditional on every board (see + // atGlanceShow()/newMsgImpl() above). Real input during the window hands + // off to the user actually looking at the screen now -- don't snatch it + // back off from underneath them; just stop tracking it and let the normal + // idle timeout (already bumped by that real input) take over. + if (s_glance_lit_ms) { + if (_screen_off) atGlanceHide(); // already dark again some other way + else if (_last_input_ms > s_glance_lit_ms) atGlanceHide(); // user took over + else { + const int32_t elapsed = (int32_t)(now - s_glance_lit_ms); + if (!s_glance_fading_out && elapsed >= 4800) { + s_glance_fading_out = true; + lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; // text only -- see atGlanceShow() + for (lv_obj_t* t : targets) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, t); + lv_anim_set_time(&a, 200); + lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_TRANSP); + lv_anim_set_exec_cb(&a, atGlanceOpaCb); + lv_anim_start(&a); + } + } + if (elapsed >= 5000) { + atGlanceHide(); + touchScreenBacklight(false); + setCpuForScreen(false); + _screen_off = true; + } + } + } + #if defined(HAS_TOUCH_UI) if (!g_lv.ready) return; diff --git a/src/ui-touch/extras_lat_28.c b/src/ui-touch/extras_lat_28.c index 4c886ba..d465503 100644 --- a/src/ui-touch/extras_lat_28.c +++ b/src/ui-touch/extras_lat_28.c @@ -1,5 +1,8 @@ #include "device_caps.h" -#if defined(HAS_TANMATSU) +// Was HAS_TANMATSU-only (only consumer used to be the Tanmatsu's Large/Huge UI-scale +// accented-Latin fallback); now compiled on every board too for the "at a glance" +// notification's 28 px message body (see atGlanceEnsureFont() in UITask.cpp), which +// needs accented Latin / em-dash / ellipsis glyph coverage at that size on any board. /******************************************************************************* * Size: 28 px * Bpp: 4 @@ -3240,6 +3243,3 @@ lv_font_t extras_lat_28 = { #endif /*#if EXTRAS_LAT_28*/ - - -#endif From 14d5fdb8b328a4d1f3d13135ef380d9a2b0a1fdf Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Thu, 30 Jul 2026 09:12:05 -0600 Subject: [PATCH 2/3] touch: add "at a glance while locked" opt-in, fix glance multi-line body New Options > Lock screen toggle lets the at-a-glance overlay fire while the device is manually locked too, not just unlocked+idle-dimmed -- off by default since it makes message text readable off a locked device. Persisted via TouchPrefsStore (touchPrefsGetGlanceWhenLocked). When it fires from a locked state, mirrors lockscreenReveal()'s peek path (panel lit, _manual_lock left alone) rather than wakeScreen() (which would silently clear the lock). Also fixes the glance body label: LV_LABEL_LONG_DOT only wraps/dot- ellipsizes across multiple lines when the label has a fixed height, so left at auto-height it was effectively single-line everywhere, cropping longer messages instead of continuing to a second line. Now sized to the actual space available below it for the current board's resolution and font. Adds a T-Deck-only experiment shrinking the glance body font from 28px to 20px (extras_lat_20 gate widened past Tanmatsu-only to cover it); easy to revert in isolation per the comments at each site. Signed-off-by: Tesso M Costa --- include/lv_conf.h | 7 ++ platformio.ini | 9 ++ src/helpers/esp32/TouchPrefsStore.cpp | 2 + src/helpers/esp32/TouchPrefsStore.h | 2 + src/ui-touch/UITask.cpp | 116 +++++++++++++++++++++++--- src/ui-touch/extras_lat_20.c | 6 +- 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/include/lv_conf.h b/include/lv_conf.h index 4c330f7..8808621 100644 --- a/include/lv_conf.h +++ b/include/lv_conf.h @@ -61,6 +61,13 @@ #define LV_FONT_MONTSERRAT_20 1 #define LV_FONT_MONTSERRAT_24 1 #endif +/* T-Deck-only, unrelated to the Large/Huge UI-scale block above: an experiment + * to shrink the "at a glance" notification's message body from 28px to 20px + * on this board only (see atGlanceEnsureFont() in UITask.cpp). Easy to revert + * by dropping this block + the T-Deck branch in atGlanceEnsureFont(). */ +#if defined(HAS_TDECK_GT911) +#define LV_FONT_MONTSERRAT_20 1 +#endif /* 28 px Montserrat for the boot splash title — keeps the rest of the UI on * the smaller fonts so the .data cost stays modest. */ #define LV_FONT_MONTSERRAT_28 1 diff --git a/platformio.ini b/platformio.ini index dc10360..1f64552 100644 --- a/platformio.ini +++ b/platformio.ini @@ -564,6 +564,15 @@ build_flags = -D HAS_TDECK_GT911=1 -D HAS_TDECK_TRACKBALL=1 -D HAS_TDECK_KEYBOARD=1 + ; Forced here (not just in lv_conf.h) because .pio/libdeps//MeshCore/include/lv_conf.h + ; -- a stale snapshot vendored by the pinned core tag -- sits earlier in the -I search + ; order than this repo's own include/lv_conf.h, so LV_CONF_INCLUDE_SIMPLE's quoted + ; #include "lv_conf.h" silently resolves to that stale copy for src/ compiles (though + ; library .c files, e.g. lvgl's own, still see the right one -- inconsistent per TU). A + ; -D here is immune to that: it's set before any header is processed and isn't + ; overridden by a plain #define. Only 20px is forced, for the "at a glance" experiment + ; below (atGlanceEnsureFont() in UITask.cpp) -- not 18/24, which this board doesn't use. + -D LV_FONT_MONTSERRAT_20=1 -D ENABLE_ADVERT_ON_BOOT=0 -D PIN_TB_UP=15 -D PIN_TB_DOWN=3 diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index c2952b9..705643c 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1785,6 +1785,8 @@ bool touchPrefsGetEdgeScroll() { if (!s_begun) touchPrefsBegin(); return s_ void touchPrefsSetEdgeScroll(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("tb_edgesc", on ? 1 : 0); } bool touchPrefsGetLockOnScreenOff() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("lock_off", 0) != 0; } void touchPrefsSetLockOnScreenOff(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("lock_off", on ? 1 : 0); } +bool touchPrefsGetGlanceWhenLocked() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_lck", 0) != 0; } +void touchPrefsSetGlanceWhenLocked(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_lck", on ? 1 : 0); } #if defined(HAS_TANMATSU) // only the Tanmatsu has the message LED — keep S3 (T-Deck/V4) bins unchanged bool touchPrefsGetMsgLed() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("msg_led", 1) != 0; } // default ON diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 8cd41eb..c8c6ade 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -416,6 +416,8 @@ bool touchPrefsGetEdgeScroll(); // push cursor past edge to scrol void touchPrefsSetEdgeScroll(bool on); bool touchPrefsGetLockOnScreenOff(); // idle screen-off auto-locks; only a deliberate hold wakes (default false) void touchPrefsSetLockOnScreenOff(bool on); +bool touchPrefsGetGlanceWhenLocked(); // "at a glance" also fires while manually/idle locked, not just unlocked+dimmed (default false) +void touchPrefsSetGlanceWhenLocked(bool on); /** Per-channel mute, keyed by channel name. Bit 0 = mute messages, bit 1 = * mute @-mentions. Suppresses the notification SOUND for that channel (the diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 54738e2..a9f0361 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -445,12 +445,17 @@ extern "C" const lv_font_t extras_16; // montserrat_28 was picked over 24 because only 12/14/16/28 are actually built // into this project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't enabled). extern "C" const lv_font_t extras_lat_28; +// extras_lat_20 was HAS_TANMATSU-only too (Large/Huge UI-scale fallback); the +// T-Deck now also builds it, for its 20px at-glance body experiment (see +// atGlanceEnsureFont()) -- extras_lat_20.c's own gate was widened to match. +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_GT911) +extern "C" const lv_font_t extras_lat_20; +#endif #if defined(HAS_TANMATSU) // Compressed Latin-accent fonts at the scaled sizes so umlauts etc. match their // neighbours at Large/Huge UI scale (issue #129). Room for these was made by // storing the extras fonts compressed (LV_USE_FONT_COMPRESSED) — nothing lost, // net binary SMALLER, so the P4 app-load ceiling is respected. -extern "C" const lv_font_t extras_lat_20; extern "C" const lv_font_t extras_lat_24; #endif static lv_font_t g_font_12; @@ -10399,6 +10404,21 @@ static void lockOnScreenOffToggleCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(unlock_hint, 1600); } +// "At a glance" is read straight from NVS at message time (not cached), same as +// the sound/DND checks right next to its call site in newMsgImpl -- it's not a +// per-loop hot path, just once per inbound message. +static void glanceWhenLockedToggleCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); +#if defined(ESP32) + touchPrefsSetGlanceWhenLocked(on); +#endif + if (g_lv.task) { + g_lv.task->showAlert(on ? TR("Glance shows message previews\nwhile the screen is locked") + : TR("Glance only shows when unlocked"), 1600); + } +} + #if CAP_TRACKBALL // Invert the scrollball direction everywhere it drives motion (cursor, map pan, // emoji selector). Cached in s_tb_reverse so the per-tick poll never hits NVS. @@ -11920,6 +11940,21 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, lockOnScreenOffToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } + + // "At a glance" (see atGlanceShow()/newMsgImpl()) normally only fires while + // unlocked-but-idle-dimmed; this opts in to it firing while manually locked + // too, i.e. message previews become readable off a locked device -- off by + // default since that's a real trade-off, not just a convenience toggle. + { + int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); + lv_obj_t* sw = lv_switch_create(body); + lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); +#if defined(ESP32) + if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(sw, LV_STATE_CHECKED); +#endif + lv_obj_add_event_cb(sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } #endif @@ -43751,16 +43786,29 @@ static void atGlanceOpaCb(void* var, int32_t v) { // project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't). g_font_16 itself // is deliberately left alone -- it's the UI-scale-driven font hundreds of OTHER // widgets use, not something to repurpose for one feature. +// +// T-Deck experiment: 20px instead of 28px (LV_FONT_MONTSERRAT_20 + extras_lat_20 +// widened for this board specifically, see lv_conf.h/extras_lat_20.c) -- trying +// a smaller glance body on this panel. Revert by dropping this branch + those +// two gate widenings. static lv_font_t s_glance_body_font; static bool s_glance_font_ready = false; static void atGlanceEnsureFont() { if (s_glance_font_ready) return; s_glance_font_ready = true; +#if defined(HAS_TDECK_GT911) + static lv_font_t s_lat20; + s_lat20 = extras_lat_20; + s_lat20.fallback = &extras_16; + s_glance_body_font = lv_font_montserrat_20; + s_glance_body_font.fallback = &s_lat20; +#else static lv_font_t s_lat28; s_lat28 = extras_lat_28; s_lat28.fallback = &extras_16; s_glance_body_font = lv_font_montserrat_28; s_glance_body_font.fallback = &s_lat28; +#endif } static void atGlanceHide() { @@ -43816,7 +43864,21 @@ static void atGlanceShow(const char* title, const char* body, bool fade_in) { lv_obj_set_style_text_color(s_glance_body, lv_color_hex(0xFFFFFFu), LV_PART_MAIN); lv_obj_set_width(s_glance_body, lv_pct(85)); lv_obj_set_style_text_align(s_glance_body, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); - lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); // single line -- keeps the smallest panels (Pager 222px tall) safe + // LV_LABEL_LONG_DOT only wraps across multiple lines (dot-ellipsizing the LAST + // one on overflow) when the label has a fixed HEIGHT, not just a fixed width -- + // left at auto/content height (as this was), it sizes to exactly one line, so + // every board was effectively single-line regardless of panel size (reported: + // text cropped at the end of the first line instead of continuing on a second). + // Give it the actual remaining space below this label's own y-offset down to a + // small bottom margin, computed from this board's resolution + active body font, + // so it uses exactly what's available -- more lines on tall panels, fewer on the + // 222 px-tall Pager -- instead of a guessed fixed line count. + { + const lv_coord_t avail_h = lv_disp_get_ver_res(nullptr) - 60 - 10; + const lv_coord_t line_h = lv_font_get_line_height(&s_glance_body_font); + lv_obj_set_height(s_glance_body, avail_h > line_h ? avail_h : line_h); + } + lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); lv_obj_align(s_glance_body, LV_ALIGN_TOP_LEFT, 14, 60); // a bit more clearance now the title above is 16 px, not 12 } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); @@ -43924,16 +43986,50 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex #if defined(HAS_TOUCH_UI) // "At a glance": device unlocked but idle-dimmed to off -- light the plain // black glance overlay instead of the full app (see atGlanceShow() above). - // Skipped if DND is on or the device is manually locked (that case is the - // existing lock-screen reveal, untouched here). `_screen_off` covers the - // first message of a burst (wake + show); `s_glance_lit_ms` (already showing, - // from an earlier message in the same window) covers later ones -- update - // the text and restart the 5 s window instead of dropping them, since our - // own wake already cleared _screen_off by then. - if (!dndActive() && !_manual_lock && (_screen_off || s_glance_lit_ms)) { + // Skipped if DND is on. Manually locked is ALSO skipped unless the user + // opted in via Options > Lock screen > "At a glance while locked" + // (touchPrefsGetGlanceWhenLocked()) -- off by default since it's a real + // trade-off (message text becomes readable off a locked device). `_screen_off` + // covers the first message of a burst (wake + show); `s_glance_lit_ms` + // (already showing, from an earlier message in the same window) covers later + // ones -- update the text and restart the 5 s window instead of dropping + // them, since our own wake already cleared _screen_off by then. +#if defined(ESP32) + const bool glance_locked_ok = !_manual_lock || touchPrefsGetGlanceWhenLocked(); +#else + const bool glance_locked_ok = !_manual_lock; +#endif + if (!dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { const bool was_off = _screen_off; atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst - if (was_off) { lv_refr_now(nullptr); wakeScreen(); } // paint before the backlight comes on -- no stale-frame flash + if (was_off) { + lv_refr_now(nullptr); // paint before the backlight comes on -- no stale-frame flash + if (_manual_lock) { + // Locked: light the panel WITHOUT clearing the lock -- wakeScreen() + // unconditionally clears _manual_lock, which would silently unlock the + // device (touch/input live again) just to show a glance. Mirrors + // lockscreenReveal()'s peek path instead: same fields, lock untouched. + // The auto-hide re-dim below (UITask::loop()) is symmetric -- it never + // touches _manual_lock either, so the device is exactly as locked after + // the glance as before it. + setCpuForScreen(true); + touchScreenBacklight(true); + _screen_off = false; + // MUST bump this even though it's not real input: loop()'s generic idle + // screen-timeout check fires on !_screen_off && (now - _last_input_ms) >= + // _screen_timeout_ms regardless of _manual_lock -- leaving it stale (from + // before the lock) made that check true on the very next loop() tick, + // instantly flipping the backlight back off before the glance was ever + // seen (reported: sound played but nothing showed). wakeScreen() bumps + // this for the same reason; omitting it here was the bug. + _last_input_ms = millis(); +#if CAP_LOCK_SCREEN + _lock_lit_ms = millis(); // keep the lock screen's own burn-in guard armed too +#endif + } else { + wakeScreen(); + } + } s_glance_lit_ms = millis(); s_glance_fading_out = false; // a new message cancels any fade-out already begun } diff --git a/src/ui-touch/extras_lat_20.c b/src/ui-touch/extras_lat_20.c index c8cef46..75778a4 100644 --- a/src/ui-touch/extras_lat_20.c +++ b/src/ui-touch/extras_lat_20.c @@ -1,5 +1,9 @@ #include "device_caps.h" -#if defined(HAS_TANMATSU) +// Was HAS_TANMATSU-only (Large/Huge UI-scale accented-Latin fallback); the T-Deck +// now also builds this for the "at a glance" notification's 20 px message body +// (see atGlanceEnsureFont() in UITask.cpp) -- an experiment to see whether a +// smaller-than-28px glance body is still legible on that panel. +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_GT911) /******************************************************************************* * Size: 20 px * Bpp: 4 From 3307d87e9cdeb39cc6b9b22e7fe2de28940ea6ff Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 24 Aug 2026 09:03:41 -0600 Subject: [PATCH 3/3] touch: add master "At a glance" toggle, move while-locked switch under it Options > Display previously only had "At a glance while locked" (opt-in, off by default). Add a master "At a glance" switch above it (on by default, matching the previously-unconditional behavior) that gates the whole feature, and move the while-locked switch out of Options > Lock screen to sit under it. The while-locked switch's own saved preference is left alone when the master is toggled off -- only its editability follows the master, so re-enabling the master always restores its true prior state rather than resetting it. Signed-off-by: Tesso M Costa --- src/helpers/esp32/TouchPrefsStore.cpp | 2 + src/helpers/esp32/TouchPrefsStore.h | 2 + src/ui-touch/UITask.cpp | 91 ++++++++++++++++++++------- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index f2c8a2a..133ae40 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1835,6 +1835,8 @@ bool touchPrefsGetLockOnScreenOff() { if (!s_begun) touchPrefsBegin(); return s_ void touchPrefsSetLockOnScreenOff(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("lock_off", on ? 1 : 0); } bool touchPrefsGetGlanceWhenLocked() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_lck", 0) != 0; } void touchPrefsSetGlanceWhenLocked(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_lck", on ? 1 : 0); } +bool touchPrefsGetGlanceEnabled() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_en", 1) != 0; } +void touchPrefsSetGlanceEnabled(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_en", on ? 1 : 0); } #if defined(HAS_TANMATSU) // only the Tanmatsu has the message LED — keep S3 (T-Deck/V4) bins unchanged bool touchPrefsGetMsgLed() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("msg_led", 1) != 0; } // default ON diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 9be91d3..4d7205f 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -430,6 +430,8 @@ bool touchPrefsGetLockOnScreenOff(); // idle screen-off auto-locks; on void touchPrefsSetLockOnScreenOff(bool on); bool touchPrefsGetGlanceWhenLocked(); // "at a glance" also fires while manually/idle locked, not just unlocked+dimmed (default false) void touchPrefsSetGlanceWhenLocked(bool on); +bool touchPrefsGetGlanceEnabled(); // master "at a glance" feature toggle (default true) +void touchPrefsSetGlanceEnabled(bool on); /** Per-channel mute, keyed by channel name. Bit 0 = mute messages, bit 1 = * mute @-mentions. Suppresses the notification SOUND for that channel (the diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b902cc4..0ca8f6b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2105,6 +2105,8 @@ struct SettingsModalState { lv_obj_t* exp_boost_sw; lv_obj_t* exp_dc_sw; lv_obj_t* wifi_sw; + lv_obj_t* glance_en_sw; + lv_obj_t* glance_locked_sw; lv_obj_t* wifi_ssid_ta; lv_obj_t* wifi_pwd_ta; /** Transports modal: live STA line (IP when connected, else Arduino WiFi status). */ @@ -10851,6 +10853,23 @@ static void glanceWhenLockedToggleCb(lv_event_t* e) { } } +// Master "At a glance" toggle (Options > Display): gates the whole feature. +// The secondary "while locked" switch's own saved preference is left +// alone -- only its editability follows the master, so re-enabling always +// restores its true prior state. +static void glanceEnabledToggleCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); +#if defined(ESP32) + touchPrefsSetGlanceEnabled(on); +#endif + if (g_set_modal.glance_locked_sw) { + if (on) lv_obj_clear_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); + else lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); + } + if (g_lv.task) g_lv.task->showAlert(on ? TR("At a glance enabled") : TR("At a glance disabled"), 1200); +} + #if CAP_TRACKBALL // Invert the scrollball direction everywhere it drives motion (cursor, map pan, // emoji selector). Cached in s_tb_reverse so the per-tick poll never hits NVS. @@ -11927,6 +11946,41 @@ static void buildDeviceSettings(int sec) { y += LV_MAX(40, h + 12); } + /* At a glance: master enable for the "at a glance" message-preview overlay + (see atGlanceShow()/newMsgImpl()) -- on by default, matching the + previously-unconditional behavior. Gates the "while locked" switch below. */ + bool glance_on = true; + { + int h = settingsRowLabel(body, y, 6, TR("At a glance"), COLOR_SUB, nullptr, 56); + g_set_modal.glance_en_sw = lv_switch_create(body); + lv_obj_align(g_set_modal.glance_en_sw, LV_ALIGN_TOP_RIGHT, 0, y); +#if defined(ESP32) + glance_on = touchPrefsGetGlanceEnabled(); + if (glance_on) lv_obj_add_state(g_set_modal.glance_en_sw, LV_STATE_CHECKED); +#endif + lv_obj_add_event_cb(g_set_modal.glance_en_sw, glanceEnabledToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } + +#if !defined(HAS_TDISPLAY_P4) + /* "At a glance" also fires while manually/idle locked, not just while + unlocked+dimmed -- off by default since it's a real trade-off (message + text becomes readable off a locked device). Disabled (not cleared) while + the master switch above is off. Moved here from Options > Lock screen so + it sits with the feature it modifies. */ + { + int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); + g_set_modal.glance_locked_sw = lv_switch_create(body); + lv_obj_align(g_set_modal.glance_locked_sw, LV_ALIGN_TOP_RIGHT, 0, y); +#if defined(ESP32) + if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_CHECKED); + if (!glance_on) lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); +#endif + lv_obj_add_event_cb(g_set_modal.glance_locked_sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } +#endif + #if defined(HAS_EXPANSION_KIT) /* Show Sensors tab (V4 Expansion Kit): toggles the bottom Sensors tab + the Home env widget. Also auto-hidden when no environment sensor is attached. @@ -12416,21 +12470,6 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, lockOnScreenOffToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } - - // "At a glance" (see atGlanceShow()/newMsgImpl()) normally only fires while - // unlocked-but-idle-dimmed; this opts in to it firing while manually locked - // too, i.e. message previews become readable off a locked device -- off by - // default since that's a real trade-off, not just a convenience toggle. - { - int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); - lv_obj_t* sw = lv_switch_create(body); - lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); -#if defined(ESP32) - if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(sw, LV_STATE_CHECKED); -#endif - lv_obj_add_event_cb(sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); - y += LV_MAX(40, h + 12); - } #endif @@ -45933,20 +45972,24 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex #if defined(HAS_TOUCH_UI) // "At a glance": device unlocked but idle-dimmed to off -- light the plain // black glance overlay instead of the full app (see atGlanceShow() above). - // Skipped if DND is on. Manually locked is ALSO skipped unless the user - // opted in via Options > Lock screen > "At a glance while locked" - // (touchPrefsGetGlanceWhenLocked()) -- off by default since it's a real - // trade-off (message text becomes readable off a locked device). `_screen_off` - // covers the first message of a burst (wake + show); `s_glance_lit_ms` - // (already showing, from an earlier message in the same window) covers later - // ones -- update the text and restart the 5 s window instead of dropping - // them, since our own wake already cleared _screen_off by then. + // Master-disabled via Options > Display > "At a glance" + // (touchPrefsGetGlanceEnabled()) skips this entirely. Skipped if DND is on. + // Manually locked is ALSO skipped unless the user opted in via Options > + // Display > "At a glance while locked" (touchPrefsGetGlanceWhenLocked()) + // -- off by default since it's a real trade-off (message text becomes + // readable off a locked device). `_screen_off` covers the first message of + // a burst (wake + show); `s_glance_lit_ms` (already showing, from an + // earlier message in the same window) covers later ones -- update the text + // and restart the 5 s window instead of dropping them, since our own wake + // already cleared _screen_off by then. #if defined(ESP32) + const bool glance_enabled_ok = touchPrefsGetGlanceEnabled(); const bool glance_locked_ok = !_manual_lock || touchPrefsGetGlanceWhenLocked(); #else + const bool glance_enabled_ok = true; const bool glance_locked_ok = !_manual_lock; #endif - if (!dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { + if (glance_enabled_ok && !dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { const bool was_off = _screen_off; atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst if (was_off) {