// app lifecycle manager: nav stack, dispatch, font propagation, draw // // all dispatch is static (monomorphized via with_app!); no dyn, no vtable // loading indicator is drawn between app content and overlays so it // sits on top of page content but under quick menu and button bumps use crate::apps::files::FilesApp; use crate::apps::home::HomeApp; use crate::apps::reader::ReaderApp; use crate::apps::settings::SettingsApp; use crate::apps::stats::StatsApp; use crate::apps::{ App, AppContext, AppId, DeferredPersistenceReason, Launcher, PendingSetting, Redraw, Transition, }; use esp_hal::delay::Delay; use crate::apps::widgets::quick_menu::{MAX_APP_ACTIONS, QuickMenuResult}; use crate::apps::widgets::{ButtonFeedback, QuickMenu}; use crate::board::action::{Action, ActionEvent, ButtonMapper}; use crate::board::{Epd, SCREEN_H, SCREEN_W}; use crate::drivers::input::Event; use crate::drivers::sdcard::SdStorage; use crate::drivers::strip::StripBuffer; use crate::fonts; use crate::kernel::KernelHandle; use crate::kernel::app::AppLayer; use crate::kernel::bookmarks::BookmarkCache; use crate::kernel::config::{SystemSettings, WifiConfig}; use crate::kernel::input_policy::SemanticInput; use crate::ui::Region; // monomorphized dispatch from AppId to concrete app type macro_rules! with_app { ($id:expr, $mgr:expr, |$app:ident| $body:expr) => { match $id { AppId::Home => { let $app = &mut *$mgr.home; $body } AppId::Files => { let $app = &mut *$mgr.files; $body } AppId::Reader => { let $app = &mut *$mgr.reader; $body } AppId::Settings => { let $app = &mut *$mgr.settings; $body } AppId::Stats => { let $app = &mut *$mgr.stats; $body } AppId::Upload => { unreachable!("Upload mode is handled outside the app dispatch loop"); } } }; } // shared-ref variant for read-only dispatch (draw, quick_actions) macro_rules! with_app_ref { ($id:expr, $mgr:expr, |$app:ident| $body:expr) => { match $id { AppId::Home => { let $app = &*$mgr.home; $body } AppId::Files => { let $app = &*$mgr.files; $body } AppId::Reader => { let $app = &*$mgr.reader; $body } AppId::Settings => { let $app = &*$mgr.settings; $body } AppId::Stats => { let $app = &*$mgr.stats; $body } AppId::Upload => { unreachable!("Upload mode is handled outside the app dispatch loop"); } } }; } #[allow(unused_imports)] pub(crate) use with_app; #[allow(unused_imports)] pub(crate) use with_app_ref; pub struct AppManager { pub launcher: &'static mut Launcher, pub home: &'static mut HomeApp, pub files: &'static mut FilesApp, pub reader: &'static mut ReaderApp, pub settings: &'static mut SettingsApp, pub stats: &'static mut StatsApp, pub quick_menu: &'static mut QuickMenu, pub bumps: &'static mut ButtonFeedback, pub mapper: ButtonMapper, } impl AppManager { #[allow(clippy::too_many_arguments)] pub fn new( launcher: &'static mut Launcher, home: &'static mut HomeApp, files: &'static mut FilesApp, reader: &'static mut ReaderApp, settings: &'static mut SettingsApp, stats: &'static mut StatsApp, quick_menu: &'static mut QuickMenu, bumps: &'static mut ButtonFeedback, mapper: ButtonMapper, ) -> Self { Self { launcher, home, files, reader, settings, stats, quick_menu, bumps, mapper, } } #[inline] pub fn active(&self) -> AppId { self.launcher.active() } #[inline] pub fn ctx(&self) -> &AppContext { &self.launcher.ctx } #[inline] pub fn ctx_mut(&mut self) -> &mut AppContext { &mut self.launcher.ctx } #[inline] pub fn has_redraw(&self) -> bool { self.launcher.ctx.has_redraw() } #[inline] pub fn take_redraw(&mut self) -> Redraw { self.launcher.ctx.take_redraw() } #[inline] pub fn request_full_redraw(&mut self) { self.launcher.ctx.request_full_redraw(); } #[inline] pub fn apply_nav(&mut self, transition: Transition) -> Option { self.launcher.apply(transition) } pub fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>) { self.settings.load_eager(k); self.propagate_fonts(); self.sync_button_config(); } // sync button mapper and label widget from settings fn sync_button_config(&mut self) { let swap = self.settings.system_settings().swap_buttons; self.mapper.set_swap(swap); if self.bumps.set_swap(swap) { // labels changed, need to redraw the button bar self.launcher.ctx.mark_dirty(crate::ui::Region::new( 0, crate::board::SCREEN_H - crate::ui::BUTTON_BAR_H, crate::board::SCREEN_W, crate::ui::BUTTON_BAR_H, )); } } pub fn load_home_recent(&mut self, k: &mut KernelHandle<'_>) { self.home.load_recent(k); } pub fn enter_initial(&mut self, k: &mut KernelHandle<'_>) { self.home.on_enter(&mut self.launcher.ctx, k); } // save active app state to bookmark cache before sleep pub fn save_active_state(&mut self, bm: &mut crate::kernel::bookmarks::BookmarkCache) { let active = self.launcher.active(); with_app!(active, self, |app| app.save_state(bm)); } // collect session state to RTC memory struct before sleep pub fn collect_session(&self, session: &mut crate::kernel::rtc_session::RtcSession) { use crate::kernel::rtc_session::MAX_NAV_STACK; // save navigation stack session.nav_depth = self.launcher.depth() as u8; for i in 0..MAX_NAV_STACK { session.nav_stack[i] = if i < self.launcher.depth() { self.launcher.stack_at(i) as u8 } else { 0 }; } // save reader state session.reader_filename_len = self.reader.filename_len() as u8; let len = session.reader_filename_len as usize; session.reader_filename[..len].copy_from_slice(self.reader.filename_bytes()); session.reader_is_epub = self.reader.is_epub() as u8; session.reader_chapter = self.reader.chapter(); session.reader_page = self.reader.page() as u16; session.reader_byte_offset = self.reader.byte_offset(); session.reader_font_size = self.reader.font_size_idx(); // save files state session.files_scroll = self.files.scroll() as u16; session.files_selected = self.files.selected() as u8; session.files_total = self.files.total() as u16; // save home state session.home_state = self.home.state_id(); session.home_selected = self.home.selected() as u8; session.home_bm_selected = self.home.bm_selected() as u8; session.home_bm_scroll = self.home.bm_scroll() as u8; // save settings cache let ss = self.settings.system_settings(); session.settings_sleep_timeout = ss.sleep_timeout; session.settings_ghost_clear = ss.ghost_clear_every; session.settings_book_font = ss.book_font_size_idx; session.settings_ui_font = ss.ui_font_size_idx; session.settings_valid = 1; log::debug!( "session: collected nav_depth={} active={:?}", session.nav_depth, self.launcher.active() ); } // restore session from RTC memory; returns true if successful // // IMPORTANT: this does NOT call on_enter() on any app. the previous // code called on_enter() after restore_state(), which clobbered the // restored values (home.selected, files.scroll, reader.chapter, etc). // instead, each app's restore_state() is now responsible for setting // up ALL state needed to resume, and the reader enters at NeedBookmark // with its chapter/offset pre-populated from the RTC session. pub fn apply_session( &mut self, session: &crate::kernel::rtc_session::RtcSession, k: &mut KernelHandle<'_>, ) -> bool { // validate session data if session.nav_depth == 0 || session.nav_depth > 4 { log::warn!("session: invalid nav_depth {}", session.nav_depth); return false; } // restore navigation stack self.launcher.restore_stack( session.nav_depth as usize, &session.nav_stack, |id| match id { 0 => AppId::Home, 1 => AppId::Files, 2 => AppId::Reader, 3 => AppId::Settings, 4 => AppId::Stats, _ => AppId::Home, }, ); log::debug!( "session: restored nav stack depth={} active={:?}", session.nav_depth, self.launcher.active() ); // restore home state (always in stack) // restore_state sets state/selected/bm cursors without // resetting them like on_enter() would self.home.restore_state( session.home_state, session.home_selected as usize, session.home_bm_selected as usize, session.home_bm_scroll as usize, ); // battery percentage for status display self.home.set_battery(k.battery_mv()); // restore files state if in stack if self.launcher.contains(AppId::Files) { self.files.restore_state( session.files_scroll as usize, session.files_selected as usize, session.files_total as usize, ); } // restore reader state if active or in stack if self.launcher.active() == AppId::Reader || self.launcher.contains(AppId::Reader) { let filename = &session.reader_filename[..session.reader_filename_len as usize]; self.reader.restore_state( filename, session.reader_is_epub != 0, session.reader_chapter, session.reader_page as usize, session.reader_byte_offset, session.reader_font_size, ); // Wake-to-reader should use the rich cover loading screen on // the very first frame instead of flashing a generic resume // screen before background restore reloads cached assets. if self.launcher.active() == AppId::Reader { self.reader.prepare_restore_loading_screen(k); } } // propagate fonts (uses settings already loaded) self.propagate_fonts(); // set loading indicator for reader if it's the active app, // so the first frame shows "Opening" instead of blank content if self.launcher.active() == AppId::Reader { self.launcher .ctx .set_loading(crate::apps::reader::LOADING_REGION, "Resuming", 0); } // mark full redraw needed — the next render will draw the // active app's content using the restored state self.launcher.ctx.request_full_redraw(); log::debug!( "session: restore complete, active={:?}", self.launcher.active() ); true } /// Open the quick menu for the active app. fn open_quick_menu(&mut self) { let active = self.launcher.active(); let actions: &[_] = with_app!(active, self, |app| app.quick_actions()); self.quick_menu.show(actions); self.launcher.ctx.mark_dirty(self.quick_menu.region()); } /// Close the quick menu, propagating any changed cycle values /// and pending settings to the active app. /// /// Idempotent: safe to call even if `hide()` was already called /// (e.g. from `QuickMenu::on_action`). fn close_quick_menu(&mut self) { let region = self.quick_menu.region(); self.quick_menu.hide(); self.sync_quick_menu(); self.launcher.ctx.mark_dirty(region); } /// Handle a semantic input from the input policy layer. pub fn dispatch_semantic_input(&mut self, input: SemanticInput) -> Transition { match input { SemanticInput::MenuTap => { if self.quick_menu.open { self.close_quick_menu(); } else { self.open_quick_menu(); } Transition::None } } } // power-button long-press must be intercepted by the scheduler // before calling this method pub fn dispatch_event(&mut self, hw_event: Event, bm_cache: &mut BookmarkCache) -> Transition { let event = self.mapper.map_event(hw_event); if self.quick_menu.open { return self.handle_quick_menu(event, bm_cache); } if matches!(event, ActionEvent::Press(Action::Menu)) { self.open_quick_menu(); return Transition::None; } let active = self.launcher.active(); with_app!(active, self, |app| { app.on_event(event, &mut self.launcher.ctx) }) } fn handle_quick_menu( &mut self, event: ActionEvent, bm_cache: &mut BookmarkCache, ) -> Transition { let action = match event { ActionEvent::Press(a) | ActionEvent::Repeat(a) => a, _ => return Transition::None, }; let result = self.quick_menu.on_action(action); match result { QuickMenuResult::Consumed => { if self.quick_menu.dirty { self.launcher.ctx.mark_dirty(self.quick_menu.region()); self.quick_menu.dirty = false; } Transition::None } QuickMenuResult::Close => { // hide() already called by on_action; close_quick_menu // is idempotent and handles sync + dirty marking self.close_quick_menu(); Transition::None } QuickMenuResult::RefreshScreen => { self.close_quick_menu(); self.launcher.ctx.request_full_redraw(); Transition::None } QuickMenuResult::GoHome => { self.close_quick_menu(); Transition::Home } QuickMenuResult::AppTrigger(id) => { let active = self.launcher.active(); self.close_quick_menu(); with_app!(active, self, |app| { app.on_quick_trigger(id, &mut self.launcher.ctx); // Save app state after trigger (e.g. font change // may invalidate the reader's current page offset). app.save_state(bm_cache); }); Transition::None } } } /// Flush deferred persistence for all app singletons. /// /// Dispatches to every concrete app so that failed flushes can /// retry even when the owning app is suspended (e.g. reader /// dirty state retries while Home is active). pub fn flush_deferred_persistence( &mut self, k: &mut KernelHandle<'_>, reason: DeferredPersistenceReason, ) -> crate::error::Result<()> { // today only ReaderApp does real work; others inherit the // default no-op. iterate all singletons for retry semantics. let mut first_error = None; for &id in &[ AppId::Home, AppId::Files, AppId::Reader, AppId::Settings, AppId::Stats, ] { let result = with_app!(id, self, |app| app.flush_deferred_persistence(k, reason)); if let Err(e) = result { log::warn!("flush_deferred_persistence({:?}): {}", id, e); first_error.get_or_insert(e); } } if let Some(err) = first_error { Err(err) } else { Ok(()) } } pub fn apply_transition(&mut self, transition: Transition, k: &mut KernelHandle<'_>) { if let Some(nav) = self.launcher.apply(transition) { log::debug!("app: {:?} -> {:?}", nav.from, nav.to); if nav.from != AppId::Upload { with_app!(nav.from, self, |app| app.save_state(k.bookmark_cache_mut())); // force flush deferred persistence for all app singletons // before leaving the current app so inactive retries still // run and reader dirty state gets one last chance before the // singleton is potentially reused for another book. if let Err(e) = self.flush_deferred_persistence(k, DeferredPersistenceReason::Transition) { log::warn!("flush on leave {:?}: {}", nav.from, e); } with_app!(nav.from, self, |app| { if nav.suspend { app.on_suspend(); } else { app.on_exit(); } }); } self.propagate_fonts(); self.launcher.ctx.clear_loading(); if nav.to != AppId::Upload { if nav.resume { with_app!(nav.to, self, |app| { app.on_resume(&mut self.launcher.ctx, k) }); } else { with_app!(nav.to, self, |app| { app.on_enter(&mut self.launcher.ctx, k) }); } } if nav.resume { self.launcher .ctx .mark_dirty(Region::new(0, 0, SCREEN_W, SCREEN_H)); } else { self.launcher.ctx.request_full_redraw(); } } } pub async fn run_background(&mut self, k: &mut KernelHandle<'_>) { let active = self.launcher.active(); with_app!(active, self, |app| { app.background(&mut self.launcher.ctx, k).await }); for &id in &[AppId::Home, AppId::Files, AppId::Reader, AppId::Settings] { if id != active { with_app!(id, self, |app| { if app.has_background_when_suspended() { app.background_suspended(k); } }); } } } pub fn draw(&self, strip: &mut StripBuffer) { let active = self.launcher.active(); with_app_ref!(active, self, |app| app.draw(strip)); // loading indicator: after app content, before overlays. // the reader draws its own centered loading screen while a // book is opening, so suppress the generic indicator there. let suppress_loading = active == AppId::Reader && self.reader.shows_loading_screen(); if self.launcher.ctx.loading_active() && !suppress_loading { let region = self.launcher.ctx.loading_region(); if region.intersects(strip.logical_window()) { crate::ui::LoadingIndicator::new( region, self.launcher.ctx.loading_msg(), self.launcher.ctx.loading_pct(), ) .draw(strip); } } if self.quick_menu.open { self.quick_menu.draw(strip); } let hide = with_app_ref!(active, self, |app| app.hide_button_bar()); if !hide { self.bumps.draw(strip); } } pub fn propagate_fonts(&mut self) { let ss = self.settings.system_settings(); let ui_idx = ss.ui_font_size_idx; let book_idx = ss.book_font_size_idx; let theme_idx = ss.reading_theme; let reader_status = ss.reader_status; let text_alignment = ss.text_alignment; self.home.set_ui_font_size(ui_idx); self.files.set_ui_font_size(ui_idx); self.settings.set_ui_font_size(ui_idx); self.stats.set_ui_font_size(ui_idx); self.reader.set_book_font_size(book_idx); self.reader.set_reading_theme(theme_idx); self.reader.set_show_chrome(reader_status); self.reader.set_text_alignment(text_alignment); let chrome = fonts::chrome_font(); self.reader.set_chrome_font(chrome); self.quick_menu.set_chrome_font(chrome); self.bumps.set_chrome_font(chrome); } fn sync_quick_menu(&mut self) { let active = self.launcher.active(); for id in 0..MAX_APP_ACTIONS as u8 { if let Some(value) = self.quick_menu.app_cycle_value(id) { with_app!(active, self, |app| { app.on_quick_cycle_update(id, value, &mut self.launcher.ctx); }); } } let pending = with_app!(active, self, |app| app.pending_setting()); if let Some(setting) = pending { match setting { PendingSetting::BookFontSize(idx) => { let ss = self.settings.system_settings_mut(); if ss.book_font_size_idx != idx { ss.book_font_size_idx = idx; self.settings.mark_save_needed(); } } } } } #[inline] pub fn system_settings(&self) -> &crate::kernel::config::SystemSettings { self.settings.system_settings() } #[inline] pub fn settings_loaded(&self) -> bool { self.settings.is_loaded() } #[inline] pub fn settings_generation(&self) -> u32 { self.settings.generation() } #[inline] pub fn wifi_config(&self) -> &crate::kernel::config::WifiConfig { self.settings.wifi_config() } pub fn ghost_clear_every(&self) -> u32 { if self.settings.is_loaded() { self.settings.system_settings().ghost_clear_every as u32 } else { crate::kernel::DEFAULT_GHOST_CLEAR_EVERY } } } impl AppLayer for AppManager { type Id = AppId; #[inline] fn active(&self) -> AppId { self.launcher.active() } fn dispatch_event(&mut self, event: Event, bm: &mut BookmarkCache) -> Transition { AppManager::dispatch_event(self, event, bm) } fn dispatch_semantic(&mut self, input: SemanticInput) -> Transition { AppManager::dispatch_semantic_input(self, input) } fn apply_transition(&mut self, t: Transition, k: &mut KernelHandle<'_>) { AppManager::apply_transition(self, t, k); } async fn run_background(&mut self, k: &mut KernelHandle<'_>) { AppManager::run_background(self, k).await; } fn draw(&self, strip: &mut StripBuffer) { AppManager::draw(self, strip); } #[inline] fn has_redraw(&self) -> bool { self.launcher.ctx.has_redraw() } #[inline] fn take_redraw(&mut self) -> Redraw { self.launcher.ctx.take_redraw() } #[inline] fn request_full_redraw(&mut self) { self.launcher.ctx.request_full_redraw(); } #[inline] fn ctx_mut(&mut self) -> &mut AppContext { &mut self.launcher.ctx } fn system_settings(&self) -> &SystemSettings { self.settings.system_settings() } fn settings_loaded(&self) -> bool { self.settings.is_loaded() } fn settings_generation(&self) -> u32 { self.settings.generation() } fn on_swap_buttons_changed(&mut self, swap: bool) { self.mapper.set_swap(swap); if self.bumps.set_swap(swap) { self.launcher.ctx.mark_dirty(crate::ui::Region::new( 0, crate::board::SCREEN_H - crate::ui::BUTTON_BAR_H, crate::board::SCREEN_W, crate::ui::BUTTON_BAR_H, )); } } fn ghost_clear_every(&self) -> u32 { AppManager::ghost_clear_every(self) } fn wants_grayscale(&self) -> bool { self.launcher.active() == AppId::Reader && !self.quick_menu.open && self.reader.wants_grayscale() } fn wifi_config(&self) -> &WifiConfig { self.settings.wifi_config() } fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>) { AppManager::load_eager_settings(self, k); } fn load_initial_state(&mut self, k: &mut KernelHandle<'_>) { AppManager::load_home_recent(self, k); } fn enter_initial(&mut self, k: &mut KernelHandle<'_>) { AppManager::enter_initial(self, k); } fn save_active_state(&mut self, bm: &mut crate::kernel::bookmarks::BookmarkCache) { AppManager::save_active_state(self, bm); } fn flush_deferred_persistence( &mut self, k: &mut KernelHandle<'_>, reason: DeferredPersistenceReason, ) -> crate::error::Result<()> { AppManager::flush_deferred_persistence(self, k, reason) } fn collect_session(&self, session: &mut crate::kernel::rtc_session::RtcSession) { AppManager::collect_session(self, session); } fn apply_session( &mut self, session: &crate::kernel::rtc_session::RtcSession, k: &mut KernelHandle<'_>, ) -> bool { AppManager::apply_session(self, session, k) } fn needs_special_mode(&self) -> bool { self.launcher.active() == AppId::Upload } async fn run_special_mode( &mut self, epd: &mut Epd, strip: &mut StripBuffer, delay: &mut Delay, sd: &SdStorage, ) { // Safety: WIFI is not owned by any other driver. Upload mode // runs in isolation (the scheduler exits the main dispatch loop // first) and tears down the radio stack before returning. The // peripheral is not accessed again until the next upload session. let wifi = unsafe { esp_hal::peripherals::WIFI::steal() }; crate::apps::upload::run_upload_mode( wifi, epd, strip, delay, sd, self.settings.system_settings().ui_font_size_idx, &*self.bumps, self.settings.wifi_config(), ) .await; } fn suppress_deferred_input(&self) -> bool { self.quick_menu.open } }