A custom OS for the xteink x4 ebook reader
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: code review cleanup, bug fixes, and RTC session persistence rtc session persistence: - add rtc_session module for deep sleep state preservation - save/restore nav stack, reader position, files scroll, home state - cache settings in RTC memory to skip SD reads on wake - keep RTC FAST memory powered during sleep (~1-2µA extra) - add collect_session/apply_session to AppLayer trait - add session state accessors to home, files, reader apps bugs: - fix typos in timing.rs (dookmark -> bookmark, dffset -> offset) - add spine bounds check in images.rs scan_chapter_for_image - add chapter validation after spine load in reader - log errors on pulp dir creation failure in main.rs - fix misleading comment in bitmap.rs (fallback is '?' not space) consolidation: - remove redundant extern crate alloc from files.rs, reader/mod.rs, images.rs - extract hint() helper in build.rs - extract is_power_event() helper in scheduler.rs - extract enter_error() helper in reader/mod.rs - add CONTENT_REGION constant in home.rs - trim unused layout constants clarity: - add rotation comment for SCREEN_W/H swap in board/mod.rs - add window bounds comment in strip.rs - rename cryptic vars in paging.rs (lc->line_count, ls->line_start, etc) - add state machine comment in reader/mod.rs simplification: - use if let instead of empty match in storage.rs - use map_or in app.rs render_ready - use .expect() for spawn failures in main.rs style: - convert /// doc comments to // in rtc_session.rs, layout.rs, utf8.rs

hansmrtn 2f7b352f 0f756d92

