#include "UITask.h" #include #include "../MyMesh.h" #include "target.h" #if defined(ESP32) #include // The UI runs on Arduino's loop task: LVGL's renderer, FAT on the card and // the map's decoders all nest on its stack, and the stock 8 KB left a few // hundred bytes at the deepest point (Diagnostics > Stack free, the lowest // ever) -- a screenshot's file write went past it and reset the device. SET_LOOP_TASK_STACK_SIZE(16 * 1024); #endif #include #include // lv_cover_check_info_t (coversOwnArea) #include // lv_label_t (labelShows) #include // lv_area_intersect (clockview::wheelDraw) #include #include // Flags: LVGL draws text one codepoint at a time, so a flag (a pair of regional // indicator letters) would show as two letter tiles. Every label and span of // this UI (all of it is this one file and the headers it includes) goes through // these, which swap each pair for the codepoint fonts/ui_emoji.c keeps the // flag's image under. Display only: what's stored and sent stays as it was. // A label given the text it already shows is left alone: the status bar, the // clocks and the live values are set every refresh, and each set is a redraw. extern "C" char* ui_emoji_flags(const char* s); static void ui_label_set_text(lv_obj_t* o, const char* t) { char* f = t ? ui_emoji_flags(t) : nullptr; // t == NULL: LVGL's "redraw the current text" const char* s = f ? f : t; const char* cur = lv_label_get_text(o); if (!s || !cur || strcmp(cur, s) != 0) lv_label_set_text(o, s); lv_free(f); } static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) __attribute__((format(printf, 2, 3))); static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) { va_list ap; va_start(ap, fmt); char buf[160]; int n = vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); if (n < (int)sizeof(buf)) { ui_label_set_text(o, buf); return; } char* big = (char*)lv_malloc(n + 1); if (!big) return; va_start(ap, fmt); vsnprintf(big, n + 1, fmt, ap); va_end(ap); ui_label_set_text(o, big); lv_free(big); } static void ui_span_set_text(lv_span_t* s, const char* t) { char* f = ui_emoji_flags(t); lv_span_set_text(s, f ? f : t); lv_free(f); } #define lv_label_set_text ui_label_set_text #define lv_label_set_text_fmt ui_label_set_text_fmt #define lv_span_set_text ui_span_set_text // A zeroed buffer of `n` T in PSRAM when the board has it: internal RAM is what // BLE, WiFi and TLS need. Allocated once (at startup or first use), never freed. template static T* psramBuf(size_t n) { #if defined(ESP32) if (void* p = heap_caps_calloc(n, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) return (T*)p; #endif return (T*)calloc(n, sizeof(T)); } static bool s_chats_stale = false; // Messages: a refresh waits for a held row to be let go #include "../ui-core/UiCore.h" // shared UI Core (header-only, this TU) #include "../ui-core/NearbyModel.h" #include "../ui-core/EtaTracker.h" #include "../ui-core/SettingsSchema.h" #include "../ui-core/SettingsCli.h" // the companion CLI's settings keys #include "../ui-core/ContactQr.h" #include "../ui-core/GpsAverager.h" #include "../ui-core/TrackBack.h" #include "../ui-core/TrailProfile.h" #include "../ui-core/StatusHistory.h" #include "../ui-core/CalendarMath.h" #include "../ui-core/RadioControl.h" #include "../ui-core/RepeaterControl.h" #include "../ui-core/Diagnostics.h" #include "../ui-core/Battery.h" #include "../ui-core/Telemetry.h" #include "../ui-core/ChannelControl.h" #include "../ui-core/BotConfig.h" #include "../ui-core/SoundControl.h" #include "../ui-core/SoundNotifier.h" #include "../ui-core/MessageText.h" #include "../ui-core/SiteQr.h" #include "Theme.h" #include "Anim.h" #include "LvglPort.h" #include "lv_mem_psram.h" #include "HistoryStore.h" #include "map/MapLayers.h" #include "map/LiveCache.h" static histstore::SdArchive s_archive; // message history on the SD card namespace storeview { static void stopWalk(); } // StorageScreen.h #include "../ui-core/KeyboardData.h" #include "Keyboard.h" static const char* waypointsFull() { static char t[24]; snprintf(t, sizeof(t), "Waypoints full (%d)", WaypointStore::CAPACITY); return t; } // ui-lvgl skeleton (docs/solo/developer/ui-core.md, step 5): status bar, home, // conversation list, contact picker, conversation view with compose. Every // piece of state it shows comes from the UI Core; this file only draws it. static UITask* s_ui = nullptr; // for LVGL's C callbacks static bool s_hdr_fit; // the title bar's contents changed: fitHeader() before the next frame #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) #include // The board's one button, pressed from the simulator page (web/lvgl.html). static bool s_sim_btn_click = false, s_sim_btn_hold = false, s_sim_wake = false; extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; } extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button_hold() { s_sim_btn_hold = true; } extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_button() { s_sim_wake = true; } static bool s_sim_shot = false; // the side + top button combo extern "C" EMSCRIPTEN_KEEPALIVE void sim_screenshot() { s_sim_shot = true; } // The speaker, polled every frame by the page's Web Audio oscillator. extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing() { return s_ui && s_ui->isBuzzerPlaying() ? 1 : 0; } extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { return s_ui ? (int)s_ui->buzzerFreqHz() : 0; } extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume() { return s_ui ? (int)s_ui->buzzerVolume() : 0; } #endif #include "Widgets.h" // ── Lifecycle ───────────────────────────────────────────────────────────────── void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs* node_prefs) { s_ui = this; _ui_started_ms = millis(); _display = display_drv; _sensors = sensors; _prefs = node_prefs; s_prefs = node_prefs; if (node_prefs) battery::useCurve(node_prefs->batt_curve_mv); // Settings > Battery curve _core = new UiCore(); _core->begin(node_prefs, sensors, this); { // history kept on the SD card: back into the ring, then every new entry to it int hk = lvport::loadHistKeep(); s_archive.setKeep(histstore::KEEP[hk >= 0 && hk < histstore::KEEP_COUNT ? hk : histstore::KEEP_DEFAULT]); int lc = lvport::loadLiveCap(); mapview::s_live_cache.setLimit((uint64_t)mapview::LIVE_CAP_MB[lc >= 0 && lc < mapview::LIVE_CAP_COUNT ? lc : mapview::LIVE_CAP_DEFAULT] << 20); s_archive.restore(_core->history); _core->history.setArchive(&s_archive); } trailJournalRestore(); // a trail cut off by a restart or a flat battery _tap_wake = lvport::loadTapWake(); msgtext::seedQuick(node_prefs); // "OK" in quick message 1 on first boot _nearby = new NearbyModel(); _nearby->bindModel(_core, node_prefs); _scan = new NearbyModel(); _scan->bindModel(_core, node_prefs); _scan->setSource(NearbyModel::SRC_SCAN); #ifdef PIN_USER_BTN user_btn.begin(); #endif #ifdef PIN_BUZZER soundctl::applyMode(_prefs, _buzzer, isClientConnected(), rtc_clock.getCurrentTime()); _buzzer.setVolume(_prefs ? _prefs->buzzer_volume : 4); _buzzer.begin(); // plays the startup sound unless muted #endif lv_init(); lv_tick_set_cb([]() -> uint32_t { return (uint32_t)millis(); }); if (!lvport::begin()) { Serial.println("ui-lvgl: no memory for display buffers"); return; } theme::setAccent(lvport::loadAccent()); theme::setLight(lvport::loadLight()); theme::install(lv_display_get_default()); lv_display_add_event_cb(lv_display_get_default(), [](lv_event_t*) { rowsFitPending(); if (s_hdr_fit && s_ui) { s_hdr_fit = false; s_ui->fitHeader(); } }, LV_EVENT_REFR_START, NULL); buildStatusBar(); quickPanelBuild(); // QuickPanel.h: ready before the first pull applyDisplayPrefs(); // a slider percentage overrides the level main.cpp set { char c[24]; lvport::crashSummary(c, sizeof(c)); } // the dump in flash read now (~45 ms), not on the first Diagnostics showHome(); if (pinSet()) lockScreen(); // a reboot doesn't get round the PIN showSplash(); // over both; fades out by itself } MyMesh::Listener* UITask::meshListener() { return _core; } // The USER button's hold: sound on / off (leaves Auto, as the Sound page's // On / Off would). void UITask::toggleMute() { #ifdef PIN_BUZZER if (!_prefs) return; bool on = _buzzer.isQuiet(); // muted now, however (mode, Auto, quiet hours): sound on soundctl::setMode(_prefs, _buzzer, on ? soundctl::MODE_ON : soundctl::MODE_OFF, isClientConnected()); prefsSave(); if (on) _buzzer.playForced(soundctl::MEL_VOLUME); if (!_asleep) { showToast(on ? "Sound on" : "Sound off", 1200); refreshStatusBar(); } #endif } static constexpr uint32_t LOCK_OFF_MS = 30000; // the lock screen's own auto-off cap #include "PerfTest.h" // -D UI_PERF_TEST only: draw profiles and control timing void UITask::loop() { pollConnection(); drainCoreEvents(); sampleHistory(); // USER (BOOT, side) button: Home's clock page; held and let go, mutes / unmutes, also with the // screen off, which it doesn't wake. WAKE (top) button: screen off / on. // Either one silences a ringing alarm first. Held in the first 8 s (once the // screen is up -- held at power-on it's the ESP32's download mode): CLI rescue. bool btn_click = false, btn_hold = false, wake_press = false; #ifdef PIN_USER_BTN int ev = user_btn.check(); btn_click = ev == BUTTON_EVENT_CLICK; btn_hold = ev == BUTTON_EVENT_LONG_PRESS; #elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) btn_click = s_sim_btn_click; btn_hold = s_sim_btn_hold; wake_press = s_sim_wake; s_sim_btn_click = s_sim_btn_hold = s_sim_wake = false; #endif #if defined(SEEED_WIO_TRACKER_L2) if ((int32_t)(millis() - _next_wake_poll_ms) >= 0) { // an I2C read on the expander _next_wake_poll_ms = millis() + 50; bool down = board.readWakeButton(); wake_press = down && !_wake_down; _wake_down = down; } #endif // Side button held + top button: a screenshot (and neither does its own thing). // A long hold only arms the mute, which happens on letting go -- so holding // the side button a bit long before the top one doesn't mute as well. static bool mute_armed = false; bool shot = false, mute = false; #ifdef PIN_USER_BTN bool side_down = user_btn.isPressed(); if (wake_press && side_down) { user_btn.cancelClick(); shot = true; mute_armed = false; } if (mute_armed && !side_down) { mute_armed = false; mute = true; } #elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) shot = s_sim_shot; s_sim_shot = false; if (mute_armed) { mute_armed = false; mute = true; } // the page sends the hold on release #endif if (shot) { if (!_asleep) takeScreenshot(); } else if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing(); else if (wake_press) { if (_asleep) wake(); else sleep(); } else if (btn_hold && millis() - _ui_started_ms < 8000) { // held in the first 8 s: CLI rescue, as on the L1 the_mesh.enterCLIRescue(); showToast("CLI rescue on the USB serial", 4000); } else if (btn_hold) mute_armed = true; else if (mute) toggleMute(); else if (btn_click) { // the side button doesn't wake: pockets if (flashlightOn()) flashlightClose(); else if (quickPanelOpen()) quickPanelClose(true); else if (!_asleep && !locked()) goHome(); } if (_asleep) { // Tap to wake (off: the top button only). An I2C read on the touch panel. if (_tap_wake && (int32_t)(millis() - _next_touch_poll_ms) >= 0) { _next_touch_poll_ms = millis() + 50; if (lvport::touched()) { lvport::swallowTouch(); wake(); } } } else { if (lv_display_get_inactive_time(NULL) < 1000) the_mesh.noteUserInput(); // Locked, nobody's using it: the lock screen goes dark after LOCK_OFF_MS // even with auto-off at Never (a woken-up pocket, a boot PIN, a message). uint32_t aoff = autoOffMillis(); if (locked() && (aoff == 0 || aoff > LOCK_OFF_MS)) aoff = LOCK_OFF_MS; if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy() && !flashlightOn()) sleep(); } _core->loop(); drainCoreEvents(); prefsFlush(); usbPoll(); #if defined(UI_HEAP_REPORT) && defined(ESP32) // -D UI_HEAP_REPORT: internal / PSRAM heap once, 20 s after boot. static bool heap_done = false; if (!heap_done && millis() > 20000) { heap_done = true; Serial.printf("HEAP internal free %u largest %u min %u | psram free %u | idf %s\n", (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), (unsigned)heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL), (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), esp_get_idf_version()); } #endif #if defined(UI_PERF_TEST) && defined(ESP32) perfWalk(); // PerfTest.h #endif #ifdef PIN_BUZZER _buzzer.loop(); if (soundctl::tick(_prefs, _buzzer, isClientConnected(), rtc_clock.getCurrentTime()) && !_asleep) refreshStatusBar(); // The alarm melody repeats until dismissed or the ring window ends. if (_core->clock.isRinging() && !_buzzer.isPlaying()) playMelody(soundctl::MEL_ALARM); #endif checkLowBattery(); mapDownloadTick(); // a map download keeps running on any screen, and asleep otaTick(); // so does a firmware update trailJournalTick(); // the live trail's copy on the card, once a minute if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen _next_trackback_ms = millis() + 1000; navPollTrackBack(); } uint32_t lv_next = 0; if (!_asleep) { if ((int32_t)(millis() - _next_status_ms) >= 0) { _next_status_ms = millis() + 1000; refreshStatusBar(); refreshLock(); if (_screen == SCR_HOME) refreshHome(); if (s_chats_stale && !_nav_overlay) refreshChats(); // a row was held when it came refreshDiag(); refreshCompass(); refreshGps(); refreshBattCurve(); if (quickPanelOpen()) quickPanelRefresh(); } if (locked()) lockPoll(); else if (_screen == SCR_HOME) { homeSwipePoll(); homeMapLoop(); } if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) { // Scan results trickle in over a few seconds: poll fast while scanning. _next_nearby_ms = millis() + (_scanning ? 250 : 2000); if (_scanning && (int32_t)(millis() - _scan_until_ms) >= 0) _scanning = false; if (_screen == SCR_NEARBY) { refreshNearbyList(); if (_scan_overlay) refreshScanPopup(); } else { refreshNode(); } } fillTick(); if (_screen == SCR_MAP) mapLoop(); if (_screen == SCR_WIFI) pollWifiScan(); if (_screen == SCR_STORAGE) pollStorage(); if (_screen == SCR_ADMIN) adminPoll(); if (_screen == SCR_ROUTE && (int32_t)(millis() - _next_route_ms) >= 0) { _next_route_ms = millis() + 200; routePoll(); } roomPoll(); if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) { _next_clock_ms = millis() + 100; // stopwatch tenths refreshClock(); } if (_screen == SCR_MELODY && (int32_t)(millis() - _next_clock_ms) >= 0) { _next_clock_ms = millis() + 30; // the playing note's highlight keeps up refreshMelody(); } if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) { _next_thread_check_ms = millis() + 500; uint32_t sig = threadSignature(); if (_thread_dirty || sig != _thread_sig) refreshThread(); else if ((int32_t)(millis() - _next_thread_ages_ms) >= 0) refreshThreadAges(); messagePopupTick(); } lvport::cpuSlow(!cpuNeeded()); lv_next = lv_timer_handler(); } lvport::idle(idleMillis(lv_next)); } // How long loop() may sleep: nothing is due before then. Not at all while the // mesh has packets queued; briefly while a melody plays (its notes are timed // here) or frames to or from the app wait (read one a pass, sent spaced out). // A frame arriving from the app over BLE wakes the loop (serialFrameArrived). uint32_t UITask::idleMillis(uint32_t lv_next) { if (the_mesh.hasPendingWork()) return 0; #ifdef PIN_BUZZER if (_buzzer.isPlaying()) return 1; #endif if (isClientConnected() && _serial->hasPendingFrames()) return 2; if (_asleep) return 50; // as often as the buttons and tap to wake are polled return lv_next < 10 ? lv_next : 10; // LVGL's next timer: a refresh, an animation, input } void UITask::shutdown(bool restart) { s_prefs_dirty_ms = s_prefs_save_at = 0; saveBeforeShutdown(); if (!trailJournalTick(true)) _core->trail.onShutdown(); // no card: the internal slot, if enabled #ifdef PIN_BUZZER _buzzer.shutdown(); // the goodbye sound, unless muted #ifndef SIM_PLATFORM // the sim's single thread would freeze the page for (uint32_t t0 = millis(); _buzzer.isPlaying() && millis() - t0 < 2500; ) { _buzzer.loop(); delay(10); } #endif #endif powerDown(restart, _display, _sensors); } // Settings > Power > Battery shutdown (NodePrefs::low_batt_mv), as // ui-new does it: a smoothed reading every 8 s, never while on USB power. void UITask::checkLowBattery() { if ((int32_t)(millis() - _next_batt_ms) < 0) return; _next_batt_ms = millis() + 8000; uint16_t raw = getBattMilliVolts(); if (raw > 0) _batt_mv = battEma(_batt_mv, raw); if (!lowBatteryShutdownDue(_batt_mv, _prefs)) return; wake(); showToast("Low battery - shutting down", 3000); lv_timer_handler(); delay(2000); shutdown(); } uint32_t UITask::autoOffMillis() const { if (!_prefs || _prefs->auto_off_secs == 0) return 0; return (uint32_t)_prefs->auto_off_secs * 1000UL; } static void bannerDismiss(); // below, with the banner void UITask::sleep() { if (_asleep) return; _asleep = true; quickPanelClose(false); flashlightClose(); bannerDismiss(); if (_toast) { // same: the toast's timer won't run while asleep if (_toast_timer) lv_timer_pause(_toast_timer); lv_anim_delete(_toast, NULL); lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN); } if (_display) _display->turnOff(); prefsSaveSoon(0); // nothing to stall now lvport::powerSave(true, _tap_wake); if ((_prefs && _prefs->auto_lock) || pinSet()) lockScreen(); // Lock screen, or a screen PIN } void UITask::wake() { lv_display_trigger_activity(NULL); if (!_asleep) return; _asleep = false; lvport::powerSave(false, _tap_wake); if (_display) _display->turnOn(); // dark until the frame below is on the panel refreshStatusBar(); if (_screen == SCR_HOME) refreshHome(); if (_screen == SCR_THREAD) { if (locked()) refreshThread(); else reopenThreadIfUnread(); } // locked: on unlocking lv_obj_invalidate(lv_screen_active()); lv_refr_now(NULL); lvport::backlightFadeIn(); } // ── Core events ─────────────────────────────────────────────────────────────── void UITask::drainCoreEvents() { UiEvent ev; while (_core->events.pop(ev)) { switch (ev.type) { case UiEventType::MessageArrived: onMessageArrived(ev); break; case UiEventType::ClockAlert: showRing(ev.text); playMelody(soundctl::MEL_ALARM); break; case UiEventType::ClockRingEnded: stopMelody(); hideRing(); break; case UiEventType::LiveShareEnded: showToast("Live share ended"); break; case UiEventType::LocatorCrossed: showToast(ev.text, 3000); #ifdef PIN_BUZZER if (!_buzzer.isQuiet()) playMelody(ev.flag ? soundctl::MEL_ARRIVE : soundctl::MEL_LEAVE); #endif break; case UiEventType::LocatorBeep: // Settings > Map > Proximity beeper playMelody(soundctl::MEL_TICK); break; case UiEventType::AdvertHeard: notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop); break; default: break; } } } // ── Message banner ── // An incoming message slides down from under the status bar: an icon, the // sender (or channel) and the start of the text. A tap opens the conversation // (not while the screen is locked), a swipe up puts it away, and it goes by // itself after BANNER_MS. The toast at the bottom stays for everything else. static lv_obj_t* s_banner = nullptr; static lv_obj_t* s_banner_icon = nullptr; static lv_obj_t* s_banner_title = nullptr; static lv_obj_t* s_banner_text = nullptr; static lv_timer_t* s_banner_timer = nullptr; static UIEventType s_banner_kind = UIEventType::none; static int16_t s_banner_ch = -1; static uint8_t s_banner_key[4]; static const uint32_t BANNER_MS = 4000; static void bannerHidden(lv_anim_t* a) { lv_obj_add_flag((lv_obj_t*)a->var, LV_OBJ_FLAG_HIDDEN); } static void bannerHide() { if (!s_banner || lv_obj_has_flag(s_banner, LV_OBJ_FLAG_HIDDEN)) return; if (s_banner_timer) lv_timer_pause(s_banner_timer); lv_anim_delete(s_banner, NULL); int32_t off = -(lv_obj_get_height(s_banner) + theme::STATUS_H); anim::run(s_banner, anim::setTy, lv_obj_get_style_translate_y(s_banner, LV_PART_MAIN), off, anim::OUT_MS, bannerHidden); } static void bannerTimerCb(lv_timer_t* t) { (void)t; bannerHide(); } // Gone at once, no slide: LVGL's timers and animations stand still while the // screen sleeps, so a banner left up would still be there on waking. static void bannerDismiss() { if (!s_banner) return; if (s_banner_timer) lv_timer_pause(s_banner_timer); lv_anim_delete(s_banner, NULL); lv_obj_add_flag(s_banner, LV_OBJ_FLAG_HIDDEN); } static void onBanner(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_GESTURE) { if (lv_indev_get_gesture_dir(lv_indev_active()) == LV_DIR_TOP) bannerHide(); return; } bannerHide(); s_ui->bannerOpen(); } // Built once, behind the status bar (made just after it on the top layer). static void bannerBuild() { const int32_t w = lv_display_get_horizontal_resolution(NULL); s_banner = lv_obj_create(lv_layer_top()); lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_GESTURE_BUBBLE); lv_obj_set_size(s_banner, w - 12, LV_SIZE_CONTENT); lv_obj_align(s_banner, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 2); // Raised off the page: lighter than it on the dark theme, white on the light. lv_obj_set_style_bg_color(s_banner, lv_color_hex(theme::s_light ? theme::SURFACE : theme::SURFACE_2), 0); lv_obj_set_style_bg_color(s_banner, lv_color_hex(theme::s_light ? theme::SURFACE_2 : theme::SURFACE), LV_STATE_PRESSED); lv_obj_set_style_border_color(s_banner, lv_color_hex(theme::EDGE), 0); lv_obj_set_style_border_width(s_banner, 1, 0); lv_obj_set_style_radius(s_banner, theme::RADIUS, 0); lv_obj_set_style_shadow_width(s_banner, 12, 0); lv_obj_set_style_shadow_color(s_banner, lv_color_hex(0x000000), 0); lv_obj_set_style_shadow_opa(s_banner, theme::s_light ? LV_OPA_20 : LV_OPA_60, 0); lv_obj_set_style_pad_all(s_banner, 8, 0); lv_obj_set_style_pad_column(s_banner, 10, 0); lv_obj_set_flex_flow(s_banner, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(s_banner, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_add_event_cb(s_banner, onBanner, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(s_banner, onBanner, LV_EVENT_GESTURE, NULL); s_banner_icon = label(s_banner, LV_SYMBOL_ENVELOPE, THEME_FONT_TITLE, theme::ACCENT); lv_obj_set_width(s_banner_icon, 20); // the text lines up whichever icon lv_obj_set_style_text_align(s_banner_icon, LV_TEXT_ALIGN_CENTER, 0); lv_obj_t* col = flexBox(s_banner, LV_FLEX_FLOW_COLUMN); lv_obj_set_width(col, 1); lv_obj_set_flex_grow(col, 1); lv_obj_set_style_pad_row(col, 1, 0); s_banner_title = label(col, "", THEME_FONT_BODY, theme::TEXT); lv_label_set_long_mode(s_banner_title, LV_LABEL_LONG_DOT); lv_obj_set_width(s_banner_title, LV_PCT(100)); s_banner_text = label(col, "", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_label_set_long_mode(s_banner_text, LV_LABEL_LONG_DOT); // (LONG_DOT cuts at the max height) lv_obj_set_width(s_banner_text, LV_PCT(100)); lv_obj_set_style_max_height(s_banner_text, lv_font_get_line_height(THEME_FONT_SMALL) * 2, 0); // two lines, then "..." lv_obj_add_flag(s_banner, LV_OBJ_FLAG_HIDDEN); } static void bannerShow(const char* icon, const char* title, const char* text) { if (!s_banner) return; lv_label_set_text(s_banner_icon, icon); lv_label_set_text(s_banner_title, title); lv_label_set_text(s_banner_text, text); bool shown = !lv_obj_has_flag(s_banner, LV_OBJ_FLAG_HIDDEN); lv_anim_delete(s_banner, NULL); lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_HIDDEN); if (!shown) { // a new one while it's up just changes the text lv_obj_align(s_banner, LV_ALIGN_TOP_MID, 0, belowTopBar() + 2); layoutNow(s_banner); anim::run(s_banner, anim::setTy, -(lv_obj_get_height(s_banner) + theme::STATUS_H), 0, anim::SCREEN_MS); } else { lv_obj_set_style_translate_y(s_banner, 0, 0); } if (!s_banner_timer) s_banner_timer = lv_timer_create(bannerTimerCb, BANNER_MS, NULL); lv_timer_set_period(s_banner_timer, BANNER_MS); lv_timer_reset(s_banner_timer); lv_timer_resume(s_banner_timer); } void UITask::bannerOpen() { if (locked()) return; // the lock screen first if (s_banner_kind == UIEventType::channelMessage && s_banner_ch >= 0) openChannel((uint8_t)s_banner_ch); else if (s_banner_kind == UIEventType::contactMessage) openDM(s_banner_key); else showChats(); // a room post: the list } void UITask::onMessageArrived(const UiEvent& ev) { if (ev.kind == UIEventType::contactMessage && ev.flag) { // the sender's alert / melody overrides memcpy(_notif_dm_prefix, ev.key, 4); _notif_dm_valid = true; } if (ev.kind == UIEventType::channelMessage) _notif_ch_idx = ev.idx; notify(ev.kind); // The banner: the name from the event, the text from the newest history entry. const char* text = ""; const char* icon = LV_SYMBOL_ENVELOPE; s_banner_kind = ev.kind; s_banner_ch = ev.idx; memcpy(s_banner_key, ev.key, 4); if (ev.kind == UIEventType::channelMessage) { icon = "#"; int pos = ev.idx >= 0 ? _core->history.histEntryForChannel(ev.idx, 0) : -1; if (pos >= 0) text = _core->history.chAtPos(pos).text; } else if (ev.kind == UIEventType::contactMessage) { int pos = _core->history.dmHistEntryForContact(ev.key, 0); if (pos >= 0) text = _core->history.dmAtPos(pos).text; } else { icon = LV_SYMBOL_LIST; // a room } // Wake for the message unless an app is already showing it, or the user // turned message-wake off. bool wake_disabled = _prefs && (!_prefs->msg_wake || inQuietHours(*_prefs, rtc_clock.getCurrentTime())); // quiet hours: dark too if (_asleep && !wake_disabled && !isClientConnected()) wake(); else if (!_asleep) lv_display_trigger_activity(NULL); bool open_here = _screen == SCR_THREAD && (ev.kind == UIEventType::channelMessage ? _thread_is_channel && ev.idx == _thread_channel : !_thread_is_channel && memcmp(_thread_key, ev.key, 4) == 0); bool banner = !_prefs || _prefs->msg_alert != NodePrefs::MSG_ALERT_OFF; if (!open_here && banner && !_asleep) bannerShow(icon, ev.text, text); // not over the very conversation it's in if (_screen == SCR_CHATS && !_nav_overlay) refreshChats(); // new unread counts (not under an open popup) } // On screen: a conversation left open under the lock or a dark display isn't read. bool UITask::threadInView() const { return !_asleep && !locked() && _screen == SCR_THREAD; } bool UITask::isViewingChannel(uint8_t channel_idx) { return threadInView() && _thread_is_channel && _thread_channel == channel_idx; } bool UITask::isViewingDM(const uint8_t* pub_key) { return threadInView() && !_thread_is_channel && memcmp(_thread_key, pub_key, 4) == 0; } // ── Status bar + toast (top layer, over every screen) ───────────────────────── static void onStatusBarTouch(lv_event_t* e); // QuickPanel.h void UITask::buildStatusBar() { // The banner and the toast first: the status bar, made after them, covers // them as they slide in and out from under it. bannerBuild(); _toast = lv_obj_create(lv_layer_top()); lv_obj_remove_flag(_toast, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(_toast, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_size(_toast, LV_SIZE_CONTENT, LV_SIZE_CONTENT); // On the light theme a dark pill, as a phone's: a light one was lost on the page. lv_obj_set_style_bg_color(_toast, lv_color_hex(theme::s_light ? theme::TEXT : theme::SURFACE_2), 0); lv_obj_set_style_border_color(_toast, lv_color_hex(theme::EDGE), 0); lv_obj_set_style_border_width(_toast, theme::s_light ? 0 : 1, 0); lv_obj_set_style_radius(_toast, theme::RADIUS, 0); lv_obj_set_style_pad_hor(_toast, 12, 0); lv_obj_set_style_pad_ver(_toast, 6, 0); lv_obj_align(_toast, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // slides down from under the status bar lv_obj_t* tl = label(_toast, "", THEME_FONT_BODY, theme::s_light ? theme::SURFACE : theme::TEXT); lv_label_set_long_mode(tl, LV_LABEL_LONG_WRAP); // long texts wrap instead of running off the screen lv_obj_set_style_max_width(tl, lv_display_get_horizontal_resolution(NULL) - 40, 0); lv_obj_set_style_text_align(tl, LV_TEXT_ALIGN_CENTER, 0); lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN); lv_obj_t* bar = lv_obj_create(lv_layer_top()); s_status_bar = bar; styleOpaque(bar, theme::BG); lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(bar, LV_OBJ_FLAG_PRESS_LOCK); // pulled down past it: still its touch (the quick panel) lv_obj_remove_flag(bar, LV_OBJ_FLAG_GESTURE_BUBBLE); lv_obj_add_event_cb(bar, onStatusBarTouch, LV_EVENT_ALL, NULL); lv_obj_set_size(bar, LV_PCT(100), theme::STATUS_H); lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_pad_hor(bar, theme::PAD, 0); _status_time = label(bar, "--:--", THEME_FONT_SMALL, theme::TEXT); lv_obj_align(_status_time, LV_ALIGN_LEFT_MID, 0, 0); _status_batt = label(bar, "", THEME_FONT_ICONS, theme::TEXT_MUTED); lv_obj_align(_status_batt, LV_ALIGN_RIGHT_MID, 0, 0); _status_chg = label(bar, LV_SYMBOL_CHARGE, THEME_FONT_ICONS, theme::OK); lv_obj_add_flag(_status_chg, LV_OBJ_FLAG_HIDDEN); _status_icons = lv_obj_create(bar); lv_obj_remove_style_all(_status_icons); lv_obj_remove_flag(_status_icons, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_size(_status_icons, 200, LV_PCT(100)); // fixed: packed to its right edge lv_obj_set_flex_flow(_status_icons, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(_status_icons, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(_status_icons, 2, 0); refreshStatusBar(); } // lv_obj_align_to(o, base, LV_ALIGN_OUT_LEFT_MID, dx, 0), `base` a sibling, // for what's refreshed every second: LVGL restyles the box on every call, so // it redraws moved or not -- here it's only moved. static void placeLeftOf(lv_obj_t* o, lv_obj_t* base, int32_t dx) { layoutNow(o); int32_t x = lv_obj_get_x(base) - lv_obj_get_width(o) + dx; int32_t y = lv_obj_get_y(base) + (lv_obj_get_height(base) - lv_obj_get_height(o)) / 2; if (lv_obj_get_style_align(o, LV_PART_MAIN) != LV_ALIGN_TOP_LEFT || lv_obj_get_x(o) != x || lv_obj_get_y(o) != y) lv_obj_align_to(o, base, LV_ALIGN_OUT_LEFT_MID, dx, 0); } // Whether the title fits its bar. The bar is a row where the title takes what // the rest leave, so the rest gives way: the words of the screen's buttons // (their icons stay, last button first). Only when that is not enough is the // title cut with "...". // A label with a space after its icon is collapsible: clipped to the icon's // width, its text untouched, so a screen may rewrite it later. static void collapseLabel(lv_obj_t* l, const lv_font_t* font, bool on) { const char* t = lv_label_get_text(l); const char* sp = strchr(t, ' '); if (!on || !sp) { lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); lv_obj_set_width(l, LV_SIZE_CONTENT); return; } char icon[8]; size_t n = (size_t)(sp - t) < sizeof(icon) - 1 ? (size_t)(sp - t) : sizeof(icon) - 1; memcpy(icon, t, n); icon[n] = '\0'; lv_point_t sz; lv_text_get_size(&sz, icon, font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); lv_label_set_long_mode(l, LV_LABEL_LONG_CLIP); lv_obj_set_width(l, sz.x); } void UITask::fitHeader() { if (!_header || !_hdr_clock || !_hdr_title) return; lv_obj_t* lbl[8]; // the collapsible labels, in the row's order int n = 0; const int cnt = (int)lv_obj_get_child_count(_header); for (int i = 0; i < cnt && n < 8; i++) { lv_obj_t* b = lv_obj_get_child(_header, i); if (!lv_obj_check_type(b, &lv_button_class) || lv_obj_get_child_count(b) == 0) continue; lv_obj_t* l = lv_obj_get_child(b, 0); if (lv_obj_check_type(l, &lv_label_class)) lbl[n++] = l; } lv_point_t tsz; lv_text_get_size(&tsz, lv_label_get_text(_hdr_title), THEME_FONT_TITLE, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); auto place = [&]() { // the row stops short of the clock pill layoutNow(_hdr_clock); const int32_t w = lv_obj_get_width(_hdr_clock); if (lv_obj_get_style_pad_right(_header, LV_PART_MAIN) != w) { lv_obj_set_style_pad_right(_header, w, 0); lv_obj_align(_hdr_clock, LV_ALIGN_RIGHT_MID, w, 0); // aligned inside the padding: out into it } layoutNow(_header); return lv_obj_get_width(_hdr_title) >= tsz.x; }; for (int i = 0; i < n; i++) collapseLabel(lbl[i], THEME_FONT_SMALL, false); if (place()) return; for (int i = n - 1; i >= 0; i--) { collapseLabel(lbl[i], THEME_FONT_SMALL, true); if (place()) return; } } void UITask::refreshStatusBar() { if (!_status_time) return; struct tm ti; if (localTime(_prefs, ti)) { char clk[12]; fmtClock(clk, sizeof(clk), ti, _prefs, true); setText(_status_time, clk); } else { setText(_status_time, "--:--"); } // Battery: an icon, % or volts per Settings > Power > Battery display. The // smoothed reading (checkLowBattery): an ADC read blocks the loop for 10 ms. uint16_t mv = _batt_mv ? _batt_mv : getBattMilliVolts(); int pct = battery::percent(mv, _prefs ? _prefs->low_batt_mv : 0); char batt[12]; switch (battery::mode(_prefs ? _prefs->batt_display_mode : 0)) { case battery::PERCENT: snprintf(batt, sizeof(batt), "%d%%", pct); break; case battery::VOLTAGE: snprintf(batt, sizeof(batt), "%u.%02uV", mv / 1000, (mv % 1000) / 10); break; default: snprintf(batt, sizeof(batt), "%s", pct > 80 ? LV_SYMBOL_BATTERY_FULL : pct > 55 ? LV_SYMBOL_BATTERY_3 : pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY); } setText(_status_batt, batt); const bool chg = _board->isExternalPowered(); if (_hdr_clock) { // the title bar's: the time over the battery, green on the charger // the pill's width changes with these: fitHeader() before the next frame if (!labelShows(_hdr_time, lv_label_get_text(_status_time)) || !labelShows(_hdr_batt, batt)) s_hdr_fit = true; setText(_hdr_time, lv_label_get_text(_status_time)); setText(_hdr_batt, batt); setTextColor(_hdr_batt, chg ? theme::OK : theme::TEXT_MUTED); } lv_obj_t* left_of = _status_batt; // the icons pack up to this if (chg) { lv_obj_remove_flag(_status_chg, LV_OBJ_FLAG_HIDDEN); placeLeftOf(_status_chg, _status_batt, -2); left_of = _status_chg; } else lv_obj_add_flag(_status_chg, LV_OBJ_FLAG_HIDDEN); // Status icons, as the original's status bar (ui-new): Bluetooth (accent // when the app is connected), WiFi (when switched on, accent connected), GPS (green with a fix), the alarm, mute, then // the modes that keep running in the background -- auto-advert, trail, live // share, repeater, arrival alert -- in the accent colour. Right to left in // that order, next to the battery. struct Icon { const char* sym; uint32_t col; }; Icon icons[11]; int n = 0; if (isBluetoothEnabled()) icons[n++] = { LV_SYMBOL_BLUETOOTH, hasConnection() ? theme::ACCENT : theme::TEXT_MUTED }; if (lvport::wifiAllowed()) // WiFi switched on: accent while connected (map tiles, update) icons[n++] = { LV_SYMBOL_WIFI, lvport::netRadio() == lvport::NET_UP ? theme::ACCENT : theme::TEXT_MUTED }; int32_t lat, lon; if (_core->gpsEnabled()) { // switched off, the module still reports its last fix as valid bool fix = _core->course.currentLocation(lat, lon); icons[n++] = { LV_SYMBOL_GPS, fix ? theme::OK : theme::TEXT_MUTED }; } if (_prefs && _prefs->alarm_on) icons[n++] = { LV_SYMBOL_BELL, theme::TEXT_MUTED }; #ifdef PIN_BUZZER if (_buzzer.isQuiet()) icons[n++] = { UI_SYMBOL_MUTE, theme::TEXT_MUTED }; #endif if (_prefs && _prefs->advert_auto_interval_sec > 0) icons[n++] = { UI_SYMBOL_RADIO, theme::ACCENT }; if (_core->trail.isActive()) icons[n++] = { UI_SYMBOL_ROUTE, theme::ACCENT }; if (_prefs && _prefs->loc_share_enabled) icons[n++] = { UI_SYMBOL_PIN, theme::ACCENT }; if (_prefs && _prefs->client_repeat) icons[n++] = { LV_SYMBOL_LOOP, theme::ACCENT }; if (_prefs && _prefs->locator_enabled && _prefs->locator_has_target) icons[n++] = { UI_SYMBOL_FLAG, theme::ACCENT }; char sig[sizeof(_status_sig)]; int o = 0; for (int i = 0; i < n && o < (int)sizeof(sig) - 12; i++) o += snprintf(sig + o, sizeof(sig) - o, "%s%lx", icons[i].sym, (unsigned long)icons[i].col); sig[o] = '\0'; if (strcmp(sig, _status_sig) != 0) { // rebuilt only when something changed strcpy(_status_sig, sig); lv_obj_clean(_status_icons); for (int i = n - 1; i >= 0; i--) // the icon font gives each the same size and cell label(_status_icons, icons[i].sym, THEME_FONT_ICONS, icons[i].col); } placeLeftOf(_status_icons, left_of, -5); } // The status bar is up over a screen without a title bar (Home, the map, a // conversation typing), and over the quick panel and the lock whatever the // screen; a title bar carries the time and battery itself. void UITask::statusBarSync() { if (!s_status_bar) return; const bool titled = _header && !lv_obj_has_flag(_header, LV_OBJ_FLAG_HIDDEN); const bool hide = titled && !s_sb_panel && !s_sb_lock; if (hide != lv_obj_has_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN)) lv_obj_set_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN, hide); } void UITask::setGps(bool on) { if (!_core->setGpsEnabled(on, false)) { showToast("No GPS on this device"); return; } prefsSave(); // written on leaving the screen, as the other switches refreshStatusBar(); } void UITask::botSetGPS(bool on) { if (_core->setGpsEnabled(on)) refreshStatusBar(); } bool UITask::ensureGps() { int32_t lat, lon; if (_core->gpsAvailable() && !_core->gpsEnabled()) { setGps(true); return false; } // off, its last fix still reads valid if (_core->course.currentLocation(lat, lon)) return true; showToast("Waiting for a GPS fix"); return false; } // One persistent timer, paused between toasts: hides the toast when it fires. static void toastHidden(lv_anim_t* a) { lv_obj_add_flag((lv_obj_t*)a->var, LV_OBJ_FLAG_HIDDEN); } static void toastTimerCb(lv_timer_t* t) { // back up under the status bar lv_obj_t* o = (lv_obj_t*)lv_timer_get_user_data(t); anim::run(o, anim::setTy, 0, -(lv_obj_get_y(o) + lv_obj_get_height(o)), anim::OUT_MS, toastHidden); lv_timer_pause(t); } // A deliberate restart (the card formatted, the USB drive given back): a full // screen saying so -- drawn at once, the loop stops here -- unsaved settings // written, then the restart. Without it the screen just froze. static void restartScreen(const char* why) { lv_obj_t* o = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(o); lv_obj_set_size(o, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(o, lv_color_hex(theme::BG), 0); lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); lv_obj_set_flex_flow(o, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(o, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(o, 8, 0); label(o, LV_SYMBOL_REFRESH, THEME_FONT_LARGE, theme::ACCENT); label(o, "Restarting...", THEME_FONT_LARGE, theme::TEXT); lv_obj_t* w = label(o, why, THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_set_style_text_align(w, LV_TEXT_ALIGN_CENTER, 0); lv_obj_invalidate(o); lv_refr_now(NULL); prefsSaveSoon(0); prefsFlush(); delay(1200); // long enough to read lvport::restart(); lv_obj_delete(o); // the sim carries on } // The screen as it is, toasts and popups included, re-rendered once into a // buffer the flush callback fills (lvport::shotCopy). uint16_t* UITask::captureFrame(int& w, int& h) { lv_display_t* d = lv_display_get_default(); w = lv_display_get_horizontal_resolution(d); h = lv_display_get_vertical_resolution(d); #ifdef ESP32 uint16_t* px = (uint16_t*)heap_caps_malloc(w * h * 2, MALLOC_CAP_SPIRAM); #else uint16_t* px = (uint16_t*)malloc(w * h * 2); #endif if (!px) return nullptr; lvport::s_shot = px; lv_obj_invalidate(lv_screen_active()); // the whole display, every layer lv_refr_now(d); lvport::s_shot = nullptr; return px; } // The screen, written to the card as a 24-bit BMP: /sdcard/screenshots/scr_NNNN.bmp. void UITask::takeScreenshot() { if (!lvport::mountStorage()) { showToast("No SD card"); return; } mkdir("/sdcard", 0777); // the sim's MEMFS may not have it yet (the card: fails, harmless) mkdir("/sdcard/screenshots", 0777); char path[48]; struct stat st; int n = 1; for (; n <= 9999; n++) { snprintf(path, sizeof(path), "/sdcard/screenshots/scr_%04d.bmp", n); if (stat(path, &st) != 0) break; } if (n > 9999) { showToast("Screenshots folder full"); return; } int W, H; uint16_t* px = captureFrame(W, H); if (!px) { showToast("Out of memory"); return; } bool ok = false; if (FILE* f = fopen(path, "wb")) { const uint32_t row = (uint32_t)W * 3, size = 54 + row * H; // W*3 is a multiple of 4 at 320 uint8_t hdr[54] = {'B', 'M'}; auto put32 = [&](int at, uint32_t v) { for (int i = 0; i < 4; i++) hdr[at + i] = (uint8_t)(v >> (8 * i)); }; put32(2, size); put32(10, 54); put32(14, 40); put32(18, W); put32(22, H); hdr[26] = 1; hdr[28] = 24; put32(34, row * H); ok = fwrite(hdr, 1, 54, f) == 54; static PSRAM_BSS uint8_t line[320 * 3]; // not on the loop task's stack for (int32_t y = H - 1; ok && y >= 0; y--) { // bottom-up, BGR const uint16_t* s = px + y * W; for (int32_t x = 0; x < W && x < 320; x++) { uint16_t c = s[x]; uint8_t r = (c >> 11) & 0x1F, g = (c >> 5) & 0x3F, b = c & 0x1F; line[x * 3 + 0] = (uint8_t)((b << 3) | (b >> 2)); line[x * 3 + 1] = (uint8_t)((g << 2) | (g >> 4)); line[x * 3 + 2] = (uint8_t)((r << 3) | (r >> 2)); } ok = fwrite(line, 1, row, f) == row; } ok = (fclose(f) == 0) && ok; } free(px); if (!ok) { remove(path); showToast("Couldn't save the screenshot"); return; } // A white frame flashing round the edges, then where it went. Four thin // strips, not a full-screen layer: each frame of the fade redraws only // them (a whole 320x240 frame per step tore and stuttered on the panel). const int32_t T = 6; const int32_t strip[4][4] = { { 0, 0, W, T }, { 0, H - T, W, T }, { 0, T, T, H - 2 * T }, { W - T, T, T, H - 2 * T } }; for (const auto& r : strip) { lv_obj_t* f = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(f); lv_obj_set_pos(f, r[0], r[1]); lv_obj_set_size(f, r[2], r[3]); lv_obj_set_style_bg_color(f, lv_color_white(), 0); lv_obj_remove_flag(f, LV_OBJ_FLAG_CLICKABLE); anim::run(f, anim::setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, 400, anim::coverDone); } char msg[40]; snprintf(msg, sizeof(msg), "Saved scr_%04d.bmp", n); showToast(msg); } void UITask::showToast(const char* text, uint32_t ms) { if (!_toast) return; lv_label_set_text(lv_obj_get_child(_toast, 0), text); bool shown = !lv_obj_has_flag(_toast, LV_OBJ_FLAG_HIDDEN); lv_anim_delete(_toast, NULL); // on its way out lv_obj_remove_flag(_toast, LV_OBJ_FLAG_HIDDEN); // Under the message banner while that's up, else right under the top bar. int32_t y = belowTopBar() + 4; if (s_banner && !lv_obj_has_flag(s_banner, LV_OBJ_FLAG_HIDDEN)) y = lv_obj_get_y(s_banner) + lv_obj_get_height(s_banner) + 4; lv_obj_align(_toast, LV_ALIGN_TOP_MID, 0, y); layoutNow(_toast); if (!shown) anim::run(_toast, anim::setTy, -(y + lv_obj_get_height(_toast)), 0, anim::POP_MS); // a replaced text just changes else lv_obj_set_style_translate_y(_toast, 0, 0); if (!_toast_timer) _toast_timer = lv_timer_create(toastTimerCb, ms, _toast); lv_timer_set_period(_toast_timer, ms); lv_timer_reset(_toast_timer); lv_timer_resume(_toast_timer); } // ── Screens ─────────────────────────────────────────────────────────────────── static void onBack(lv_event_t* e) { (void)e; s_ui->back(); } // A fresh screen below the status bar, loaded in place of the current one; // returns its content area (flex column). The old screen is deleted // asynchronously -- this usually runs from a click on one of its own widgets. static uint32_t s_wifi_test_ms = 0; // Settings > WiFi checking a just-saved network (WifiScreen.h) namespace home { static void leave(); } // HomeScreen.h static int s_th_rows_n = 0; // conversation bubbles built (refreshThread) // A screen builds in pieces over several loop passes: what shows first at // once, the rest as time allows. A widget costs ~1 ms on the L2 (they live in // PSRAM), so 48 nodes, a few hundred contacts or a long settings page built // in one go held the screen for up to a second. The pieces queue in order: // fill(fn, from, to) makes fn(from) .. fn(to - 1), each from the data as it // is then; then(fn, arg) makes its piece at once unless others still wait, // keeping its place after them. A new screen drops what is left. struct FillJob { void (UITask::*fn)(int); int next, end; }; static const int FILL_JOBS = 12; static FillJob s_fill[FILL_JOBS]; static int s_fill_n = 0; static const uint32_t FILL_BUDGET_US = 25000; // pieces made per loop pass: about a frame's worth void UITask::fill(void (UITask::*fn)(int), int from, int to) { if (from >= to) return; if (s_fill_n == FILL_JOBS) { while (from < to) (this->*fn)(from++); return; } // no room: now s_fill[s_fill_n++] = { fn, from, to }; } void UITask::then(void (UITask::*fn)(int), int arg) { if (s_fill_n) fill(fn, arg, arg + 1); else (this->*fn)(arg); } // Rows 0 .. n-1 of a list: the first `first` at once, the rest queued. void UITask::fillStart(int n, int first, void (UITask::*row)(int)) { int now = first < n ? first : n; for (int i = 0; i < now; i++) (this->*row)(i); fill(row, now, n); } void UITask::fillStep() { FillJob& j = s_fill[0]; (this->*j.fn)(j.next++); // may queue more, behind if (!s_fill_n) return; // the piece built a new screen: the queue went with the old one if (j.next >= j.end) memmove(s_fill, s_fill + 1, --s_fill_n * sizeof(FillJob)); } void UITask::fillTick() { // Not while the screen moves in: its frames came a budget apart (Nodes, // 57 rows: 33 ms a frame, up to 65). What it shows first is built already. if (!s_fill_n || (_body && lv_anim_get(_body, NULL)) || lv_display_get_screen_prev(NULL)) return; uint32_t t0 = micros(); while (s_fill_n) { fillStep(); if (micros() - t0 >= FILL_BUDGET_US) break; } } // Everything still queued, now: before scrolling to where the screen was. void UITask::fillFlush() { while (s_fill_n) fillStep(); } int UITask::fillPending() const { int n = 0; for (int k = 0; k < s_fill_n; k++) n += s_fill[k].end - s_fill[k].next; return n; } #include "NodeViz.h" // the node detail's rose and path (newScreen() drops its card) lv_obj_t* UITask::newScreen(const char* title, bool with_back) { quickPanelClose(false); s_fill_n = 0; // what was still to be built goes with the screen _home_clock = _home_date = _home_unread = nullptr; _thread_list = _compose_ta = _keyboard = nullptr; s_th_rows_n = 0; // the bubbles went with the screen if (_shown_screen == SCR_SETTINGS && _body) s_settings_y = lv_obj_get_scroll_y(_body); pickerClose(); _header = _body = nullptr; _nearby_list = _nearby_status = nullptr; _node_info = _node_ping = _node_delete_lbl = nullptr; nodeviz::s_card = nullptr; _scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen _map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = _map_center_btn = nullptr; _dl_overlay = _dl_info = _dl_sub = _dl_start_lbl = _dl_job_row = _dl_job_lbl = nullptr; _nav_bar = _nav_title = _nav_info = _nav_clear = nullptr; _nav_overlay = _nav_ta = _nav_kb = _nav_del_lbl = _nav_rec = _nav_avg_pill = nullptr; _nav_trail_lbl = _nav_trail_btn = _nav_reset_lbl = _nav_share_lbl = _nav_share_btn = _nav_tb_btn = nullptr; _wifi_ssid = _wifi_pass = _wifi_kb = _wifi_list = _wifi_status = nullptr; if ((_wifi_scanning || s_wifi_test_ms) && !wifiInUse()) lvport::netEnd(); // left mid-scan / check: the radio goes off _wifi_scanning = false; s_wifi_test_ms = 0; _ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = nullptr; for (lv_obj_t*& t : _map_tiles) t = nullptr; lv_obj_t* prev = _scr; lv_obj_t* scr = lv_obj_create(NULL); s_fit_screen = scr; // rowsFit() s_fit_pending = true; styleOpaque(scr, theme::BG); lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); // The title bar, at the very top in place of the status bar: back, the // title, the screen's actions (headerButton(), after the title in a row), // and at the right end the time and battery. A drag down on it brings the // quick panel, as on the status bar. int top = theme::STATUS_H; _hdr_clock = _hdr_time = _hdr_batt = _hdr_title = nullptr; if (title) { lv_obj_t* hdr = lv_obj_create(scr); styleSurface(hdr, theme::BG); lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(hdr, LV_PCT(100), theme::APPBAR_H); lv_obj_set_pos(hdr, 0, 0); lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_left(hdr, with_back ? 4 : theme::PAD, 0); lv_obj_set_style_pad_column(hdr, theme::GAP, 0); lv_obj_add_flag(hdr, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(hdr, LV_OBJ_FLAG_PRESS_LOCK); lv_obj_remove_flag(hdr, LV_OBJ_FLAG_GESTURE_BUBBLE); lv_obj_add_event_cb(hdr, onStatusBarTouch, LV_EVENT_ALL, (void*)1); // a drag only: a tap on the title is nothing _header = hdr; if (with_back) { lv_obj_t* b = lv_button_create(hdr); lv_obj_set_size(b, 40, theme::BAR_BTN_H); lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); lv_obj_set_style_shadow_width(b, 0, 0); lv_obj_add_event_cb(b, onBack, LV_EVENT_CLICKED, NULL); lv_obj_center(label(b, LV_SYMBOL_LEFT, THEME_FONT_BODY, theme::TEXT)); } lv_obj_t* t = label(hdr, title, THEME_FONT_TITLE, theme::TEXT); lv_label_set_long_mode(t, LV_LABEL_LONG_DOT); lv_obj_set_width(t, 1); lv_obj_set_flex_grow(t, 1); // what the actions leave _hdr_title = t; s_hdr_fit = true; // The time and battery on a pill as the bar's buttons are, out of the // row: the row stops short of it (the bar's right padding, refreshStatusBar()). lv_obj_t* c = lv_obj_create(hdr); lv_obj_remove_style_all(c); lv_obj_add_flag(c, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(c, LV_OBJ_FLAG_PRESS_LOCK); lv_obj_remove_flag(c, LV_OBJ_FLAG_GESTURE_BUBBLE); lv_obj_add_event_cb(c, onStatusBarTouch, LV_EVENT_ALL, NULL); // a tap opens the panel, as the status bar's lv_obj_set_size(c, LV_SIZE_CONTENT, LV_PCT(100)); lv_obj_set_style_pad_left(c, theme::GAP, 0); lv_obj_set_style_pad_right(c, 4, 0); lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(c, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_align(c, LV_ALIGN_RIGHT_MID, 0, 0); _hdr_clock = c; lv_obj_t* p = lv_obj_create(c); lv_obj_remove_style_all(p); lv_obj_remove_flag(p, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_size(p, LV_SIZE_CONTENT, theme::BAR_BTN_H); lv_obj_set_flex_flow(p, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(p, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(p, 6, 0); lv_obj_set_style_bg_color(p, lv_color_hex(theme::SURFACE), 0); lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); lv_obj_set_style_radius(p, theme::RADIUS, 0); lv_obj_set_style_pad_hor(p, 8, 0); _hdr_time = label(p, "", THEME_FONT_BODY, theme::TEXT); _hdr_batt = label(p, "", THEME_FONT_ICONS, theme::TEXT_MUTED); top = theme::APPBAR_H; } lv_obj_t* body = lv_obj_create(scr); styleSurface(body, theme::BG); lv_obj_set_size(body, lv_display_get_horizontal_resolution(NULL), lv_display_get_vertical_resolution(NULL) - top); lv_obj_set_pos(body, 0, top); lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(body, theme::PAD, 0); lv_obj_set_style_pad_row(body, theme::GAP, 0); _body = body; // The new screen emerges from the middle (Anim.h); a screen rebuilt in // place (same screen, same title) swaps without motion. bool same = _screen == _shown_screen && strncmp(title ? title : "", _shown_title, sizeof(_shown_title) - 1) == 0; bool backward = _nav_back || screenDepth(_screen) < screenDepth(_shown_screen); _nav_back = false; if (!same) prefsSaveSoon(anim::FADE_MS + 60); // left the screen they were changed on if (_shown_screen == SCR_HOME && _screen != SCR_HOME) home::leave(); // arranging the apps ends, saved _shown_screen = _screen; snprintf(_shown_title, sizeof(_shown_title), "%s", title ? title : ""); _scr = scr; if (_fade_next && prev && !_asleep && prev == lv_screen_active()) { // A cross-fade: the new screen dissolves in over the old one, deleted after. lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_FADE_IN, anim::FADE_MS, 0, true); } else { lv_screen_load(scr); if (prev) lv_obj_delete_async(prev); // this usually runs from one of its own widgets if (prev && !same && !_asleep) anim::screenIn(body, backward); } _fade_next = false; statusBarSync(); refreshStatusBar(); // the title bar's time and battery return body; } // How deep a screen sits below Home, for the slide direction. int UITask::screenDepth(Screen s) { switch (s) { case SCR_HOME: return 0; case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS: case SCR_COMPASS: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK: case SCR_DIAG: case SCR_GPS: return 1; case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE: case SCR_SETTINGS_NAV: case SCR_ADMIN: return 2; default: return 3; // pages under a settings page } } void UITask::back() { if (quickPanelOpen()) { quickPanelClose(true); return; } _nav_back = true; // the screen shown from here slides back switch (_screen) { case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break; case SCR_CONTACTS: showChats(); break; case SCR_CHANNEL_EDIT: showChats(); break; case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_ADMIN_PICK: showHome(); break; case SCR_ROUTE: // back to the node; a trace in flight is dropped _core->route.cancelTrace(); _screen = SCR_NODE; buildNode(); break; case SCR_ROUTE_PICK: _screen = SCR_ROUTE; buildRoute(); break; case SCR_OTA: otaLeave(); break; case SCR_COMPASS: showHome(); break; case SCR_BATT: showSchemaSettings(PG_POWER); break; case SCR_GPS: if (_gps_from_settings) showSettings(); else showHome(); break; case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: // the map's options go back to the map if (_settings_page == settings::PG_NAV && s_opts_from_map) { // The map as it was left -- its mode and view, Map tools over it, // scrolled where it was -- dissolving in over the page. const bool tools = s_nav_section >= 0; _fade_next = true; showMap(false); if (tools) navToolsPopup(true); } else if (_settings_page == settings::PG_RADIO) { showRadio(); } else { showSettings(); } break; case SCR_QUICK: // back to Messages & contacts' bottom, where the row is if (_nav_overlay) { navClosePopup(); break; } showSchemaSettings(settings::PG_MESSAGES); if (_body) { layoutNow(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); } break; case SCR_MELODY: // back to the Sound page's bottom, where the melodies are melodySave(); stopMelody(); showSchemaSettings(settings::PG_SOUND); if (_body) { layoutNow(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); } break; case SCR_CLOCK: showHome(); break; case SCR_RADIO: if (radioPopupOpen()) radioCloseFreq(); else if (_nav_overlay) navClosePopup(); else showSettings(); break; case SCR_REPEATER: if (radioPopupOpen()) radioCloseFreq(); else if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SCOPES: // back to the Radio screen's bottom, where Scopes is if (_nav_overlay) { navClosePopup(); break; } showRadio(); if (_body) { layoutNow(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); } break; case SCR_CHATS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_NEARBY: if (_nav_overlay) navClosePopup(); // the advert popup else if (_scan_overlay) closeScanPopup(); else showHome(); break; case SCR_NODE: // back to where the node was picked: map, the list, or the scan popup over it if (_node_from_map) { _map_nodes = true; showMap(false); break; } // the nodes map as it was left _screen = SCR_NEARBY; buildNearby(); if (_node_from_scan) showScanPopup(); break; case SCR_MAP: if (_route_pick) { routeMapDone(); break; } if (_nav_overlay) navClosePopup(); else if (areaSelecting()) areaSelectEnd(); else if (_dl_overlay) mapDownloadClose(); else if (_map_nodes || !_map_nav) showNearby(); // opened from Nodes else showHome(); break; case SCR_WIFI: showSettings(); break; case SCR_STORAGE: storeview::stopWalk(); showSettings(); break; default: break; } _nav_back = false; // only closed a popup } // ── Home ────────────────────────────────────────────────────────────────────── static void onOpenChats(lv_event_t* e) { (void)e; s_ui->showChats(); } static void onOpenSettings(lv_event_t* e) { (void)e; s_ui->showSettings(); } static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); } static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); } static void onOpenClock(lv_event_t* e); // ClockScreen.h static void onOpenBot(lv_event_t* e); // BotScreen.h static void onOpenCompass(lv_event_t* e); // CompassScreen.h static void onOpenFlashlight(lv_event_t* e); // Flashlight.h static void onOpenDiag(lv_event_t* e); // DiagScreen.h static void onOpenGps(lv_event_t* e); // GpsScreen.h static void onOpenGpsFromSettings(lv_event_t* e); static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h static void onNodeName(lv_event_t* e); static void onPowerRow(lv_event_t* e); static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr_t)lv_event_get_user_data(e)); } // Home itself (pages, clock, minimap, apps) is HomeScreen.h. #include "VirtualList.h" #include "ChatList.h" #include "NodesView.h" #include "MapScreen.h" #include "Chart.h" #include "ProfileView.h" #include "NavMap.h" #include "MapAreas.h" #include "MapRegions.h" #include "HomeScreen.h" #include "ClockScreen.h" static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced #include "RadioScreen.h" #include "WifiScreen.h" #include "ChannelScreen.h" #include "AdminScreen.h" #include "RouteScreen.h" #include "BotScreen.h" #include "ThreadView.h" #include "SettingsPages.h" #include "ConversationScreen.h" #include "DeviceScreen.h" #include "DiagScreen.h" #include "CompassScreen.h" #include "Flashlight.h" #include "BatteryScreen.h" #include "GpsScreen.h" #include "RadioExtras.h" #include "RepeaterScreen.h" #include "SoundScreen.h" #include "QuickScreen.h" #include "QuickPanel.h" #include "OtaScreen.h" #include "StorageScreen.h" #include "Splash.h" // The screen on, the full CPU clock while it's worth having: touched in the // last CPU_IDLE_MS, something moving, map tiles to decode, a download or an // update on WiFi. Otherwise 80 MHz (lvport::cpuSlow) -- a clock ticking over, // a message being read. static constexpr uint32_t CPU_IDLE_MS = 2000; bool UITask::cpuNeeded() { #ifdef UI_PERF_TEST return true; // measured at the full clock #endif if (lv_display_get_inactive_time(NULL) < CPU_IDLE_MS || lv_anim_count_running() > 0) return true; if (wifiInUse() || mapview::s_dl.liveQueued() > 0) return true; if (_screen == SCR_MAP && _map_pending) return true; if (_screen == SCR_HOME && home::mini::s_area && home::mini::s_pending) return true; return false; } #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) void UITask::simCompose(const char* text) { if (!_compose_ta) return; setKeyboardVisible(true); lv_textarea_set_text(_compose_ta, text); } void UITask::simDm() { for (int i = 0; i < the_mesh.getNumContacts(); i++) { ContactInfo c; if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) && c.type == ADV_TYPE_CHAT) { openDM(c.id.pub_key); return; } } } void UITask::simUnread() { _core->simUnreadDemo(); if (_screen == SCR_CHATS) refreshChats(); } // Sim tests: straight to a screen by name. extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!s_ui) return; struct { const char* n; void (UITask::*fn)(); } const SCREENS[] = { { "home", &UITask::showHome }, { "chats", &UITask::showChats }, { "settings", &UITask::showSettings }, { "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi }, { "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes }, { "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage }, { "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "admin", &UITask::showAdminPick }, { "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts }, }; for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; } if (!strncmp(name, "page", 4)) { s_ui->showSchemaSettings(atoi(name + 4)); return; } if (!strcmp(name, "map")) { s_ui->openMap(true); return; } if (!strcmp(name, "gps")) { s_ui->showGps(false); return; } if (!strcmp(name, "nodesmap")) { s_ui->openMap(false); return; } // as from Nodes if (!strcmp(name, "battcurve")) { s_ui->showBattCurve(); return; } if (!strcmp(name, "homeedit")) { s_ui->showHome(); s_ui->homeEdit(true); return; } // arranging the apps if (!strncmp(name, "accent@", 7)) { theme::setAccent(atoi(name + 7)); return; } // "accent@4": Cyan if (!strncmp(name, "map@", 4)) { s_ui->simMapAt(name + 4); return; } // "map@lat,lon,z" if (!strncmp(name, "channel@", 8)) { s_ui->openChannel(atoi(name + 8)); return; } if (!strncmp(name, "compose@", 8)) { s_ui->simCompose(name + 8); return; } if (!strcmp(name, "dm")) { s_ui->simDm(); return; } if (!strcmp(name, "send")) { s_ui->simSend(); return; } if (!strcmp(name, "unread")) { s_ui->simUnread(); return; } if (!strcmp(name, "sleep")) { s_ui->simSleep(true); return; } if (!strcmp(name, "wake")) { s_ui->simSleep(false); return; } if (!strcmp(name, "lock")) { s_ui->simLock(true); return; } if (!strcmp(name, "unlock")) { s_ui->simLock(false); return; } if (!strcmp(name, "vector")) { s_ui->setVectorMap(true); return; } if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; } if (!strcmp(name, "areasel")) { s_ui->areaSelectBegin(); return; } if (!strcmp(name, "areas")) { s_ui->mapAreasPopup(); return; } if (!strcmp(name, "regions")) { s_ui->mapRegionsPopup(); return; } if (!strcmp(name, "region0")) { s_ui->mapRegionPopup(0); return; } if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; } if (!strcmp(name, "trailsave")) { s_ui->navToolAction(navmap::TL_TRAIL_SAVE); return; } // Map tools > Save if (!strcmp(name, "histdemo")) { // 8 h of made-up history, for the charts using namespace diagview; for (int i = 0; i < 96; i++) { s_hist.batt.push((int16_t)(4150 - i * 4 + (i % 7) * 3)); s_hist.noise.push((int16_t)(-112 + (i % 11 == 0 ? 6 : 0) + (i / 24))); s_hist.traffic.push((int16_t)((i * 37) % 13 + (i % 16 == 0 ? 20 : 0))); } for (int i = 0; i < 32; i++) s_hist.snr.push((int16_t)(4 * (8 - (i * 7) % 19))); s_hist.version++; s_near.f0 = 869.525f - s_near.step * (s_near.n / 2); // and a noise sweep for (int i = 0; i < s_near.n; i++) s_near.v[i] = (int16_t)(-118 + (i % 9 == 0 ? 7 : (i * 5) % 4)); s_floor_med = -116; s_floor_lo = -118; s_noise_have = true; return; } if (!strcmp(name, "profdemo")) { // a made-up height profile in a popup, for the chart using namespace profileview; s_prof = trailprofile::Sampler(); for (int i = 0; i < 100; i++) { s_prof.dist[i] = i * 62.0f; s_prof.alt[i] = (int16_t)(420 + 80 * sinf(i / 14.0f) + i * 1.5f); s_prof.st.add(s_prof.alt[i]); } s_prof.n = 100; s_prof.total = 99 * 62.0f; lv_obj_t* box = lv_obj_create(lv_layer_top()); styleSurface(box, theme::BG); lv_obj_set_size(box, 304, 132); lv_obj_center(box); profileChart(box, 120); return; } if (!strcmp(name, "savedtrail0")) { navmap::scanTrails(); s_ui->savedTrailPopup(0); return; } if (!strncmp(name, "trail@", 6)) { // "trail@name.trl" from /sdcard/trails: Load, as the saved-trail popup does navmap::scanTrails(); for (int i = 0; i < navmap::s_st_n; i++) if (!strcmp(navmap::s_st_names[i], name + 6)) { navmap::s_st_sel = i; s_ui->savedTrailAction(navmap::TL_ST_LOAD); } return; } } // Scrolls the screen's (or a popup's) main list by dy; returns what was left to scroll. static void dbgScrollable(lv_obj_t* o, lv_obj_t*& best, int& h) { if (lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN) || lv_obj_check_type(o, &lv_roller_class) || lv_obj_check_type(o, &lv_dropdownlist_class)) return; int v = lv_obj_get_scroll_bottom(o) + lv_obj_get_scroll_y(o); if (lv_obj_has_flag(o, LV_OBJ_FLAG_SCROLLABLE) && v > h) { h = v; best = o; } for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) dbgScrollable(lv_obj_get_child(o, i), best, h); } extern "C" EMSCRIPTEN_KEEPALIVE int sim_scroll(int dy) { lv_obj_t* o = nullptr; int h = 10; dbgScrollable(lv_layer_top(), o, h); if (!o) dbgScrollable(lv_screen_active(), o, h); if (!o) return -1; int left = lv_obj_get_scroll_bottom(o); lv_obj_scroll_by(o, 0, -(dy < left ? dy : left), LV_ANIM_OFF); return left; } #endif #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) // The simulator page opens a screen by name (screenshots of the layout): // home, chats, contacts, settings, settings:, nearby, map, navmap, places, clock, radio, // gps, diag, compass, storage, repeater, wifi, bot, quick, node, channel, layer:. extern "C" EMSCRIPTEN_KEEPALIVE int sim_ui_show(const char* name) { if (!s_ui || !name) return 0; UITask& u = *s_ui; if (!strcmp(name, "home")) u.showHome(); else if (!strcmp(name, "chats")) u.showChats(); else if (!strcmp(name, "contacts")) u.showContacts(); else if (!strcmp(name, "settings")) u.showSettings(); else if (!strncmp(name, "settings:", 9)) u.showSchemaSettings(atoi(name + 9)); else if (!strcmp(name, "nearby")) u.showNearby(); else if (!strcmp(name, "map")) u.openMap(false); else if (!strcmp(name, "navmap")) u.openMap(true); else if (!strcmp(name, "places")) { u.openMap(true); u.navTargetsPopup(); } else if (!strncmp(name, "layer:", 6)) u.setMapLayer(atoi(name + 6)); // the map's base layer (MapLayers.h) else if (!strcmp(name, "clock")) u.showClock(); else if (!strcmp(name, "radio")) u.showRadio(); else if (!strcmp(name, "gps")) u.showGps(false); else if (!strcmp(name, "diag")) u.showDiag(); else if (!strcmp(name, "compass")) u.showCompass(); else if (!strcmp(name, "storage")) u.showStorage(); else if (!strcmp(name, "repeater")) u.showRepeater(); else if (!strcmp(name, "wifi")) u.showWifi(); else if (!strcmp(name, "bot")) u.showBot(); else if (!strcmp(name, "quick")) u.showQuickMsgs(); else if (!strcmp(name, "node")) { u.showNearby(); u.openNode(0); } else if (!strcmp(name, "channel")) u.openChannel(0); else return 0; return 1; } #endif