+1004 -421
+19 -33
build.rs
··· 4 4 generate_bitmap_fonts(); 5 5 } 6 6 7 + fn hint(msg: &str) { 8 + eprintln!(); 9 + eprintln!("hint: {msg}"); 10 + eprintln!(); 11 + } 12 + 7 13 fn linker_be_nice() { 8 14 let args: Vec<String> = std::env::args().collect(); 9 15 // --error-handling-script passes two args: kind and symbol ··· 13 19 14 20 match kind.as_str() { 15 21 "undefined-symbol" => match what.as_str() { 16 - what if what.starts_with("_defmt_") => { 17 - eprintln!(); 18 - eprintln!( 19 - "hint: `defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`" 20 - ); 21 - eprintln!(); 22 - } 23 - "_stack_start" => { 24 - eprintln!(); 25 - eprintln!("hint: is the linker script `linkall.x` missing?"); 26 - eprintln!(); 27 - } 28 - what if what.starts_with("esp_rtos_") => { 29 - eprintln!(); 30 - eprintln!( 31 - "hint: `esp-radio` has no scheduler enabled. make sure you have initialized `esp-rtos` or provided an external scheduler." 32 - ); 33 - eprintln!(); 34 - } 35 - "embedded_test_linker_file_not_added_to_rustflags" => { 36 - eprintln!(); 37 - eprintln!( 38 - "hint: `embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests" 39 - ); 40 - eprintln!(); 41 - } 22 + what if what.starts_with("_defmt_") => hint( 23 + "`defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`" 24 + ), 25 + "_stack_start" => hint("is the linker script `linkall.x` missing?"), 26 + what if what.starts_with("esp_rtos_") => hint( 27 + "`esp-radio` has no scheduler enabled. make sure you have initialized `esp-rtos` or provided an external scheduler." 28 + ), 29 + "embedded_test_linker_file_not_added_to_rustflags" => hint( 30 + "`embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests" 31 + ), 42 32 "free" 43 33 | "malloc" 44 34 | "calloc" ··· 46 36 | "malloc_internal" 47 37 | "realloc_internal" 48 38 | "calloc_internal" 49 - | "free_internal" => { 50 - eprintln!(); 51 - eprintln!( 52 - "hint: did you forget the `esp-alloc` dependency or didn't enable the `compat` feature on it?" 53 - ); 54 - eprintln!(); 55 - } 39 + | "free_internal" => hint( 40 + "did you forget the `esp-alloc` dependency or didn't enable the `compat` feature on it?" 41 + ), 56 42 _ => (), 57 43 }, 58 44 _ => {
+2 -1
kernel/src/board/mod.rs
··· 12 12 pub use crate::drivers::strip::StripBuffer; 13 13 pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 14 14 15 + // logical screen size (portrait mode via 270-degree rotation of 800x480 panel) 15 16 pub const SCREEN_W: u16 = HEIGHT; // 480 16 - pub const SCREEN_H: u16 = WIDTH; // 800 17 + pub const SCREEN_H: u16 = WIDTH; // 800 17 18 18 19 use core::cell::RefCell; 19 20
+3 -1
kernel/src/drivers/input.rs
··· 139 139 if !self.long_press_fired && held >= Duration::from_millis(timing::LONG_PRESS_MS) { 140 140 self.long_press_fired = true; 141 141 self.last_repeat = now; 142 + log::info!("input: LongPress({:?}) after {}ms", btn, held.as_millis()); 142 143 return Some(Event::LongPress(btn)); 143 144 } 144 145 ··· 155 156 } 156 157 157 158 fn read_raw(&mut self) -> Option<Button> { 158 - if crate::board::power_button_is_low() { 159 + let power_low = crate::board::power_button_is_low(); 160 + if power_low { 159 161 return Some(Button::Power); 160 162 } 161 163
+3 -6
kernel/src/drivers/storage.rs
··· 469 469 let mut guard = borrow(sd)?; 470 470 let inner = &mut *guard; 471 471 472 - match inner.mgr.open_dir(inner.root, PULP_DIR).await { 473 - Ok(dir) => { 474 - let _ = inner.mgr.close_dir(dir); 475 - return Ok(()); 476 - } 477 - Err(_) => {} 472 + if let Ok(dir) = inner.mgr.open_dir(inner.root, PULP_DIR).await { 473 + let _ = inner.mgr.close_dir(dir); 474 + return Ok(()); 478 475 } 479 476 match inner.mgr.make_dir_in_dir(inner.root, PULP_DIR).await { 480 477 Ok(()) => Ok(()),
+1
kernel/src/drivers/strip.rs
··· 196 196 gy: i32, 197 197 black: bool, 198 198 ) { 199 + // window bounds (wx, wy = origin; wx2, wy2 = extent; rb = row bytes) 199 200 let wx = self.win_x as i32; 200 201 let wy = self.win_y as i32; 201 202 let wx2 = wx + self.win_w as i32;
+38 -5
kernel/src/kernel/app.rs
··· 209 209 Redraw::None => false, 210 210 Redraw::Full => true, 211 211 Redraw::Partial(_) => { 212 - self.immediate 213 - || self 214 - .coalesce_until 215 - .map(|t| Instant::now() >= t) 216 - .unwrap_or(true) 212 + self.immediate || self.coalesce_until.map_or(true, |t| Instant::now() >= t) 217 213 } 218 214 } 219 215 } ··· 350 346 } 351 347 } 352 348 349 + #[inline] 353 350 pub fn active(&self) -> Id { 354 351 self.stack[self.depth - 1] 352 + } 353 + 354 + #[inline] 355 + pub fn depth(&self) -> usize { 356 + self.depth 357 + } 358 + 359 + #[inline] 360 + pub fn stack_at(&self, index: usize) -> Id { 361 + self.stack[index] 362 + } 363 + 364 + /// Check if an app ID is anywhere in the stack 365 + pub fn contains(&self, id: Id) -> bool { 366 + self.stack[..self.depth].iter().any(|&i| i == id) 367 + } 368 + 369 + /// Restore stack from saved session data 370 + /// 371 + /// The `convert` function maps u8 values to Id. 372 + pub fn restore_stack<F>(&mut self, depth: usize, stack: &[u8], convert: F) 373 + where 374 + F: Fn(u8) -> Id, 375 + { 376 + self.depth = depth.min(MAX_STACK_DEPTH).max(1); 377 + for i in 0..self.depth { 378 + self.stack[i] = convert(stack[i]); 379 + } 355 380 } 356 381 357 382 pub fn apply(&mut self, transition: Transition<Id>) -> Option<NavEvent<Id>> { ··· 447 472 fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>); 448 473 fn load_initial_state(&mut self, k: &mut KernelHandle<'_>); 449 474 fn enter_initial(&mut self, k: &mut KernelHandle<'_>); 475 + 476 + // session persistence: save/restore active app across sleep/wake 477 + // using RTC FAST memory (survives deep sleep, zeroed on power-on) 478 + // 479 + // collect_session writes app state to the provided RtcSession struct 480 + // apply_session restores app state from RtcSession, returns true if successful 481 + fn collect_session(&self, session: &mut super::rtc_session::RtcSession); 482 + fn apply_session(&mut self, session: &super::rtc_session::RtcSession, k: &mut KernelHandle<'_>) -> bool; 450 483 451 484 // true when the active app wants to take over the main loop 452 485 // (e.g. wifi upload mode bypasses the normal event dispatch)
+1
kernel/src/kernel/mod.rs
··· 12 12 pub mod console; 13 13 pub mod dir_cache; 14 14 pub mod handle; 15 + pub mod rtc_session; 15 16 pub mod scheduler; 16 17 pub mod tasks; 17 18 pub mod timing;
+220
kernel/src/kernel/rtc_session.rs
··· 1 + // RTC FAST memory session persistence 2 + // 3 + // stores session state in RTC FAST memory (8KB on ESP32-C3) which 4 + // survives deep sleep but is zeroed on power-on reset. this enables 5 + // instant wake restoration without SD card I/O. 6 + // 7 + // the session struct is placed in .rtc_fast.persistent via link_section; 8 + // esp-hal's linker scripts ensure this memory is only zeroed on power-on, 9 + // not on deep sleep wake. 10 + 11 + use core::sync::atomic::{AtomicU32, Ordering}; 12 + 13 + // magic value to validate RTC session data: "PLPS" (PuLP Session) 14 + const RTC_SESSION_MAGIC: u32 = 0x504C5053; 15 + 16 + // max navigation stack depth (must match app::MAX_STACK_DEPTH) 17 + pub const MAX_NAV_STACK: usize = 4; 18 + 19 + // max filename length for reader state 20 + pub const MAX_FILENAME_LEN: usize = 32; 21 + 22 + // RTC-persistent session state 23 + // stored in RTC FAST memory; survives deep sleep 24 + // all fields use fixed-size types for stable memory layout 25 + #[derive(Clone, Copy)] 26 + #[repr(C, align(4))] 27 + pub struct RtcSession { 28 + // header (16 bytes) 29 + magic: u32, // must equal RTC_SESSION_MAGIC for valid data 30 + wake_count: u32, // incremented each successful wake 31 + flags: u32, // reserved 32 + _header_pad: u32, 33 + 34 + // navigation stack (8 bytes) 35 + pub nav_depth: u8, // stack depth (1-4) 36 + pub nav_stack: [u8; MAX_NAV_STACK], // app ids: Home=0, Files=1, Reader=2, Settings=3, Upload=4 37 + _nav_pad: [u8; 3], 38 + 39 + // reader state (48 bytes) 40 + pub reader_filename: [u8; MAX_FILENAME_LEN], 41 + pub reader_filename_len: u8, 42 + pub reader_is_epub: u8, 43 + pub reader_chapter: u16, 44 + pub reader_page: u16, 45 + pub reader_byte_offset: u32, 46 + pub reader_font_size: u8, 47 + _reader_pad: [u8; 5], 48 + 49 + // files state (8 bytes) 50 + pub files_scroll: u16, 51 + pub files_selected: u8, 52 + pub files_total: u16, 53 + _files_pad: [u8; 3], 54 + 55 + // home state (8 bytes) 56 + pub home_state: u8, // 0=Menu, 1=ShowBookmarks 57 + pub home_selected: u8, 58 + pub home_bm_selected: u8, 59 + pub home_bm_scroll: u8, 60 + _home_pad: [u8; 4], 61 + 62 + // settings cache (16 bytes) - avoid SD reads on wake 63 + pub settings_sleep_timeout: u16, 64 + pub settings_ghost_clear: u8, 65 + pub settings_book_font: u8, 66 + pub settings_ui_font: u8, 67 + pub settings_valid: u8, 68 + _settings_pad: [u8; 10], 69 + 70 + // reserved (24 bytes) 71 + _reserved: [u8; 24], 72 + } 73 + 74 + // Compile-time size check: ensure struct fits comfortably in RTC FAST (8KB) 75 + // and doesn't grow unexpectedly 76 + const _: () = assert!(core::mem::size_of::<RtcSession>() <= 256); 77 + 78 + impl RtcSession { 79 + // create zeroed session (invalid until populated) 80 + pub const fn zeroed() -> Self { 81 + Self { 82 + magic: 0, 83 + wake_count: 0, 84 + flags: 0, 85 + _header_pad: 0, 86 + 87 + nav_depth: 0, 88 + nav_stack: [0; MAX_NAV_STACK], 89 + _nav_pad: [0; 3], 90 + 91 + reader_filename: [0; MAX_FILENAME_LEN], 92 + reader_filename_len: 0, 93 + reader_is_epub: 0, 94 + reader_chapter: 0, 95 + reader_page: 0, 96 + reader_byte_offset: 0, 97 + reader_font_size: 0, 98 + _reader_pad: [0; 5], 99 + 100 + files_scroll: 0, 101 + files_selected: 0, 102 + files_total: 0, 103 + _files_pad: [0; 3], 104 + 105 + home_state: 0, 106 + home_selected: 0, 107 + home_bm_selected: 0, 108 + home_bm_scroll: 0, 109 + _home_pad: [0; 4], 110 + 111 + settings_sleep_timeout: 0, 112 + settings_ghost_clear: 0, 113 + settings_book_font: 0, 114 + settings_ui_font: 0, 115 + settings_valid: 0, 116 + _settings_pad: [0; 10], 117 + 118 + _reserved: [0; 24], 119 + } 120 + } 121 + 122 + #[inline] 123 + pub fn is_valid(&self) -> bool { 124 + self.magic == RTC_SESSION_MAGIC 125 + } 126 + 127 + #[inline] 128 + pub fn mark_valid(&mut self) { 129 + self.magic = RTC_SESSION_MAGIC; 130 + } 131 + 132 + pub fn clear(&mut self) { 133 + self.magic = 0; 134 + } 135 + 136 + pub fn increment_wake_count(&mut self) { 137 + self.wake_count = self.wake_count.wrapping_add(1); 138 + } 139 + 140 + #[inline] 141 + pub fn wake_count(&self) -> u32 { 142 + self.wake_count 143 + } 144 + } 145 + 146 + // RTC FAST persistent storage 147 + // 148 + // This static is placed in .rtc_fast.persistent section which: 149 + // - Survives deep sleep (RTC domain stays powered) 150 + // - Is zeroed only on power-on reset (not deep sleep wake) 151 + // - Requires RTC FAST memory to remain powered during sleep 152 + // 153 + // Safety: Access is through save()/load() which use volatile operations 154 + // and are only called from single-threaded boot/sleep contexts. 155 + #[unsafe(link_section = ".rtc_fast.persistent")] 156 + static mut RTC_SESSION: RtcSession = RtcSession::zeroed(); 157 + 158 + // Atomic flag to track if we've detected a valid session this boot 159 + // (prevents re-reading stale data after initial restore) 160 + static SESSION_CONSUMED: AtomicU32 = AtomicU32::new(0); 161 + 162 + // check if RTC session data is valid and available for restore 163 + // returns true only once per boot (subsequent calls return false) 164 + // must be called from main thread during boot, before async tasks 165 + pub fn is_valid_session() -> bool { 166 + // Only allow one restore per boot 167 + if SESSION_CONSUMED.load(Ordering::Relaxed) != 0 { 168 + return false; 169 + } 170 + 171 + // Safety: single-threaded boot context, volatile read via raw pointer 172 + let valid = unsafe { 173 + let ptr = core::ptr::addr_of!(RTC_SESSION); 174 + core::ptr::read_volatile(core::ptr::addr_of!((*ptr).magic)) 175 + } == RTC_SESSION_MAGIC; 176 + 177 + if valid { 178 + SESSION_CONSUMED.store(1, Ordering::Relaxed); 179 + } 180 + 181 + valid 182 + } 183 + 184 + // load session data (caller should check is_valid_session() first) 185 + // must be called from main thread during boot 186 + pub fn load() -> RtcSession { 187 + // Safety: single-threaded context, volatile read via raw pointer 188 + unsafe { 189 + let ptr = core::ptr::addr_of!(RTC_SESSION); 190 + core::ptr::read_volatile(ptr) 191 + } 192 + } 193 + 194 + // save session data before entering deep sleep 195 + // must be called from main thread before sleep, after tasks stopped 196 + pub fn save(session: &RtcSession) { 197 + unsafe { 198 + let ptr = core::ptr::addr_of_mut!(RTC_SESSION); 199 + // Copy all fields 200 + core::ptr::write_volatile(ptr, *session); 201 + // Ensure magic is set (caller may have forgotten) 202 + core::ptr::write_volatile(core::ptr::addr_of_mut!((*ptr).magic), RTC_SESSION_MAGIC); 203 + } 204 + } 205 + 206 + // clear RTC session data 207 + pub fn clear() { 208 + unsafe { 209 + let ptr = core::ptr::addr_of_mut!(RTC_SESSION); 210 + core::ptr::write_volatile(core::ptr::addr_of_mut!((*ptr).magic), 0); 211 + } 212 + } 213 + 214 + // get wake count for debugging (doesn't consume session) 215 + pub fn wake_count() -> u32 { 216 + unsafe { 217 + let ptr = core::ptr::addr_of!(RTC_SESSION); 218 + core::ptr::read_volatile(core::ptr::addr_of!((*ptr).wake_count)) 219 + } 220 + }
+122 -19
kernel/src/kernel/scheduler.rs
··· 29 29 30 30 use super::timing; 31 31 32 + #[inline] 33 + fn is_power_event(ev: Event) -> bool { 34 + matches!(ev, Event::Press(Button::Power) | Event::Release(Button::Power)) 35 + } 36 + 32 37 impl super::Kernel { 33 38 // render boot console to EPD; call before boot() to show 34 39 // hardware init progress in the built-in mono font ··· 40 45 } 41 46 42 47 // one-time boot: load caches, settings, render the home screen 48 + // if waking from deep sleep with valid RTC session, restore it 43 49 pub async fn boot<A: AppLayer>(&mut self, app_mgr: &mut A) { 50 + use super::rtc_session; 51 + 44 52 self.bm_cache.ensure_loaded(&self.sd); 45 53 54 + // check for valid RTC session before loading settings 55 + // (session may contain cached settings to skip SD reads) 56 + let has_rtc_session = rtc_session::is_valid_session(); 57 + let rtc_session_data = if has_rtc_session { 58 + let session = rtc_session::load(); 59 + info!( 60 + "boot: RTC session valid (wake count {})", 61 + session.wake_count() 62 + ); 63 + Some(session) 64 + } else { 65 + info!("boot: no RTC session (power-on or first boot)"); 66 + None 67 + }; 68 + 69 + // load settings - may use cached values from RTC session 46 70 { 47 71 let mut handle = self.handle(); 48 72 app_mgr.load_eager_settings(&mut handle); ··· 51 75 52 76 tasks::set_idle_timeout(app_mgr.system_settings().sleep_timeout); 53 77 self.log_stats(); 54 - app_mgr.enter_initial(&mut self.handle()); 78 + 79 + // try to restore session from RTC memory 80 + let restored = if let Some(session) = rtc_session_data { 81 + app_mgr.apply_session(&session, &mut self.handle()) 82 + } else { 83 + false 84 + }; 85 + 86 + if !restored { 87 + app_mgr.enter_initial(&mut self.handle()); 88 + } 55 89 56 90 { 57 91 let draw = |s: &mut StripBuffer| app_mgr.draw(s); ··· 86 120 }; 87 121 88 122 if let Some(ev) = hw_event { 123 + if matches!(ev, Event::LongPress(_)) { 124 + info!("scheduler: received {:?}", ev); 125 + } 89 126 if self.handle_input(ev, app_mgr) { 90 - self.enter_sleep("power held").await; 127 + self.sleep_with_session(app_mgr, "power held").await; 91 128 continue; 92 129 } 93 130 } ··· 129 166 130 167 if let Some(ev) = bg_input { 131 168 if self.handle_input(ev, app_mgr) { 132 - self.enter_sleep("power held").await; 169 + self.sleep_with_session(app_mgr, "power held").await; 133 170 continue; 134 171 } 135 172 ··· 139 176 } 140 177 141 178 if self.poll_housekeeping(app_mgr) { 142 - self.enter_sleep("idle timeout").await; 179 + self.sleep_with_session(app_mgr, "idle timeout").await; 143 180 continue; 144 181 } 145 182 146 183 if app_mgr.ctx_mut().render_ready() { 147 184 let redraw = app_mgr.take_redraw(); 148 - self.render(app_mgr, redraw).await; 185 + if self.render(app_mgr, redraw).await { 186 + self.sleep_with_session(app_mgr, "power held").await; 187 + continue; 188 + } 149 189 } 150 190 } 151 191 } ··· 171 211 let _ = tasks::IDLE_SLEEP_DUE.try_take(); 172 212 173 213 if hw_event == Event::LongPress(Button::Power) { 214 + info!("handle_input: LongPress(Power) detected, triggering sleep"); 174 215 return true; 175 216 } 176 217 177 218 let suppressed_before = app_mgr.suppress_deferred_input(); 178 219 let transition = app_mgr.dispatch_event(hw_event, &mut *self.bm_cache); 220 + let power = is_power_event(hw_event); 179 221 180 222 if transition != Transition::None { 181 223 app_mgr.apply_transition(transition, &mut self.handle()); 182 - tasks::request_hold_reset(); 224 + // don't consume hold for power button - we still want LongPress for sleep 225 + if !power { 226 + tasks::request_hold_reset(); 227 + } 183 228 } else if app_mgr.suppress_deferred_input() != suppressed_before { 184 - tasks::request_hold_reset(); 229 + // quick menu opened/closed; don't consume hold for power button 230 + if !power { 231 + tasks::request_hold_reset(); 232 + } 185 233 } 186 234 187 235 false ··· 222 270 223 271 // partial refreshes use DU waveform (~400 ms); after ghost_clear_every 224 272 // partials, a full GC refresh (~1.6 s) clears ghosting 225 - async fn render<A: AppLayer>(&mut self, app_mgr: &mut A, redraw: Redraw) { 273 + // 274 + // returns true if power-long-press arrived during the waveform and 275 + // the caller should enter sleep 276 + async fn render<A: AppLayer>(&mut self, app_mgr: &mut A, redraw: Redraw) -> bool { 277 + let mut sleep_requested = false; 278 + 226 279 'render: { 227 280 if let Redraw::Partial(r) = redraw { 228 281 let ghost_clear_every = app_mgr.ghost_clear_every(); ··· 257 310 258 311 if let Some(rs) = rs { 259 312 self.epd.partial_start_du(&rs); 260 - let deferred = self.busy_wait_with_background(app_mgr).await; 313 + let (deferred, sleep) = self.busy_wait_with_background(app_mgr).await; 314 + sleep_requested = sleep; 261 315 262 316 if app_mgr.has_redraw() { 263 317 // content changed mid-DU; leave RED stale ··· 303 357 304 358 self.epd.start_full_update(); 305 359 306 - let deferred = self.busy_wait_with_background(app_mgr).await; 360 + let (deferred, sleep) = self.busy_wait_with_background(app_mgr).await; 361 + sleep_requested = sleep; 307 362 308 363 self.epd.finish_full_update(); 309 364 self.partial_refreshes = 0; ··· 314 369 } 315 370 } 316 371 } // 'render 372 + 373 + sleep_requested 317 374 } 318 375 319 376 // collect input and run background work while EPD is busy refreshing ··· 332 389 // 333 390 // first non-None transition wins; hold reset prevents the held 334 391 // button from re-firing LongPress/Repeat for the waveform 392 + // 393 + // returns (deferred_transition, sleep_requested) so the caller 394 + // can enter sleep after the EPD finishes if power-long-press 395 + // arrived during the waveform 335 396 async fn busy_wait_with_background<A: AppLayer>( 336 397 &mut self, 337 398 app_mgr: &mut A, 338 - ) -> Option<Transition<A::Id>> { 399 + ) -> (Option<Transition<A::Id>>, bool) { 339 400 let mut deferred: Option<Transition<A::Id>> = None; 401 + let mut sleep_requested = false; 340 402 341 403 loop { 342 404 if !self.epd.is_busy() { ··· 364 426 if let Some(hw_event) = ev { 365 427 let _ = tasks::IDLE_SLEEP_DUE.try_take(); 366 428 429 + // power long-press triggers sleep after EPD finishes 430 + if hw_event == Event::LongPress(Button::Power) { 431 + info!("busy_wait: LongPress(Power) during waveform, will sleep after"); 432 + sleep_requested = true; 433 + continue; 434 + } 435 + 367 436 let suppressed_before = app_mgr.suppress_deferred_input(); 368 437 if !suppressed_before { 369 438 let t = app_mgr.dispatch_event(hw_event, &mut *self.bm_cache); 439 + let power = is_power_event(hw_event); 440 + 370 441 if t != Transition::None && deferred.is_none() { 371 442 deferred = Some(t); 372 - tasks::request_hold_reset(); 443 + if !power { 444 + tasks::request_hold_reset(); 445 + } 373 446 } else if app_mgr.suppress_deferred_input() != suppressed_before { 374 - tasks::request_hold_reset(); 447 + if !power { 448 + tasks::request_hold_reset(); 449 + } 375 450 } 376 451 } 377 452 } ··· 379 454 self.poll_housekeeping_waveform(app_mgr); 380 455 } 381 456 382 - deferred 457 + (deferred, sleep_requested) 458 + } 459 + 460 + // save session to RTC memory and enter deep sleep; call this 461 + // instead of enter_sleep directly to ensure session state is persisted 462 + async fn sleep_with_session<A: AppLayer>(&mut self, app_mgr: &mut A, reason: &str) { 463 + use super::rtc_session; 464 + 465 + // collect session state from app layer 466 + let mut session = rtc_session::RtcSession::zeroed(); 467 + app_mgr.collect_session(&mut session); 468 + 469 + // increment wake count for debugging 470 + session.increment_wake_count(); 471 + 472 + // save to RTC memory (will be valid on next wake) 473 + rtc_session::save(&session); 474 + info!("session: saved to RTC memory"); 475 + 476 + self.enter_sleep(reason).await; 383 477 } 384 478 385 479 // flush bookmarks, render sleep screen, enter MCU deep sleep; 386 480 // on real hardware this never returns (wake = full MCU reset) 387 - pub async fn enter_sleep(&mut self, reason: &str) { 481 + // 482 + // uses a custom sleep config that keeps RTC FAST memory powered 483 + // so session state survives the sleep cycle (~1-2µA extra) 484 + async fn enter_sleep(&mut self, reason: &str) { 388 485 use embedded_graphics::mono_font::MonoTextStyle; 389 486 use embedded_graphics::mono_font::ascii::FONT_9X18; 390 487 use embedded_graphics::pixelcolor::BinaryColor; ··· 392 489 use embedded_graphics::text::Text; 393 490 use esp_hal::gpio::RtcPinWithResistors; 394 491 use esp_hal::rtc_cntl::Rtc; 395 - use esp_hal::rtc_cntl::sleep::{RtcioWakeupSource, WakeupLevel}; 492 + use esp_hal::rtc_cntl::sleep::{RtcSleepConfig, RtcioWakeupSource, WakeupLevel}; 396 493 397 494 info!("{}: entering sleep...", reason); 398 495 ··· 423 520 &mut [(&mut gpio3, WakeupLevel::Low)]; 424 521 let rtcio = RtcioWakeupSource::new(wakeup_pins); 425 522 426 - info!("mcu: entering deep sleep (power button to wake)"); 427 - rtc.sleep_deep(&[&rtcio]); 523 + // custom sleep config: keep RTC FAST memory powered for session 524 + // persistence. this adds ~1-2µA to deep sleep current but enables 525 + // instant wake restoration without SD card I/O. 526 + let mut sleep_config = RtcSleepConfig::deep(); 527 + sleep_config.set_rtc_fastmem_pd_en(false); // keep RTC FAST powered 428 528 429 - // deep sleep resets the MCU; backstop if sleep_deep returns 529 + info!("mcu: entering deep sleep (power button to wake, RTC FAST retained)"); 530 + rtc.sleep(&sleep_config, &[&rtcio]); 531 + 532 + // deep sleep resets the MCU; backstop if sleep returns 430 533 #[allow(unreachable_code)] 431 534 loop { 432 535 core::hint::spin_loop();
+4 -4
kernel/src/kernel/timing.rs
··· 1 1 // timing constants for the kernel scheduler and tasks 2 2 // 3 - // timing values that control polling intervals, debouncing, 3 + // timing values that control polling intervals, debouncing, 4 4 // coalescing, and housekeeping. some of these may 5 - // become runtime-configurable settings in the future, idk 5 + // may become runtime-configurable in the future 6 6 7 7 // main scheduler tick interval (ms) 8 8 // controls how often the event loop wakes to check for work ··· 36 36 // bookmark flush interval (seconds) 37 37 pub const BOOKMARK_FLUSH_INTERVAL_SECS: u64 = 30; 38 38 39 - // dookmark flush stagger delay (seconds) 40 - // dffset from SD check to avoid simultaneous SD operations 39 + // bookmark flush stagger delay (seconds) 40 + // offset from sd check to avoid simultaneous sd operations 41 41 pub const BOOKMARK_FLUSH_STAGGER_SECS: u64 = 2; 42 42 43 43 // initial housekeeping delay (seconds)
+1
kernel/src/lib.rs
··· 13 13 pub mod error; 14 14 pub mod kernel; 15 15 pub mod ui; 16 + pub mod util; 16 17 17 18 // re-export core error types at crate root 18 19 pub use error::{Error, ErrorKind, Result, ResultExt};
+5 -83
kernel/src/ui/layout.rs
··· 1 - // common layout constants for UI rendering 1 + // shared layout constants for UI rendering 2 2 // 3 - // centralizes magic layout values used across apps and widgets. 4 - // some of these may become runtime-configurable settings in the 5 - // future (e.g., margin sizes based on font size preferences). 3 + // only constants used by 2+ apps belong here. 4 + // single-use layout values should be defined locally. 6 5 7 6 use super::statusbar::BAR_HEIGHT; 8 - use crate::board::{SCREEN_H, SCREEN_W}; 9 - 10 - // ── Content area ──────────────────────────────────────────────── 7 + use crate::board::SCREEN_W; 11 8 12 - /// Top of the content area (below status bar). 13 9 pub const CONTENT_TOP: u16 = BAR_HEIGHT; 14 - 15 - /// Height of the content area (screen minus status bar). 16 - pub const CONTENT_H: u16 = SCREEN_H - BAR_HEIGHT; 17 - 18 - // ── Standard spacing ──────────────────────────────────────────── 19 - 20 - /// Standard margin for content edges (left/right). 21 - pub const STANDARD_MARGIN: u16 = 8; 22 - 23 - /// Large margin for content edges (used in some UIs). 24 10 pub const LARGE_MARGIN: u16 = 16; 25 - 26 - /// Standard gap between items. 27 - pub const STANDARD_GAP: u16 = 4; 28 - 29 - /// Larger gap between sections or after headers. 30 11 pub const SECTION_GAP: u16 = 8; 31 - 32 - // ── Title/header layout ───────────────────────────────────────── 33 - 34 - /// Y offset for titles below CONTENT_TOP. 35 12 pub const TITLE_Y_OFFSET: u16 = 4; 36 - 37 - /// Standard title Y position. 38 13 pub const TITLE_Y: u16 = CONTENT_TOP + TITLE_Y_OFFSET; 39 - 40 - /// Full-width for content spanning most of the screen. 41 - /// Used for headers and wide content areas. 42 - pub const FULL_CONTENT_W: u16 = SCREEN_W - 2 * LARGE_MARGIN; // 448 43 - 44 - /// Width for header/title regions (leaves room for status on right). 14 + pub const FULL_CONTENT_W: u16 = SCREEN_W - 2 * LARGE_MARGIN; 45 15 pub const HEADER_W: u16 = 300; 46 - 47 - /// Width for status regions (battery, page number, etc.). 48 - pub const STATUS_W: u16 = 144; 49 - 50 - /// X position for right-aligned status in header. 51 - pub const STATUS_X: u16 = SCREEN_W - LARGE_MARGIN - STATUS_W; // 320 52 - 53 - // ── List/menu layout ──────────────────────────────────────────── 54 - 55 - /// Standard row height for list items. 56 - pub const LIST_ROW_H: u16 = 52; 57 - 58 - /// Gap between list rows. 59 - pub const LIST_ROW_GAP: u16 = 4; 60 - 61 - /// Combined row stride (row + gap). 62 - pub const LIST_ROW_STRIDE: u16 = LIST_ROW_H + LIST_ROW_GAP; 63 - 64 - /// Menu/settings row height (slightly smaller than list). 65 - pub const MENU_ROW_H: u16 = 40; 66 - 67 - /// Gap between menu rows. 68 - pub const MENU_ROW_GAP: u16 = 6; 69 - 70 - /// Combined menu row stride. 71 - pub const MENU_ROW_STRIDE: u16 = MENU_ROW_H + MENU_ROW_GAP; 72 - 73 - // ── Progress and overlays ─────────────────────────────────────── 74 - 75 - /// Height of progress bars. 76 - pub const PROGRESS_H: u16 = 2; 77 - 78 - /// Position overlay dimensions (for page/chapter display). 79 - pub const POSITION_OVERLAY_W: u16 = 280; 80 - pub const POSITION_OVERLAY_H: u16 = 40; 81 - 82 - // ── Loading indicator ─────────────────────────────────────────── 83 - 84 - /// Default height for loading indicator region. 85 - pub const LOADING_H: u16 = 24; 86 - 87 - // ── Footer layout ─────────────────────────────────────────────── 88 - 89 - /// Standard footer height from bottom. 90 - pub const FOOTER_MARGIN: u16 = 60; 91 - 92 - /// Footer Y position. 93 - pub const FOOTER_Y: u16 = SCREEN_H - FOOTER_MARGIN;
+1 -4
kernel/src/ui/mod.rs
··· 10 10 mod widget; 11 11 12 12 pub use layout::{ 13 - CONTENT_H, CONTENT_TOP, FOOTER_MARGIN, FOOTER_Y, FULL_CONTENT_W, HEADER_W, LARGE_MARGIN, 14 - LIST_ROW_GAP, LIST_ROW_H, LIST_ROW_STRIDE, LOADING_H, MENU_ROW_GAP, MENU_ROW_H, 15 - MENU_ROW_STRIDE, POSITION_OVERLAY_H, POSITION_OVERLAY_W, PROGRESS_H, SECTION_GAP, STANDARD_GAP, 16 - STANDARD_MARGIN, STATUS_W, STATUS_X, TITLE_Y, TITLE_Y_OFFSET, 13 + CONTENT_TOP, FULL_CONTENT_W, HEADER_W, LARGE_MARGIN, SECTION_GAP, TITLE_Y, TITLE_Y_OFFSET, 17 14 }; 18 15 pub use stack_fmt::{stack_fmt, StackFmt}; 19 16 pub use statusbar::{free_stack_bytes, paint_stack, stack_high_water_mark, BAR_HEIGHT};
+5
kernel/src/util/mod.rs
··· 1 + // utility modules: small, reusable components without hardware dependencies 2 + 3 + mod utf8; 4 + 5 + pub use utf8::{Utf8Iter, decode_utf8_char};
+89
kernel/src/util/utf8.rs
··· 1 + // UTF-8 decoding utilities for no_std environments 2 + // 3 + // provides both iterator-based and single-char decoding interfaces 4 + // for processing UTF-8 byte slices without std::str 5 + 6 + // decode one UTF-8 character at buf[pos] 7 + // returns (char, byte_length); malformed sequences yield '\u{FFFD}' 8 + // panics if pos >= buf.len() 9 + #[inline] 10 + pub fn decode_utf8_char(buf: &[u8], pos: usize) -> (char, usize) { 11 + let b0 = buf[pos]; 12 + 13 + // ASCII fast path 14 + if b0 < 0x80 { 15 + return (b0 as char, 1); 16 + } 17 + 18 + // Determine expected sequence length from lead byte 19 + let (mut cp, expected) = if b0 < 0xC0 { 20 + // Stray continuation byte 21 + return ('\u{FFFD}', 1); 22 + } else if b0 < 0xE0 { 23 + ((b0 as u32) & 0x1F, 2) 24 + } else if b0 < 0xF0 { 25 + ((b0 as u32) & 0x0F, 3) 26 + } else if b0 < 0xF8 { 27 + ((b0 as u32) & 0x07, 4) 28 + } else { 29 + // Invalid lead byte 30 + return ('\u{FFFD}', 1); 31 + }; 32 + 33 + // Check if we have enough bytes 34 + let len = buf.len(); 35 + if pos + expected > len { 36 + return ('\u{FFFD}', len - pos); 37 + } 38 + 39 + // Decode continuation bytes 40 + for i in 1..expected { 41 + let cont = buf[pos + i]; 42 + if cont & 0xC0 != 0x80 { 43 + // Invalid continuation byte 44 + return ('\u{FFFD}', i); 45 + } 46 + cp = (cp << 6) | (cont as u32 & 0x3F); 47 + } 48 + 49 + let ch = char::from_u32(cp).unwrap_or('\u{FFFD}'); 50 + (ch, expected) 51 + } 52 + 53 + // iterator over UTF-8 characters in a byte slice 54 + // invalid sequences yield U+FFFD 55 + pub struct Utf8Iter<'a> { 56 + data: &'a [u8], 57 + pos: usize, 58 + } 59 + 60 + impl<'a> Utf8Iter<'a> { 61 + #[inline] 62 + pub fn new(data: &'a [u8]) -> Self { 63 + Self { data, pos: 0 } 64 + } 65 + 66 + #[inline] 67 + pub fn position(&self) -> usize { 68 + self.pos 69 + } 70 + 71 + #[inline] 72 + pub fn remaining(&self) -> &'a [u8] { 73 + &self.data[self.pos..] 74 + } 75 + } 76 + 77 + impl Iterator for Utf8Iter<'_> { 78 + type Item = char; 79 + 80 + fn next(&mut self) -> Option<char> { 81 + if self.pos >= self.data.len() { 82 + return None; 83 + } 84 + 85 + let (ch, len) = decode_utf8_char(self.data, self.pos); 86 + self.pos += len; 87 + Some(ch) 88 + } 89 + }
+36 -7
src/apps/files.rs
··· 1 1 // paginated file browser for SD card root directory 2 2 // background title scanner resolves EPUB titles from OPF metadata 3 3 4 - extern crate alloc; 5 - 6 4 use alloc::vec::Vec; 7 5 use core::fmt::Write as _; 8 6 ··· 19 17 use crate::fonts; 20 18 use crate::kernel::KernelHandle; 21 19 use crate::ui::{ 22 - Alignment, BitmapDynLabel, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, HEADER_W, 23 - LARGE_MARGIN, LIST_ROW_GAP, LIST_ROW_H, Region, SECTION_GAP, STATUS_W, STATUS_X, 24 - TITLE_Y_OFFSET, 20 + Alignment, BitmapDynLabel, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, HEADER_W, LARGE_MARGIN, 21 + Region, SECTION_GAP, TITLE_Y_OFFSET, 25 22 }; 26 23 use smol_epub::epub::{self, EpubMeta, EpubSpine}; 27 24 use smol_epub::zip::ZipIndex; ··· 33 30 34 31 const TITLE_Y: u16 = CONTENT_TOP + TITLE_Y_OFFSET; 35 32 33 + const STATUS_W: u16 = 144; 34 + const STATUS_X: u16 = SCREEN_W - LARGE_MARGIN - STATUS_W; 36 35 const FILES_STATUS_Y: u16 = TITLE_Y; 37 36 const FILES_STATUS_H: u16 = 28; 38 37 const STATUS_REGION: Region = Region::new(STATUS_X, FILES_STATUS_Y, STATUS_W, FILES_STATUS_H); 39 38 40 - const ROW_H: u16 = LIST_ROW_H; 41 - const ROW_GAP: u16 = LIST_ROW_GAP; 39 + const ROW_H: u16 = 52; 40 + const ROW_GAP: u16 = 4; 42 41 43 42 const HEADER_LIST_GAP: u16 = SECTION_GAP; 44 43 ··· 88 87 pub fn set_ui_font_size(&mut self, idx: u8) { 89 88 self.ui_fonts = fonts::UiFonts::for_size(idx); 90 89 self.list_y = TITLE_Y + self.ui_fonts.heading.line_height + HEADER_LIST_GAP; 90 + } 91 + 92 + // Session state accessors for RTC persistence 93 + #[inline] 94 + pub fn scroll(&self) -> usize { 95 + self.scroll 96 + } 97 + 98 + #[inline] 99 + pub fn selected(&self) -> usize { 100 + self.selected 101 + } 102 + 103 + #[inline] 104 + pub fn total(&self) -> usize { 105 + self.total 106 + } 107 + 108 + /// Restore files state from RTC session data 109 + pub fn restore_state(&mut self, scroll: usize, selected: usize, total: usize) { 110 + self.scroll = scroll; 111 + self.selected = selected; 112 + self.total = total; 113 + self.needs_load = true; // trigger page reload 114 + log::info!( 115 + "files: restore_state scroll={} selected={} total={}", 116 + scroll, 117 + selected, 118 + total 119 + ); 91 120 } 92 121 93 122 fn selected_entry(&self) -> Option<&DirEntry> {
+54 -13
src/apps/home.rs
··· 28 28 const BM_HEADER_GAP: u16 = 4; 29 29 const BM_BOTTOM: u16 = SCREEN_H - BUTTON_BAR_H; 30 30 31 + const CONTENT_REGION: Region = Region::new(0, CONTENT_TOP, SCREEN_W, SCREEN_H - CONTENT_TOP); 32 + 31 33 fn compute_item_regions(heading_line_h: u16) -> [Region; MAX_ITEMS] { 32 34 let item_y = CONTENT_TOP + 8 + heading_line_h + TITLE_ITEM_GAP; 33 35 [ ··· 39 41 ] 40 42 } 41 43 42 - #[derive(Clone, Copy, PartialEq)] 44 + #[derive(Clone, Copy, PartialEq, Debug)] 43 45 enum HomeState { 44 46 Menu, 45 47 ShowBookmarks, ··· 100 102 self.item_regions = compute_item_regions(self.ui_fonts.heading.line_height); 101 103 } 102 104 105 + // Session state accessors for RTC persistence 106 + #[inline] 107 + pub fn state_id(&self) -> u8 { 108 + match self.state { 109 + HomeState::Menu => 0, 110 + HomeState::ShowBookmarks => 1, 111 + } 112 + } 113 + 114 + #[inline] 115 + pub fn selected(&self) -> usize { 116 + self.selected 117 + } 118 + 119 + #[inline] 120 + pub fn bm_selected(&self) -> usize { 121 + self.bm_selected 122 + } 123 + 124 + #[inline] 125 + pub fn bm_scroll(&self) -> usize { 126 + self.bm_scroll 127 + } 128 + 129 + /// Restore home state from RTC session data 130 + pub fn restore_state( 131 + &mut self, 132 + state_id: u8, 133 + selected: usize, 134 + bm_selected: usize, 135 + bm_scroll: usize, 136 + ) { 137 + self.state = match state_id { 138 + 1 => HomeState::ShowBookmarks, 139 + _ => HomeState::Menu, 140 + }; 141 + self.selected = selected; 142 + self.bm_selected = bm_selected; 143 + self.bm_scroll = bm_scroll; 144 + if self.state == HomeState::ShowBookmarks { 145 + self.needs_load_bookmarks = true; 146 + } 147 + log::info!( 148 + "home: restore_state state={:?} selected={}", 149 + self.state, 150 + selected 151 + ); 152 + } 153 + 103 154 pub fn load_recent(&mut self, k: &mut KernelHandle<'_>) { 104 155 let mut buf = [0u8; 32]; 105 156 match k.read_app_data_start(RECENT_FILE, &mut buf) { ··· 197 248 ctx.clear_message(); 198 249 self.state = HomeState::Menu; 199 250 self.selected = 0; 200 - ctx.mark_dirty(Region::new( 201 - 0, 202 - CONTENT_TOP, 203 - SCREEN_W, 204 - SCREEN_H - CONTENT_TOP, 205 - )); 251 + ctx.mark_dirty(CONTENT_REGION); 206 252 } 207 253 208 254 fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 209 255 self.state = HomeState::Menu; 210 256 self.selected = 0; 211 257 self.needs_load_recent = true; 212 - ctx.mark_dirty(Region::new( 213 - 0, 214 - CONTENT_TOP, 215 - SCREEN_W, 216 - SCREEN_H - CONTENT_TOP, 217 - )); 258 + ctx.mark_dirty(CONTENT_REGION); 218 259 } 219 260 220 261 async fn background(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) {
+179
src/apps/manager.rs
··· 170 170 self.home.on_enter(&mut self.launcher.ctx, k); 171 171 } 172 172 173 + // collect session state to RTC memory struct before sleep 174 + pub fn collect_session(&self, session: &mut crate::kernel::rtc_session::RtcSession) { 175 + use crate::kernel::rtc_session::MAX_NAV_STACK; 176 + 177 + // save navigation stack 178 + session.nav_depth = self.launcher.depth() as u8; 179 + for i in 0..MAX_NAV_STACK { 180 + session.nav_stack[i] = if i < self.launcher.depth() { 181 + self.launcher.stack_at(i) as u8 182 + } else { 183 + 0 184 + }; 185 + } 186 + 187 + // save reader state 188 + session.reader_filename_len = self.reader.filename_len() as u8; 189 + let len = session.reader_filename_len as usize; 190 + session.reader_filename[..len].copy_from_slice(self.reader.filename_bytes()); 191 + session.reader_is_epub = self.reader.is_epub() as u8; 192 + session.reader_chapter = self.reader.chapter(); 193 + session.reader_page = self.reader.page() as u16; 194 + session.reader_byte_offset = self.reader.byte_offset(); 195 + session.reader_font_size = self.reader.font_size_idx(); 196 + 197 + // save files state 198 + session.files_scroll = self.files.scroll() as u16; 199 + session.files_selected = self.files.selected() as u8; 200 + session.files_total = self.files.total() as u16; 201 + 202 + // save home state 203 + session.home_state = self.home.state_id(); 204 + session.home_selected = self.home.selected() as u8; 205 + session.home_bm_selected = self.home.bm_selected() as u8; 206 + session.home_bm_scroll = self.home.bm_scroll() as u8; 207 + 208 + // save settings cache 209 + let ss = self.settings.system_settings(); 210 + session.settings_sleep_timeout = ss.sleep_timeout; 211 + session.settings_ghost_clear = ss.ghost_clear_every; 212 + session.settings_book_font = ss.book_font_size_idx; 213 + session.settings_ui_font = ss.ui_font_size_idx; 214 + session.settings_valid = 1; 215 + 216 + log::info!( 217 + "session: collected nav_depth={} active={:?}", 218 + session.nav_depth, 219 + self.launcher.active() 220 + ); 221 + } 222 + 223 + // restore session from RTC memory; returns true if successful 224 + pub fn apply_session( 225 + &mut self, 226 + session: &crate::kernel::rtc_session::RtcSession, 227 + k: &mut KernelHandle<'_>, 228 + ) -> bool { 229 + // validate session data 230 + if session.nav_depth == 0 || session.nav_depth > 4 { 231 + log::warn!("session: invalid nav_depth {}", session.nav_depth); 232 + return false; 233 + } 234 + 235 + // restore navigation stack 236 + self.launcher.restore_stack( 237 + session.nav_depth as usize, 238 + &session.nav_stack, 239 + |id| match id { 240 + 0 => AppId::Home, 241 + 1 => AppId::Files, 242 + 2 => AppId::Reader, 243 + 3 => AppId::Settings, 244 + _ => AppId::Home, 245 + }, 246 + ); 247 + 248 + log::info!( 249 + "session: restored nav stack depth={} active={:?}", 250 + session.nav_depth, 251 + self.launcher.active() 252 + ); 253 + 254 + // restore home state (always in stack) 255 + self.home.restore_state( 256 + session.home_state, 257 + session.home_selected as usize, 258 + session.home_bm_selected as usize, 259 + session.home_bm_scroll as usize, 260 + ); 261 + 262 + // restore files state if in stack 263 + if self.launcher.contains(AppId::Files) { 264 + self.files.restore_state( 265 + session.files_scroll as usize, 266 + session.files_selected as usize, 267 + session.files_total as usize, 268 + ); 269 + } 270 + 271 + // restore reader state if active or in stack 272 + if self.launcher.active() == AppId::Reader || self.launcher.contains(AppId::Reader) { 273 + let filename = &session.reader_filename[..session.reader_filename_len as usize]; 274 + self.reader.restore_state( 275 + filename, 276 + session.reader_is_epub != 0, 277 + session.reader_chapter, 278 + session.reader_page as usize, 279 + session.reader_byte_offset, 280 + session.reader_font_size, 281 + ); 282 + } 283 + 284 + // propagate fonts before entering apps 285 + self.propagate_fonts(); 286 + 287 + // enter apps in stack order (bottom to top) 288 + // Home is always at bottom 289 + self.home.on_enter(&mut self.launcher.ctx, k); 290 + 291 + // for apps above Home, call on_suspend for suspended ones, on_enter for active 292 + let depth = self.launcher.depth(); 293 + for i in 1..depth { 294 + let app_id = self.launcher.stack_at(i); 295 + let is_active = i == depth - 1; 296 + 297 + match app_id { 298 + AppId::Files => { 299 + if is_active { 300 + self.files.on_enter(&mut self.launcher.ctx, k); 301 + } else { 302 + // Files was pushed then another app pushed on top 303 + self.files.on_enter(&mut self.launcher.ctx, k); 304 + self.files.on_suspend(); 305 + } 306 + } 307 + AppId::Reader => { 308 + if is_active { 309 + // set message for reader to know filename 310 + let filename = 311 + &session.reader_filename[..session.reader_filename_len as usize]; 312 + self.launcher.ctx.set_message(filename); 313 + self.reader.on_enter(&mut self.launcher.ctx, k); 314 + } 315 + } 316 + AppId::Settings => { 317 + if is_active { 318 + self.settings.on_enter(&mut self.launcher.ctx, k); 319 + } 320 + } 321 + _ => {} 322 + } 323 + 324 + // suspend apps that aren't the top 325 + if !is_active { 326 + match app_id { 327 + AppId::Home => self.home.on_suspend(), 328 + AppId::Files => {} // already handled above 329 + _ => {} 330 + } 331 + } 332 + } 333 + 334 + // mark full redraw needed 335 + self.launcher.ctx.request_full_redraw(); 336 + 337 + true 338 + } 339 + 173 340 // power-button long-press must be intercepted by the scheduler 174 341 // before calling this method 175 342 pub fn dispatch_event(&mut self, hw_event: Event, bm_cache: &mut BookmarkCache) -> Transition { ··· 465 632 466 633 fn enter_initial(&mut self, k: &mut KernelHandle<'_>) { 467 634 AppManager::enter_initial(self, k); 635 + } 636 + 637 + fn collect_session(&self, session: &mut crate::kernel::rtc_session::RtcSession) { 638 + AppManager::collect_session(self, session); 639 + } 640 + 641 + fn apply_session( 642 + &mut self, 643 + session: &crate::kernel::rtc_session::RtcSession, 644 + k: &mut KernelHandle<'_>, 645 + ) -> bool { 646 + AppManager::apply_session(self, session, k) 468 647 } 469 648 470 649 fn needs_special_mode(&self) -> bool {
+7 -6
src/apps/reader/images.rs
··· 6 6 // (small images); both epub_find_and_dispatch_image (background scan) 7 7 // and dispatch_one_image_in_chapter (nearby prefetch) call through it 8 8 9 - extern crate alloc; 10 - 11 9 use alloc::vec::Vec; 12 10 use core::cell::RefCell; 13 11 14 - use smol_epub::DecodedImage; 15 12 use smol_epub::cache; 16 13 use smol_epub::epub; 17 14 use smol_epub::html_strip::{IMG_REF, MARKER}; 18 15 use smol_epub::zip::{self, ZipIndex}; 16 + use smol_epub::DecodedImage; 19 17 20 18 use crate::error::{Error, ErrorKind}; 21 - use crate::kernel::KernelHandle; 22 19 use crate::kernel::work_queue; 20 + use crate::kernel::KernelHandle; 23 21 24 22 use super::{ 25 - IMAGE_DISPLAY_H, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, ReaderApp, TEXT_AREA_H, TEXT_W, 23 + ReaderApp, IMAGE_DISPLAY_H, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, TEXT_AREA_H, TEXT_W, 26 24 }; 27 25 28 26 // result of scanning a chapter for the next uncached image ··· 300 298 ch: usize, 301 299 start_offset: usize, 302 300 ) -> crate::error::Result<ScanResult> { 303 - if ch >= cache::MAX_CACHE_CHAPTERS || !self.epub.ch_cached[ch] { 301 + if ch >= self.epub.spine.len() 302 + || ch >= cache::MAX_CACHE_CHAPTERS 303 + || !self.epub.ch_cached[ch] 304 + { 304 305 return Ok(ScanResult::NoneFound); 305 306 } 306 307 let ch_size = self.epub.chapter_sizes[ch] as usize;
+102 -44
src/apps/reader/mod.rs
··· 2 2 mod images; 3 3 mod paging; 4 4 5 - extern crate alloc; 6 - 7 - use paging::decode_utf8_char; 5 + pub use pulp_kernel::util::decode_utf8_char; 8 6 9 7 use crate::apps::PendingSetting; 10 8 use crate::fonts::bitmap::{self, BitmapFont}; ··· 29 27 use crate::kernel::QuickAction; 30 28 use crate::kernel::bookmarks; 31 29 use crate::kernel::work_queue; 32 - use crate::ui::{ 33 - Alignment, CONTENT_TOP, HEADER_W, LOADING_H, POSITION_OVERLAY_H, POSITION_OVERLAY_W, 34 - PROGRESS_H, Region, STANDARD_MARGIN, StackFmt, TITLE_Y_OFFSET, 35 - }; 30 + use crate::ui::{Alignment, CONTENT_TOP, HEADER_W, Region, StackFmt, TITLE_Y_OFFSET}; 36 31 use smol_epub::DecodedImage; 37 32 use smol_epub::cache; 38 33 use smol_epub::epub::{self, EpubMeta, EpubSpine, EpubToc, TocSource}; ··· 41 36 }; 42 37 use smol_epub::zip::{self, ZipIndex}; 43 38 44 - pub(super) const MARGIN: u16 = STANDARD_MARGIN; 39 + pub(super) const MARGIN: u16 = 8; 45 40 46 41 pub(super) const HEADER_Y: u16 = CONTENT_TOP + TITLE_Y_OFFSET - 2; // slightly tighter 47 42 pub(super) const HEADER_H: u16 = 16; ··· 84 79 // images > this size are decoded on main loop via streaming SD reads 85 80 pub(super) const PRECACHE_IMG_MAX: u32 = 30 * 1024; 86 81 87 - pub(super) const READER_PROGRESS_H: u16 = PROGRESS_H; 82 + pub(super) const READER_PROGRESS_H: u16 = 2; 88 83 pub(super) const PROGRESS_Y: u16 = SCREEN_H - READER_PROGRESS_H - 1; 89 84 pub(super) const PROGRESS_W: u16 = SCREEN_W - 2 * MARGIN; 90 85 86 + const POSITION_OVERLAY_W: u16 = 280; 87 + const POSITION_OVERLAY_H: u16 = 40; 91 88 pub(super) const POSITION_OVERLAY: Region = Region::new( 92 89 (SCREEN_W - POSITION_OVERLAY_W) / 2, 93 90 (SCREEN_H - POSITION_OVERLAY_H) / 2, ··· 96 93 ); 97 94 98 95 const LOADING_W: u16 = SCREEN_W - 2 * MARGIN - 16; 96 + const LOADING_H: u16 = 24; 99 97 pub(super) const LOADING_REGION: Region = Region::new(MARGIN, TEXT_Y, LOADING_W, LOADING_H); 100 98 101 99 pub const QA_FONT_SIZE: u8 = 1; ··· 105 103 106 104 pub(super) const QA_MAX: usize = 4; 107 105 106 + // reader state machine: 107 + // NeedBookmark -> NeedInit -> NeedOpf -> NeedToc -> NeedCache -> NeedIndex -> NeedPage -> Ready 108 + // Ready <-> ShowToc (toc overlay); any state -> Error on failure 108 109 #[derive(Clone, Copy, PartialEq, Debug)] 109 110 pub(super) enum State { 110 111 NeedBookmark, ··· 394 395 ctx.set_loading(LOADING_REGION, lbuf.as_str(), pct); 395 396 } 396 397 398 + // transition to error state with consistent handling 399 + fn enter_error(&mut self, ctx: &mut AppContext, e: Error) { 400 + self.error = Some(e); 401 + self.state = State::Error; 402 + ctx.clear_loading(); 403 + ctx.mark_dirty(PAGE_REGION); 404 + } 405 + 397 406 // run one step of image work queue polling while suspended; 398 407 // chapter caching is async and only runs during active background, 399 408 // so this only handles the sync image recv states ··· 482 491 (buf, self.filename_len) 483 492 } 484 493 494 + // Session state accessors for RTC persistence 495 + #[inline] 496 + pub fn filename_len(&self) -> usize { 497 + self.filename_len 498 + } 499 + 500 + #[inline] 501 + pub fn filename_bytes(&self) -> &[u8] { 502 + &self.filename[..self.filename_len] 503 + } 504 + 505 + #[inline] 506 + pub fn is_epub(&self) -> bool { 507 + self.is_epub 508 + } 509 + 510 + #[inline] 511 + pub fn chapter(&self) -> u16 { 512 + self.epub.chapter 513 + } 514 + 515 + #[inline] 516 + pub fn page(&self) -> usize { 517 + self.pg.page 518 + } 519 + 520 + #[inline] 521 + pub fn byte_offset(&self) -> u32 { 522 + if self.pg.page < self.pg.total_pages { 523 + self.pg.offsets[self.pg.page] 524 + } else { 525 + 0 526 + } 527 + } 528 + 529 + #[inline] 530 + pub fn font_size_idx(&self) -> u8 { 531 + self.book_font_size_idx 532 + } 533 + 534 + /// Restore reader state from RTC session data 535 + pub fn restore_state( 536 + &mut self, 537 + filename: &[u8], 538 + is_epub: bool, 539 + chapter: u16, 540 + _page: usize, 541 + byte_offset: u32, 542 + font_size: u8, 543 + ) { 544 + let len = filename.len().min(32); 545 + self.filename[..len].copy_from_slice(&filename[..len]); 546 + self.filename_len = len; 547 + self.is_epub = is_epub; 548 + self.epub.chapter = chapter; 549 + self.restore_offset = if byte_offset > 0 { 550 + Some(byte_offset) 551 + } else { 552 + None 553 + }; 554 + self.book_font_size_idx = font_size; 555 + 556 + log::info!( 557 + "reader: restore_state file={} ch={} off={}", 558 + self.name(), 559 + chapter, 560 + byte_offset 561 + ); 562 + } 563 + 485 564 pub fn save_position(&self, bm: &mut bookmarks::BookmarkCache) { 486 565 if self.state == State::Ready { 487 566 bm.save( ··· 741 820 } 742 821 Err(e) => { 743 822 log::info!("reader: epub init (zip) failed: {}", e); 744 - self.error = Some(e); 745 - self.state = State::Error; 746 - ctx.clear_loading(); 747 - ctx.mark_dirty(PAGE_REGION); 823 + self.enter_error(ctx, e); 748 824 } 749 825 }, 750 826 751 827 State::NeedOpf => match self.epub_init_opf(k) { 752 828 Ok(()) => { 829 + // clamp restored chapter to valid spine range 830 + let spine_len = self.epub.spine.len(); 831 + if spine_len > 0 && self.epub.chapter as usize >= spine_len { 832 + self.epub.chapter = (spine_len - 1) as u16; 833 + } 753 834 self.state = State::NeedToc; 754 835 ctx.set_loading(LOADING_REGION, "Loading", 40); 755 836 } 756 837 Err(e) => { 757 838 log::info!("reader: epub init (opf) failed: {}", e); 758 - self.error = Some(e); 759 - self.state = State::Error; 760 - ctx.clear_loading(); 761 - ctx.mark_dirty(PAGE_REGION); 839 + self.enter_error(ctx, e); 762 840 } 763 841 }, 764 842 ··· 830 908 } 831 909 Err(e) => { 832 910 log::info!("reader: cache ch{} failed: {}", ch, e); 833 - self.error = Some(e); 834 - self.state = State::Error; 835 - ctx.clear_loading(); 836 - ctx.mark_dirty(PAGE_REGION); 911 + self.enter_error(ctx, e); 837 912 } 838 913 } 839 914 } 840 915 Err(e) => { 841 916 log::info!("reader: cache check failed: {}", e); 842 - self.error = Some(e); 843 - self.state = State::Error; 844 - ctx.clear_loading(); 845 - ctx.mark_dirty(PAGE_REGION); 917 + self.enter_error(ctx, e); 846 918 } 847 919 }, 848 920 ··· 860 932 .epub_cache_chapter_async(k, self.epub.chapter as usize) 861 933 .await 862 934 { 863 - self.error = Some(e); 864 - self.state = State::Error; 865 - ctx.clear_loading(); 866 - ctx.mark_dirty(PAGE_REGION); 935 + self.enter_error(ctx, e); 867 936 break; 868 937 } 869 938 } ··· 885 954 ctx.clear_loading(); 886 955 ctx.mark_dirty(PAGE_REGION); 887 956 } 888 - Err(e) => { 889 - self.error = Some(e); 890 - self.state = State::Error; 891 - ctx.clear_loading(); 892 - ctx.mark_dirty(PAGE_REGION); 893 - } 957 + Err(e) => self.enter_error(ctx, e), 894 958 } 895 959 } else { 896 960 self.state = State::NeedPage; ··· 905 969 match self.load_and_prefetch(k) { 906 970 Ok(()) => {} 907 971 Err(e) => { 908 - self.error = Some(e); 909 - self.state = State::Error; 910 - ctx.clear_loading(); 911 - ctx.mark_dirty(PAGE_REGION); 972 + self.enter_error(ctx, e); 912 973 break; 913 974 } 914 975 } ··· 936 997 } 937 998 Err(e) => { 938 999 log::info!("reader: load failed: {}", e); 939 - self.error = Some(e); 940 - self.state = State::Error; 941 - ctx.clear_loading(); 942 - ctx.mark_dirty(PAGE_REGION); 1000 + self.enter_error(ctx, e); 943 1001 } 944 1002 } 945 1003 } ··· 1458 1516 if filled_w > 0 { 1459 1517 Rectangle::new( 1460 1518 Point::new(MARGIN as i32, PROGRESS_Y as i32), 1461 - Size::new(filled_w, PROGRESS_H as u32), 1519 + Size::new(filled_w, READER_PROGRESS_H as u32), 1462 1520 ) 1463 1521 .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 1464 1522 .draw(strip)
+93 -116
src/apps/reader/paging.rs
··· 10 10 use crate::kernel::KernelHandle; 11 11 12 12 use super::{ 13 - IMAGE_DISPLAY_H, INDENT_PX, LINES_PER_PAGE, LineSpan, MAX_PAGES, NO_PREFETCH, PAGE_BUF, 14 - ReaderApp, State, TEXT_W, 13 + decode_utf8_char, LineSpan, ReaderApp, State, IMAGE_DISPLAY_H, INDENT_PX, LINES_PER_PAGE, 14 + MAX_PAGES, NO_PREFETCH, PAGE_BUF, TEXT_W, 15 15 }; 16 16 17 17 impl ReaderApp { ··· 352 352 } 353 353 } 354 354 355 - // decode one utf-8 character starting at buf[pos] 356 - // returns (char, byte_length); malformed input yields ('\u{FFFD}', consumed) 357 - pub(super) fn decode_utf8_char(buf: &[u8], pos: usize) -> (char, usize) { 358 - let b0 = buf[pos]; 359 - let (mut cp, expected) = if b0 < 0xE0 { 360 - ((b0 as u32) & 0x1F, 2) 361 - } else if b0 < 0xF0 { 362 - ((b0 as u32) & 0x0F, 3) 363 - } else { 364 - ((b0 as u32) & 0x07, 4) 365 - }; 366 - let len = buf.len(); 367 - if pos + expected > len { 368 - return ('\u{FFFD}', len - pos); 369 - } 370 - for i in 1..expected { 371 - let cont = buf[pos + i]; 372 - if cont & 0xC0 != 0x80 { 373 - return ('\u{FFFD}', i); 374 - } 375 - cp = (cp << 6) | (cont as u32 & 0x3F); 376 - } 377 - let ch = char::from_u32(cp).unwrap_or('\u{FFFD}'); 378 - (ch, expected) 379 - } 355 + // UTF-8 decoding is provided by pulp_kernel::util::decode_utf8_char 356 + // (re-exported via super::decode_utf8_char) 380 357 381 358 pub(super) fn trim_trailing_cr(buf: &[u8], start: usize, end: usize) -> usize { 382 359 if end > start && buf[end - 1] == b'\r' { ··· 402 379 ) -> (usize, usize) { 403 380 let max_l = max_lines.min(lines.len()); 404 381 let base_max_w = max_width_px; 405 - let mut lc: usize = 0; 406 - let mut ls: usize = 0; 407 - let mut px: u32 = 0; 408 - let mut sp: usize = 0; 409 - let mut sp_px: u32 = 0; 382 + let mut line_count: usize = 0; 383 + let mut line_start: usize = 0; 384 + let mut cursor_x: u32 = 0; 385 + let mut last_space: usize = 0; 386 + let mut cursor_at_space: u32 = 0; 410 387 411 388 let mut bold = false; 412 389 let mut italic = false; ··· 429 406 430 407 macro_rules! emit { 431 408 ($start:expr, $end:expr) => { 432 - if lc < max_l { 409 + if line_count < max_l { 433 410 let e = trim_trailing_cr(buf, $start, $end); 434 - lines[lc] = LineSpan { 411 + lines[line_count] = LineSpan { 435 412 start: ($start) as u16, 436 413 len: (e - ($start)) as u16, 437 414 flags: LineSpan::pack_flags(bold, italic, heading), 438 415 indent, 439 416 }; 440 - lc += 1; 417 + line_count += 1; 441 418 } 442 419 }; 443 420 } ··· 451 428 let path_len = buf[i + 2] as usize; 452 429 let path_start = i + 3; 453 430 if path_start + path_len <= n && path_len > 0 { 454 - if ls < i { 455 - emit!(ls, i); 456 - if lc >= max_l { 457 - return (i, lc); 431 + if line_start < i { 432 + emit!(line_start, i); 433 + if line_count >= max_l { 434 + return (i, line_count); 458 435 } 459 436 } 460 437 461 438 let line_h = fonts.line_height(fonts::Style::Regular); 462 439 let img_lines = (IMAGE_DISPLAY_H / line_h).max(1) as usize; 463 440 464 - if lc < max_l { 465 - lines[lc] = LineSpan { 441 + if line_count < max_l { 442 + lines[line_count] = LineSpan { 466 443 start: path_start as u16, 467 444 len: path_len as u16, 468 445 flags: LineSpan::FLAG_IMAGE, 469 446 indent: 0, 470 447 }; 471 - lc += 1; 448 + line_count += 1; 472 449 } 473 450 474 451 for _ in 1..img_lines { 475 - if lc >= max_l { 452 + if line_count >= max_l { 476 453 break; 477 454 } 478 - lines[lc] = LineSpan { 455 + lines[line_count] = LineSpan { 479 456 start: 0, 480 457 len: 0, 481 458 flags: LineSpan::FLAG_IMAGE, 482 459 indent: 0, 483 460 }; 484 - lc += 1; 461 + line_count += 1; 485 462 } 486 463 487 464 i = path_start + path_len; 488 - ls = i; 489 - px = 0; 490 - sp = ls; 491 - sp_px = 0; 492 - if lc >= max_l { 493 - return (ls, lc); 465 + line_start = i; 466 + cursor_x = 0; 467 + last_space = line_start; 468 + cursor_at_space = 0; 469 + if line_count >= max_l { 470 + return (line_start, line_count); 494 471 } 495 472 continue; 496 473 } ··· 523 500 } 524 501 525 502 if b == b'\n' { 526 - emit!(ls, i); 527 - ls = i + 1; 528 - px = 0; 529 - sp = ls; 530 - sp_px = 0; 531 - if lc >= max_l { 532 - return (ls, lc); 503 + emit!(line_start, i); 504 + line_start = i + 1; 505 + cursor_x = 0; 506 + last_space = line_start; 507 + cursor_at_space = 0; 508 + if line_count >= max_l { 509 + return (line_start, line_count); 533 510 } 534 511 i += 1; 535 512 continue; ··· 542 519 543 520 // soft hyphen (U+00AD): zero-width break opportunity 544 521 if ch == '\u{00AD}' { 545 - sp = i + seq_len; 546 - sp_px = px; 522 + last_space = i + seq_len; 523 + cursor_at_space = cursor_x; 547 524 i += seq_len; 548 525 continue; 549 526 } ··· 551 528 // NBSP and regular spaces: word-break opportunity 552 529 if is_wrap_space(ch) { 553 530 let sty = current_style(bold, italic, heading); 554 - px += fonts.advance(' ', sty) as u32; 555 - sp = i + seq_len; 556 - sp_px = px; 557 - if px > max_w { 558 - emit!(ls, i); 559 - ls = i + seq_len; 560 - px = 0; 561 - sp = ls; 562 - sp_px = 0; 563 - if lc >= max_l { 564 - return (ls, lc); 531 + cursor_x += fonts.advance(' ', sty) as u32; 532 + last_space = i + seq_len; 533 + cursor_at_space = cursor_x; 534 + if cursor_x > max_w { 535 + emit!(line_start, i); 536 + line_start = i + seq_len; 537 + cursor_x = 0; 538 + last_space = line_start; 539 + cursor_at_space = 0; 540 + if line_count >= max_l { 541 + return (line_start, line_count); 565 542 } 566 543 } 567 544 i += seq_len; ··· 570 547 571 548 let sty = current_style(bold, italic, heading); 572 549 let adv = fonts.advance(ch, sty) as u32; 573 - px += adv; 574 - if px > max_w { 575 - if sp > ls { 576 - emit!(ls, sp); 577 - px -= sp_px; 578 - ls = sp; 550 + cursor_x += adv; 551 + if cursor_x > max_w { 552 + if last_space > line_start { 553 + emit!(line_start, last_space); 554 + cursor_x -= cursor_at_space; 555 + line_start = last_space; 579 556 } else { 580 - emit!(ls, i); 581 - ls = i; 582 - px = adv; 557 + emit!(line_start, i); 558 + line_start = i; 559 + cursor_x = adv; 583 560 } 584 - sp = ls; 585 - sp_px = 0; 586 - if lc >= max_l { 587 - return (ls, lc); 561 + last_space = line_start; 562 + cursor_at_space = 0; 563 + if line_count >= max_l { 564 + return (line_start, line_count); 588 565 } 589 566 } 590 567 i += seq_len; ··· 600 577 let adv = fonts.advance_byte(b, sty) as u32; 601 578 602 579 if b == b' ' { 603 - px += adv; 604 - sp = i + 1; 605 - sp_px = px; 606 - if px > max_w { 607 - emit!(ls, i); 608 - ls = i + 1; 609 - px = 0; 610 - sp = ls; 611 - sp_px = 0; 612 - if lc >= max_l { 613 - return (ls, lc); 580 + cursor_x += adv; 581 + last_space = i + 1; 582 + cursor_at_space = cursor_x; 583 + if cursor_x > max_w { 584 + emit!(line_start, i); 585 + line_start = i + 1; 586 + cursor_x = 0; 587 + last_space = line_start; 588 + cursor_at_space = 0; 589 + if line_count >= max_l { 590 + return (line_start, line_count); 614 591 } 615 592 } 616 593 i += 1; 617 594 continue; 618 595 } 619 596 620 - px += adv; 621 - if px > max_w { 622 - if sp > ls { 623 - emit!(ls, sp); 624 - px -= sp_px; 625 - ls = sp; 597 + cursor_x += adv; 598 + if cursor_x > max_w { 599 + if last_space > line_start { 600 + emit!(line_start, last_space); 601 + cursor_x -= cursor_at_space; 602 + line_start = last_space; 626 603 } else { 627 - emit!(ls, i); 628 - ls = i; 629 - px = adv; 604 + emit!(line_start, i); 605 + line_start = i; 606 + cursor_x = adv; 630 607 } 631 - sp = ls; 632 - sp_px = 0; 633 - if lc >= max_l { 634 - return (ls, lc); 608 + last_space = line_start; 609 + cursor_at_space = 0; 610 + if line_count >= max_l { 611 + return (line_start, line_count); 635 612 } 636 613 } 637 614 638 615 i += 1; 639 616 } 640 617 641 - if ls < n && lc < max_l { 642 - let e = trim_trailing_cr(buf, ls, n); 643 - if e > ls { 644 - lines[lc] = LineSpan { 645 - start: ls as u16, 646 - len: (e - ls) as u16, 618 + if line_start < n && line_count < max_l { 619 + let e = trim_trailing_cr(buf, line_start, n); 620 + if e > line_start { 621 + lines[line_count] = LineSpan { 622 + start: line_start as u16, 623 + len: (e - line_start) as u16, 647 624 flags: LineSpan::pack_flags(bold, italic, heading), 648 625 indent, 649 626 }; 650 - lc += 1; 627 + line_count += 1; 651 628 } 652 629 } 653 630 654 - (n, lc) 631 + (n, line_count) 655 632 }
+4 -6
src/apps/settings.rs
··· 13 13 SLEEP_TIMEOUT_STEP, SystemSettings, WifiConfig, parse_settings_txt, write_settings_txt, 14 14 }; 15 15 use crate::ui::{ 16 - Alignment, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, LARGE_MARGIN, MENU_ROW_GAP, MENU_ROW_H, 17 - Region, SECTION_GAP, StackFmt, TITLE_Y, wrap_next, wrap_prev, 16 + Alignment, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, LARGE_MARGIN, Region, SECTION_GAP, 17 + StackFmt, TITLE_Y, wrap_next, wrap_prev, 18 18 }; 19 19 20 - // ── Settings layout constants ─────────────────────────────────── 21 - 22 - const ROW_H: u16 = MENU_ROW_H; 23 - const ROW_GAP: u16 = MENU_ROW_GAP; 20 + const ROW_H: u16 = 40; 21 + const ROW_GAP: u16 = 6; 24 22 const ROW_STRIDE: u16 = ROW_H + ROW_GAP; 25 23 26 24 const LABEL_X: u16 = LARGE_MARGIN;
+2 -1
src/apps/upload.rs
··· 23 23 use crate::kernel::config::WifiConfig; 24 24 use crate::kernel::tasks; 25 25 use crate::ui::{ 26 - Alignment, BitmapLabel, ButtonFeedback, CONTENT_TOP, FOOTER_Y, LARGE_MARGIN, Region, stack_fmt, 26 + Alignment, BitmapLabel, ButtonFeedback, CONTENT_TOP, LARGE_MARGIN, Region, stack_fmt, 27 27 }; 28 28 29 29 const HEADING_X: u16 = LARGE_MARGIN; ··· 32 32 const BODY_X: u16 = 24; 33 33 const BODY_W: u16 = SCREEN_W - BODY_X * 2; 34 34 const BODY_LINE_GAP: u16 = 10; 35 + const FOOTER_Y: u16 = SCREEN_H - 60; 35 36 36 37 const HTTP_200_HTML: &[u8] = 37 38 b"HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n";
+8 -5
src/bin/main.rs
··· 108 108 let sd_ok = sd.probe_ok(); 109 109 if sd_ok { 110 110 console.push("sd: fat32 mounted"); 111 - let _ = storage::ensure_pulp_dir_async(&sd).await; 111 + if let Err(e) = storage::ensure_pulp_dir_async(&sd).await { 112 + console.push("sd: pulp dir failed"); 113 + log::warn!("ensure_pulp_dir: {:?}", e); 114 + } 112 115 } 113 116 114 117 let mut input = InputDriver::new(board.input); ··· 141 144 142 145 kernel.boot(&mut app_mgr).await; 143 146 144 - spawner.spawn(tasks::input_task(input)).unwrap(); 145 - spawner.spawn(tasks::housekeeping_task()).unwrap(); 146 - spawner.spawn(tasks::idle_timeout_task()).unwrap(); 147 - spawner.spawn(work_queue::worker_task()).unwrap(); 147 + spawner.spawn(tasks::input_task(input)).expect("spawn input_task"); 148 + spawner.spawn(tasks::housekeeping_task()).expect("spawn housekeeping_task"); 149 + spawner.spawn(tasks::idle_timeout_task()).expect("spawn idle_timeout_task"); 150 + spawner.spawn(work_queue::worker_task()).expect("spawn worker_task"); 148 151 info!("kernel ready."); 149 152 150 153 kernel.run(&mut app_mgr).await
+5 -67
src/fonts/bitmap.rs
··· 13 13 use crate::drivers::strip::StripBuffer; 14 14 use crate::ui::{Alignment, Region}; 15 15 16 + // re-export UTF-8 iterator from kernel util for convenience 17 + pub use pulp_kernel::util::Utf8Iter; 18 + 16 19 pub const FIRST_CHAR: u8 = 0x20; 17 20 pub const LAST_CHAR: u8 = 0x7E; 18 21 pub const GLYPH_COUNT: usize = (LAST_CHAR - FIRST_CHAR + 1) as usize; ··· 66 69 impl BitmapFont { 67 70 // look up a character, return glyph metrics 68 71 // ascii: direct array index; extended: binary search 69 - // unknown chars fall back to space glyph (index 0) 72 + // unknown chars fall back to '?' glyph 70 73 #[inline] 71 74 pub fn glyph(&self, ch: char) -> &BitmapGlyph { 72 75 self.resolve(ch).glyph ··· 243 246 ); 244 247 } 245 248 246 - // minimal utf-8 byte-slice iterator 247 - // decodes &[u8] one char at a time; invalid sequences replaced with U+FFFD 248 - // (which renders as '?' via the font fallback) 249 - pub struct Utf8Iter<'a> { 250 - data: &'a [u8], 251 - pos: usize, 252 - } 253 - 254 - impl<'a> Utf8Iter<'a> { 255 - #[inline] 256 - pub fn new(data: &'a [u8]) -> Self { 257 - Self { data, pos: 0 } 258 - } 259 - } 260 - 261 - impl Iterator for Utf8Iter<'_> { 262 - type Item = char; 263 - 264 - fn next(&mut self) -> Option<char> { 265 - if self.pos >= self.data.len() { 266 - return None; 267 - } 268 - 269 - let b0 = self.data[self.pos]; 270 - 271 - // single-byte ascii 272 - if b0 < 0x80 { 273 - self.pos += 1; 274 - return Some(b0 as char); 275 - } 276 - 277 - // determine expected sequence length from lead byte 278 - let (mut cp, expected) = if b0 < 0xC0 { 279 - // stray continuation byte 280 - self.pos += 1; 281 - return Some('\u{FFFD}'); 282 - } else if b0 < 0xE0 { 283 - ((b0 as u32) & 0x1F, 2) 284 - } else if b0 < 0xF0 { 285 - ((b0 as u32) & 0x0F, 3) 286 - } else if b0 < 0xF8 { 287 - ((b0 as u32) & 0x07, 4) 288 - } else { 289 - self.pos += 1; 290 - return Some('\u{FFFD}'); 291 - }; 292 - 293 - if self.pos + expected > self.data.len() { 294 - self.pos = self.data.len(); 295 - return Some('\u{FFFD}'); 296 - } 297 - 298 - // decode continuation bytes 299 - for i in 1..expected { 300 - let cont = self.data[self.pos + i]; 301 - if cont & 0xC0 != 0x80 { 302 - self.pos += i; 303 - return Some('\u{FFFD}'); 304 - } 305 - cp = (cp << 6) | (cont as u32 & 0x3F); 306 - } 307 - 308 - self.pos += expected; 309 - Some(char::from_u32(cp).unwrap_or('\u{FFFD}')) 310 - } 311 - } 249 + // UTF-8 iteration is provided by pulp_kernel::util::Utf8Iter (re-exported above)