···4040static SPI_BUS: StaticCell<Mutex<RefCell<SpiBus>>> = StaticCell::new();
41414242// cached ref to the SPI bus mutex, set once in Board::init
4343-static SPI_BUS_REF: Mutex<core::cell::Cell<Option<&'static Mutex<RefCell<SpiBus>>>>> =
4343+// cached ref to the SPI bus mutex; pub(crate) so scheduler can
4444+// access the bus in sd_card_sleep before deep sleep
4545+pub(crate) static SPI_BUS_REF: Mutex<core::cell::Cell<Option<&'static Mutex<RefCell<SpiBus>>>>> =
4446 Mutex::new(core::cell::Cell::new(None));
4747+4848+// sd cs clone; only used in enter_sleep to send cmd0
4949+// safety: same clone_unchecked pattern as gpio0/1/2/3 in init_input;
5050+// only accessed after all normal sd i/o has stopped and before mcu halts
5151+pub(crate) static SD_CS_SLEEP: Mutex<RefCell<Option<raw_gpio::RawOutputPin>>> =
5252+ Mutex::new(RefCell::new(None));
45534654static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None));
4755···178186179187 // GPIO12 free in DIO mode; no esp-hal type, use raw registers
180188 let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) };
189189+190190+ // second handle to GPIO12 for sending cmd0 before deep sleep
191191+ let sd_cs_sleep = unsafe { raw_gpio::RawOutputPin::new(12) };
192192+ critical_section::with(|cs| {
193193+ SD_CS_SLEEP.borrow_ref_mut(cs).replace(sd_cs_sleep);
194194+ });
181195182196 let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400));
183197
···170170 pub(crate) fn borrow_inner(&self) -> Option<core::cell::RefMut<'_, SdStorageInner>> {
171171 self.inner.as_ref().map(|c| c.borrow_mut())
172172 }
173173+174174+ // flush pending writes and close fat handles; best-effort before halt.
175175+ // after this call no further sd i/o is possible until mcu reset
176176+ pub fn flush_and_close(&self) {
177177+ if let Some(ref cell) = self.inner {
178178+ let mut guard = cell.borrow_mut();
179179+ let inner = &mut *guard;
180180+ let _ = inner.mgr.close_dir(inner.root);
181181+ poll_once(async {
182182+ let _ = inner.mgr.close_volume(inner.vol).await;
183183+ });
184184+ }
185185+ }
173186}
174187175188// drive a future to completion in exactly one poll
···1717pub mod wake;
1818pub mod work_queue;
19192020+// Unified error types (primary home: crate::error)
2121+pub use crate::error::{Error, ErrorKind, Result, ResultExt};
2222+2323+// Backward-compatible alias so `kernel::StorageError` keeps working
2424+// during migration. It is now `type StorageError = Error`.
2525+pub use crate::drivers::storage::StorageError;
2626+2027pub use app::{
2128 App, AppContext, AppIdType, AppLayer, Launcher, NavEvent, PendingSetting, QuickAction,
2229 QuickActionKind, RECENT_FILE, Redraw, Transition,
2330};
2431pub use bookmarks::BookmarkCache;
2532pub use console::BootConsole;
2626-pub use handle::{KernelHandle, StorageError};
3333+pub use handle::KernelHandle;
2734pub use wake::uptime_secs;
28352936use esp_hal::delay::Delay;
+160-70
kernel/src/kernel/scheduler.rs
···11// scheduler: main event loop, render pipeline, housekeeping, sleep
22//
33-// EPD and SD share a single SPI bus via CriticalSectionDevice;
44-// busy_wait_with_input() does NOT run background SD I/O while
55-// the EPD is refreshing to avoid RefCell borrow conflicts
33+// EPD and SD share a single SPI bus via CriticalSectionDevice.
44+// during normal operation, all SD I/O completes before render()
55+// touches the EPD. during the DU/GC waveform (~400ms), the EPD
66+// charge pump drives pixels with no SPI commands, so the bus is
77+// free for SD I/O. busy_wait_with_background exploits this window
88+// to run background caching and housekeeping during the waveform.
99+//
1010+// handle_input and poll_housekeeping are synchronous; they return
1111+// a bool flag when the caller should enter_sleep (which is async
1212+// because it renders a sleep screen via the EPD).
1313+//
1414+// sd_card_sleep sends cmd0 before deep sleep to reduce sd card
1515+// idle current from ~150 µa to ~10 µa
616717use embassy_futures::select::{Either, select};
88-use embassy_time::{Duration, Ticker, Timer};
1818+use embassy_time::{Duration, Ticker, with_timeout};
919use log::info;
10201121use super::app::{AppLayer, Redraw, Transition};
···2030const TICK_MS: u64 = 10;
21312232impl super::Kernel {
2323- // render boot console to EPD -- call before boot() to show
3333+ // render boot console to EPD; call before boot() to show
2434 // hardware init progress in the built-in mono font
2535 pub async fn show_boot_console(&mut self, console: &super::BootConsole) {
2636 let draw = |s: &mut StripBuffer| console.draw(s);
···41514252 tasks::set_idle_timeout(app_mgr.system_settings().sleep_timeout);
4353 self.log_stats();
4444- app_mgr.enter_initial(&mut self.handle()).await;
5454+ app_mgr.enter_initial(&mut self.handle());
45554656 {
4757 let draw = |s: &mut StripBuffer| app_mgr.draw(s);
···5464 info!("ui ready.");
5565 }
56665757- // event-driven main loop -- never returns
6767+ // event-driven main loop; never returns
6868+ //
6969+ // two genuine async suspension points in steady state:
7070+ // 1. select(INPUT_EVENTS.receive(), work_ticker.next())
7171+ // 2. EPD busy pin wait inside render()
7272+ // everything between them is synchronous function calls
5873 pub async fn run<A: AppLayer>(&mut self, app_mgr: &mut A) -> ! {
5974 let mut work_ticker = Ticker::every(Duration::from_millis(TICK_MS));
6075···6479 continue;
6580 }
66818282+ // async point 1: wait for input or tick
6783 let hw_event = match select(tasks::INPUT_EVENTS.receive(), work_ticker.next()).await {
6884 Either::First(ev) => Some(ev),
6985 Either::Second(_) => None,
7086 };
71877288 if let Some(ev) = hw_event {
7373- self.handle_input(ev, app_mgr).await;
8989+ if self.handle_input(ev, app_mgr) {
9090+ self.enter_sleep("power held").await;
9191+ continue;
9292+ }
7493 }
75947695 if app_mgr.needs_special_mode() {
7796 continue;
7897 }
79988080- // SAFETY-CRITICAL: SPI bus sharing invariant
9999+ // SPI bus sharing invariant
81100 //
8282- // The EPD and SD card share a single SPI2 bus via
8383- // CriticalSectionDevice (RefCell under the hood). SD I/O
8484- // and EPD rendering must NEVER overlap, concurrent access
8585- // would cause a RefCell borrow panic at runtime.
101101+ // the EPD and SD card share a single SPI2 bus via
102102+ // CriticalSectionDevice (RefCell under the hood).
86103 //
8787- // This ordering enforces that:
8888- // 1. All background SD I/O (app caching, title scan, etc.)
8989- // completes here, before any EPD access.
9090- // 2. poll_housekeeping may do SD I/O (bookmark flush,
9191- // SD probe), also before render.
9292- // 3. render() is the only code below that touches the EPD.
9393- // 4. busy_wait_with_input() does NOT run background work
9494- // while the EPD is refreshing, only input collection.
104104+ // 1. background SD I/O runs here, before EPD access;
105105+ // interruptible by input so the user can navigate
106106+ // away during long-running caching operations
107107+ // 2. poll_housekeeping may do SD I/O, also before render
108108+ // 3. render() touches the EPD; during the waveform window
109109+ // busy_wait_with_background runs SD I/O because the
110110+ // EPD charge pump is driving pixels with no SPI commands
111111+ // 4. no SD I/O outside these three sites
95112 //
9696- // If you add new SD I/O call sites, they MUST go above the
9797- // render() call. Violating this will panic, not corrupt.
9898- {
113113+ // when input arrives during run_background, the background
114114+ // future is dropped. this is safe: partial chapter cache
115115+ // writes leave ch_cached=false so the chapter is recached
116116+ // on the next attempt
117117+ let bg_input = {
99118 let mut handle = self.handle();
100100- app_mgr.run_background(&mut handle).await;
119119+ match select(
120120+ app_mgr.run_background(&mut handle),
121121+ tasks::INPUT_EVENTS.receive(),
122122+ )
123123+ .await
124124+ {
125125+ Either::First(()) => None,
126126+ Either::Second(ev) => Some(ev),
127127+ }
128128+ };
129129+130130+ if let Some(ev) = bg_input {
131131+ if self.handle_input(ev, app_mgr) {
132132+ self.enter_sleep("power held").await;
133133+ continue;
134134+ }
135135+136136+ if app_mgr.needs_special_mode() {
137137+ continue;
138138+ }
101139 }
102140103103- self.poll_housekeeping(app_mgr).await;
141141+ if self.poll_housekeeping(app_mgr) {
142142+ self.enter_sleep("idle timeout").await;
143143+ continue;
144144+ }
104145105105- if app_mgr.has_redraw() {
146146+ if app_mgr.ctx_mut().render_ready() {
106147 let redraw = app_mgr.take_redraw();
107148 self.render(app_mgr, redraw).await;
108149 }
···116157 .run_special_mode(&mut self.epd, self.strip, &mut self.delay, &self.sd)
117158 .await;
118159119119- app_mgr
120120- .apply_transition(Transition::Pop, &mut self.handle())
121121- .await;
160160+ app_mgr.apply_transition(Transition::Pop, &mut self.handle());
122161 app_mgr.request_full_redraw();
123162 }
124163125125- async fn handle_input<A: AppLayer>(&mut self, hw_event: Event, app_mgr: &mut A) {
126126- // power long-press -> sleep (intercept before app dispatch)
164164+ // returns true if caller should call enter_sleep
165165+ //
166166+ // note: the original async version called enter_sleep inline
167167+ // on power-long-press and then fell through to dispatch_event
168168+ // if sleep_deep somehow returned; this version correctly returns
169169+ // early so the caller can enter_sleep and continue the loop
170170+ fn handle_input<A: AppLayer>(&mut self, hw_event: Event, app_mgr: &mut A) -> bool {
127171 if hw_event == Event::LongPress(Button::Power) {
128128- self.enter_sleep("power held").await;
172172+ return true;
129173 }
130174131175 let transition = app_mgr.dispatch_event(hw_event, &mut *self.bm_cache);
132176133177 if transition != Transition::None {
134134- app_mgr
135135- .apply_transition(transition, &mut self.handle())
136136- .await;
178178+ app_mgr.apply_transition(transition, &mut self.handle());
137179 }
180180+181181+ false
138182 }
139183140140- async fn poll_housekeeping<A: AppLayer>(&mut self, app_mgr: &A) {
184184+ // returns true if idle sleep is due
185185+ fn poll_housekeeping<A: AppLayer>(&mut self, app_mgr: &A) -> bool {
141186 if let Some(mv) = tasks::BATTERY_MV.try_take() {
142187 self.cached_battery_mv = mv;
143188 }
···157202 }
158203 }
159204160160- if tasks::IDLE_SLEEP_DUE.try_take().is_some() {
161161- self.enter_sleep("idle timeout").await;
205205+ tasks::IDLE_SLEEP_DUE.try_take().is_some()
206206+ }
207207+208208+ // housekeeping without idle-sleep check; do not initiate sleep mid-refresh
209209+ fn poll_housekeeping_waveform<A: AppLayer>(&mut self, app_mgr: &A) {
210210+ if let Some(mv) = tasks::BATTERY_MV.try_take() {
211211+ self.cached_battery_mv = mv;
212212+ }
213213+214214+ if tasks::SD_CHECK_DUE.try_take().is_some() {
215215+ self.sd_ok = self.sd.probe_ok();
216216+ }
217217+218218+ if tasks::BOOKMARK_FLUSH_DUE.try_take().is_some() && self.bm_cache.is_dirty() {
219219+ self.bm_cache.flush(&self.sd);
162220 }
221221+222222+ if tasks::STATUS_DUE.try_take().is_some() {
223223+ self.log_stats();
224224+ if app_mgr.settings_loaded() {
225225+ tasks::set_idle_timeout(app_mgr.system_settings().sleep_timeout);
226226+ }
227227+ }
228228+ // idle sleep not checked here; never sleep during a waveform
163229 }
164230165231 // partial refreshes use DU waveform (~400 ms); after ghost_clear_every
···199265200266 if let Some(rs) = rs {
201267 self.epd.partial_start_du(&rs);
202202- let deferred = self.busy_wait_with_input(app_mgr).await;
268268+ let deferred = self.busy_wait_with_background(app_mgr).await;
203269204270 if app_mgr.has_redraw() {
205271 // content changed mid-DU; leave RED stale
···217283 }
218284219285 if let Some(transition) = deferred {
220220- app_mgr
221221- .apply_transition(transition, &mut self.handle())
222222- .await;
286286+ app_mgr.apply_transition(transition, &mut self.handle());
223287 }
224288225289 break 'render;
···247311248312 self.epd.start_full_update();
249313250250- let deferred = self.busy_wait_with_input(app_mgr).await;
314314+ let deferred = self.busy_wait_with_background(app_mgr).await;
251315252316 self.epd.finish_full_update();
253317 self.partial_refreshes = 0;
254318 self.red_stale = false;
255319256320 if let Some(transition) = deferred {
257257- app_mgr
258258- .apply_transition(transition, &mut self.handle())
259259- .await;
321321+ app_mgr.apply_transition(transition, &mut self.handle());
260322 }
261323 }
262324 } // 'render
263325 }
264326265265- // Collect input events while EPD is busy refreshing.
327327+ // collect input and run background work while EPD is busy refreshing
266328 //
267267- // SAFETY-CRITICAL: no SD I/O or background work may run here.
268268- // The EPD is actively driving the SPI bus during refresh; any
269269- // SD access would cause a RefCell borrow panic. Only input
270270- // events (from the ADC-based input_task) are collected.
271271- async fn busy_wait_with_input<A: AppLayer>(
329329+ // during the DU/GC waveform the EPD charge pump drives pixels;
330330+ // no SPI commands are sent, so the bus is free for SD I/O.
331331+ // is_busy() is a sync GPIO read; no epd borrow is held across
332332+ // any .await point, so self is fully available for handle() etc.
333333+ async fn busy_wait_with_background<A: AppLayer>(
272334 &mut self,
273335 app_mgr: &mut A,
274336 ) -> Option<Transition<A::Id>> {
275337 let mut deferred: Option<Transition<A::Id>> = None;
276338277339 loop {
340340+ // sync gpio read; no borrow held after this line
278341 if !self.epd.is_busy() {
279342 break;
280343 }
281344282282- match select(
283283- self.epd.busy_pin().wait_for_low(),
284284- select(
285285- tasks::INPUT_EVENTS.receive(),
286286- Timer::after(Duration::from_millis(TICK_MS)),
287287- ),
345345+ // wait up to TICK_MS for input; no epd borrow involved
346346+ let input_event = with_timeout(
347347+ Duration::from_millis(TICK_MS),
348348+ tasks::INPUT_EVENTS.receive(),
288349 )
289350 .await
290290- {
291291- Either::First(_) => break,
351351+ .ok();
292352293293- Either::Second(Either::First(hw_event)) => {
294294- if app_mgr.suppress_deferred_input() {
295295- continue;
296296- }
297297-353353+ if let Some(hw_event) = input_event {
354354+ if !app_mgr.suppress_deferred_input() {
298355 let t = app_mgr.dispatch_event(hw_event, &mut *self.bm_cache);
299356 if t != Transition::None && deferred.is_none() {
300357 deferred = Some(t);
301358 }
302359 }
360360+ continue;
361361+ }
303362304304- Either::Second(Either::Second(_)) => {}
363363+ // timeout elapsed; spi bus is free during waveform
364364+ {
365365+ let mut handle = self.handle();
366366+ app_mgr.run_background(&mut handle).await;
305367 }
368368+ self.poll_housekeeping_waveform(app_mgr);
306369 }
307370308371 deferred
309372 }
310373311311- // flush bookmarks, render sleep screen, enter MCU deep sleep
374374+ // flush bookmarks, render sleep screen, enter MCU deep sleep;
312375 // on real hardware this never returns (wake = full MCU reset)
313376 pub async fn enter_sleep(&mut self, reason: &str) {
314377 use embedded_graphics::mono_font::MonoTextStyle;
···326389 self.bm_cache.flush(&self.sd);
327390 }
328391392392+ self.sd_card_sleep();
393393+329394 self.epd
330395 .full_refresh_async(self.strip, &mut self.delay, &|s: &mut StripBuffer| {
331396 let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On);
···339404340405 // safety: deep sleep never returns, the MCU resets on wake, so
341406 // these stolen peripherals cannot alias with their original
342342- // owners. LPWR is not used elsewhere; GPIO3 was previously
343343- // cloned into InputHw but we are about to halt the CPU.
407407+ // owners. LPWR is not used elsewhere; GPIO3 was previously
408408+ // cloned into InputHw but we are about to halt the CPU
344409 let mut rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() });
345410 let mut gpio3 = unsafe { esp_hal::peripherals::GPIO3::steal() };
346411 let wakeup_pins: &mut [(&mut dyn RtcPinWithResistors, WakeupLevel)] =
···355420 loop {
356421 core::hint::spin_loop();
357422 }
423423+ }
424424+425425+ // send cmd0 to put sd card into idle/sleep state;
426426+ // reduces sd current from ~150 µa to ~10 µa during deep sleep.
427427+ // call after all sd i/o is done and before epd sleep-screen render
428428+ fn sd_card_sleep(&self) {
429429+ use embedded_hal::digital::OutputPin;
430430+431431+ self.sd.flush_and_close();
432432+433433+ critical_section::with(|cs| {
434434+ let bus_ref = crate::board::SPI_BUS_REF.borrow(cs).get();
435435+ let mut cs_pin = crate::board::SD_CS_SLEEP.borrow_ref_mut(cs);
436436+437437+ if let (Some(bus_ref), Some(pin)) = (bus_ref, cs_pin.as_mut()) {
438438+ let mut bus: core::cell::RefMut<'_, _> = bus_ref.borrow(cs).borrow_mut();
439439+ // 80 clocks cs high (sd spec: card ready for command)
440440+ let _ = bus.write(&[0xFF; 10]);
441441+ let _ = pin.set_low();
442442+ // cmd0 (GO_IDLE_STATE) with valid crc
443443+ let _ = bus.write(&[0x40, 0x00, 0x00, 0x00, 0x00, 0x95]);
444444+ let _ = bus.write(&[0xFF]);
445445+ let _ = pin.set_high();
446446+ }
447447+ });
358448 }
359449360450 pub fn log_stats(&self) {
+24-7
kernel/src/kernel/tasks.rs
···14141515pub static BATTERY_MV: Signal<CriticalSectionRawMutex, u16> = Signal::new();
16161717-const BATTERY_INTERVAL_TICKS: u32 = 3000; // 3000 x 10 ms = 30 s
1717+const POLL_ACTIVE_MS: u64 = 10; // 100 hz: during/after input
1818+const POLL_IDLE_MS: u64 = 50; // 20 hz: no recent input
1919+const POLL_DROWSY_MS: u64 = 200; // 5 hz: approaching sleep timeout
2020+2121+const IDLE_AFTER_MS: u64 = 2_000;
2222+const DROWSY_AFTER_MS: u64 = 30_000;
2323+2424+const BATTERY_INTERVAL_MS: u64 = 30_000;
18251926#[embassy_executor::task]
2027pub async fn input_task(mut input: InputDriver) -> ! {
2121- let mut ticker = Ticker::every(Duration::from_millis(10));
2222- let mut battery_counter: u32 = 0;
2828+ let mut poll_ms = POLL_ACTIVE_MS;
2929+ let mut battery_accum_ms: u64 = 0;
23302431 let raw = input.read_battery_mv();
2532 BATTERY_MV.signal(battery::adc_to_battery_mv(raw));
26332734 loop {
2828- ticker.next().await;
3535+ Timer::after(Duration::from_millis(poll_ms)).await;
29363037 if let Some(ev) = input.poll() {
3138 let _ = INPUT_EVENTS.try_send(ev);
3239 IDLE_RESET.signal(());
4040+ poll_ms = POLL_ACTIVE_MS;
4141+ } else {
4242+ let since = input.ms_since_last_event();
4343+ poll_ms = if since > DROWSY_AFTER_MS {
4444+ POLL_DROWSY_MS
4545+ } else if since > IDLE_AFTER_MS {
4646+ POLL_IDLE_MS
4747+ } else {
4848+ POLL_ACTIVE_MS
4949+ };
3350 }
34513535- battery_counter += 1;
3636- if battery_counter >= BATTERY_INTERVAL_TICKS {
3737- battery_counter = 0;
5252+ battery_accum_ms += poll_ms;
5353+ if battery_accum_ms >= BATTERY_INTERVAL_MS {
5454+ battery_accum_ms = 0;
3855 let raw = input.read_battery_mv();
3956 BATTERY_MV.signal(battery::adc_to_battery_mv(raw));
4057 }
···10101111pub mod board;
1212pub mod drivers;
1313+pub mod error;
1314pub mod kernel;
1415pub mod ui;
1616+1717+// Re-export the core error types at crate root for convenience.
1818+pub use error::{Error, ErrorKind, Result, ResultExt};
+1-1
kernel/src/ui/mod.rs
···1212pub use statusbar::{
1313 BAR_HEIGHT, CONTENT_TOP, free_stack_bytes, paint_stack, stack_high_water_mark,
1414};
1515-pub use widget::{Alignment, Region, wrap_next, wrap_prev};
1515+pub use widget::{Alignment, Region, draw_progress_bar, wrap_next, wrap_prev};
16161717pub use crate::board::{SCREEN_H, SCREEN_W};
+41-2
kernel/src/ui/widget.rs
···11-// region geometry and alignment helpers
11+// region geometry and alignment helpers, progress bar drawing
2233-use embedded_graphics::{prelude::*, primitives::Rectangle};
33+use embedded_graphics::{
44+ pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle, primitives::Rectangle,
55+};
66+77+use crate::drivers::strip::StripBuffer;
4859#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
610pub struct Region {
···110114 }
111115 if current == 0 { count - 1 } else { current - 1 }
112116}
117117+118118+// horizontal progress bar for 1-bit e-paper.
119119+// draws a 1px black border around the full track and fills
120120+// proportionally from the left. pct is clamped to 0..=100.
121121+// region should be at least 4px wide and 4px tall.
122122+pub fn draw_progress_bar(strip: &mut StripBuffer, region: Region, pct: u8) {
123123+ let pct = pct.min(100) as u32;
124124+125125+ // clear region
126126+ region
127127+ .to_rect()
128128+ .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
129129+ .draw(strip)
130130+ .unwrap();
131131+132132+ // 1px border shows full extent even at 0%
133133+ region
134134+ .to_rect()
135135+ .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
136136+ .draw(strip)
137137+ .unwrap();
138138+139139+ // filled portion inside the border
140140+ if pct > 0 && region.w > 2 && region.h > 2 {
141141+ let inner_w = (region.w - 2) as u32;
142142+ let fill_w = (inner_w * pct / 100).max(1);
143143+ Rectangle::new(
144144+ Point::new((region.x + 1) as i32, (region.y + 1) as i32),
145145+ Size::new(fill_w, (region.h - 2) as u32),
146146+ )
147147+ .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
148148+ .draw(strip)
149149+ .unwrap();
150150+ }
151151+}
+56-29
src/apps/files.rs
···1515use crate::board::{SCREEN_H, SCREEN_W};
1616use crate::drivers::storage::DirEntry;
1717use crate::drivers::strip::StripBuffer;
1818+use crate::error::{Error, ErrorKind};
1819use crate::fonts;
1920use crate::kernel::KernelHandle;
2021use crate::ui::{Alignment, BitmapDynLabel, BitmapLabel, CONTENT_TOP, Region};
···4647 selected: usize,
4748 needs_load: bool,
4849 stale_cache: bool,
4949- error: Option<&'static str>,
5050+ error: Option<Error>,
5051 ui_fonts: fonts::UiFonts,
5152 list_y: u16,
52535354 title_scan_idx: usize,
5455 title_scanning: bool,
5656+ title_reload: bool,
5557}
56585759impl FilesApp {
···7072 list_y: CONTENT_TOP + 8 + uf.heading.line_height + HEADER_LIST_GAP,
7173 title_scan_idx: 0,
7274 title_scanning: false,
7575+ title_reload: false,
7376 }
7477 }
7578···98101 }
99102 }
100103101101- fn load_failed(&mut self, msg: &'static str) {
104104+ fn load_failed(&mut self, e: Error) {
102105 self.needs_load = false;
103103- self.error = Some(msg);
106106+ self.error = Some(e);
104107 self.count = 0;
105108 }
106109···177180}
178181179182impl App<AppId> for FilesApp {
180180- async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
183183+ fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
181184 self.scroll = 0;
182185 self.selected = 0;
183186 self.needs_load = true;
···200203201204 fn on_suspend(&mut self) {}
202205203203- async fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
206206+ fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
204207 ctx.mark_dirty(Region::new(
205208 0,
206209 CONTENT_TOP,
···217220 }
218221219222 let mut buf = [DirEntry::EMPTY; PAGE_SIZE];
220220- match k.sync_dir_page(self.scroll, &mut buf) {
223223+ match k.dir_page(self.scroll, &mut buf) {
221224 Ok(page) => {
222225 self.load_page(&buf[..page.count], page.total);
223226 }
···227230 }
228231 }
229232230230- ctx.mark_dirty(self.list_region());
231231- ctx.mark_dirty(STATUS_REGION);
233233+ if self.title_reload {
234234+ self.title_reload = false;
235235+ ctx.mark_dirty_coalesced(self.list_region());
236236+ ctx.mark_dirty_coalesced(STATUS_REGION);
237237+ } else {
238238+ ctx.mark_dirty(self.list_region());
239239+ ctx.mark_dirty(STATUS_REGION);
240240+ }
232241 return;
233242 }
234243···237246 self.title_scan_idx = dirty.next_idx;
238247 if dirty.resolved {
239248 self.needs_load = true;
249249+ self.title_reload = true;
240250 }
241251 } else {
242252 self.title_scanning = false;
···308318 .unwrap();
309319310320 if self.total > 0 {
311311- let mut status = BitmapDynLabel::<20>::new(STATUS_REGION, self.ui_fonts.body)
321321+ let mut status = BitmapDynLabel::<24>::new(STATUS_REGION, self.ui_fonts.body)
312322 .alignment(Alignment::CenterRight);
313323 let _ = write!(status, "{}/{}", self.scroll + self.selected + 1, self.total);
324324+ if self.title_scanning {
325325+ let _ = write!(status, " ...");
326326+ }
314327 status.draw(strip).unwrap();
315328 }
316329317317- if let Some(msg) = self.error {
318318- BitmapLabel::new(self.row_region(0), msg, self.ui_fonts.body)
319319- .alignment(Alignment::CenterLeft)
320320- .draw(strip)
321321- .unwrap();
330330+ if let Some(e) = self.error {
331331+ let mut label = BitmapDynLabel::<32>::new(self.row_region(0), self.ui_fonts.body)
332332+ .alignment(Alignment::CenterLeft);
333333+ let _ = core::fmt::Write::write_fmt(&mut label, format_args!("{}", e));
334334+ label.draw(strip).unwrap();
322335 return;
323336 }
324337···373386374387 log::info!("titles: scanning {} (idx {})", name, idx);
375388376376- let result = (|| -> Result<(), &'static str> {
377377- let file_size = k.sync_file_size(name)?;
389389+ let result = (|| -> crate::error::Result<()> {
390390+ let file_size = k.file_size(name)?;
378391 if file_size < 22 {
379379- return Err("too small");
392392+ return Err(Error::new(ErrorKind::InvalidData, "title_scan: too small"));
380393 }
381394382395 let tail_size = (file_size as usize).min(512);
383396 let tail_offset = file_size - tail_size as u32;
384397 let mut buf = [0u8; 512];
385385- let n = k.sync_read_chunk(name, tail_offset, &mut buf[..tail_size])?;
398398+ let n = k.read_chunk(name, tail_offset, &mut buf[..tail_size])?;
386399400400+ // ZipIndex::parse_eocd returns Result<_, &'static str>;
401401+ // the From<&'static str> impl on Error converts automatically via ?
387402 let (cd_offset, cd_size) = ZipIndex::parse_eocd(&buf[..n], file_size)?;
388403389404 let mut cd_buf = Vec::new();
390405 cd_buf
391406 .try_reserve_exact(cd_size as usize)
392392- .map_err(|_| "CD too large")?;
407407+ .map_err(|_| Error::new(ErrorKind::OutOfMemory, "title_scan: CD alloc"))?;
393408 cd_buf.resize(cd_size as usize, 0);
394409395410 let mut total = 0usize;
396411 while total < cd_buf.len() {
397397- let rd = k.sync_read_chunk(name, cd_offset + total as u32, &mut cd_buf[total..])?;
412412+ let rd = k.read_chunk(name, cd_offset + total as u32, &mut cd_buf[total..])?;
398413 if rd == 0 {
399399- return Err("CD truncated");
414414+ return Err(Error::new(
415415+ ErrorKind::InvalidData,
416416+ "title_scan: CD truncated",
417417+ ));
400418 }
401419 total += rd;
402420 }
···410428 let container = smol_epub::zip::extract_entry(
411429 zip.entry(ci),
412430 zip.entry(ci).local_offset,
413413- |off, b| k.sync_read_chunk(name, off, b),
431431+ |off, b| {
432432+ k.read_chunk(name, off, b)
433433+ .map_err(|e: Error| -> &'static str { e.into() })
434434+ },
414435 )?;
415436 let len = epub::parse_container(&container, &mut opf_path_buf)?;
416437 drop(container);
···419440 epub::find_opf_in_zip(&zip, &mut opf_path_buf)?
420441 };
421442422422- let opf_path =
423423- core::str::from_utf8(&opf_path_buf[..opf_path_len]).map_err(|_| "bad OPF path")?;
443443+ let opf_path = core::str::from_utf8(&opf_path_buf[..opf_path_len])
444444+ .map_err(|_| Error::new(ErrorKind::BadEncoding, "title_scan: OPF path"))?;
424445425446 let opf_idx = zip
426447 .find(opf_path)
427448 .or_else(|| zip.find_icase(opf_path))
428428- .ok_or("OPF not found")?;
449449+ .ok_or(Error::new(ErrorKind::NotFound, "title_scan: OPF entry"))?;
429450430451 let opf_data = smol_epub::zip::extract_entry(
431452 zip.entry(opf_idx),
432453 zip.entry(opf_idx).local_offset,
433433- |off, b| k.sync_read_chunk(name, off, b),
454454+ |off, b| {
455455+ k.read_chunk(name, off, b)
456456+ .map_err(|e: Error| -> &'static str { e.into() })
457457+ },
434458 )?;
435459436460 let opf_dir = opf_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
···441465442466 let title = meta.title_str();
443467 if title.is_empty() {
444444- return Err("no title in OPF");
468468+ return Err(Error::new(
469469+ ErrorKind::ParseFailed,
470470+ "title_scan: no title in OPF",
471471+ ));
445472 }
446473447474 log::info!("titles: {} -> \"{}\"", name, title);
448448- let _ = k.sync_save_title(name, title);
475475+ let _ = k.save_title(name, title);
449476 k.dir_cache_mut().set_entry_title(idx, title.as_bytes());
450477451478 Ok(())
452479 })();
453480454454- if let Err(e) = result {
481481+ if let Err(e) = &result {
455482 log::warn!("titles: {} failed: {}", name, e);
456483 }
457484
+4-4
src/apps/home.rs
···102102103103 pub fn load_recent(&mut self, k: &mut KernelHandle<'_>) {
104104 let mut buf = [0u8; 32];
105105- match k.sync_read_app_data_start(RECENT_FILE, &mut buf) {
105105+ match k.read_app_data_start(RECENT_FILE, &mut buf) {
106106 Ok((_, n)) if n > 0 => {
107107 let n = n.min(32);
108108 self.recent_book[..n].copy_from_slice(&buf[..n]);
···193193}
194194195195impl App<AppId> for HomeApp {
196196- async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
196196+ fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
197197 ctx.clear_message();
198198 self.state = HomeState::Menu;
199199 self.selected = 0;
···205205 ));
206206 }
207207208208- async fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
208208+ fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
209209 self.state = HomeState::Menu;
210210 self.selected = 0;
211211 self.needs_load_recent = true;
···221221 if self.needs_load_recent {
222222 let old_count = self.item_count;
223223 let mut buf = [0u8; 32];
224224- match k.sync_read_app_data_start(RECENT_FILE, &mut buf) {
224224+ match k.read_app_data_start(RECENT_FILE, &mut buf) {
225225 Ok((_, n)) if n > 0 => {
226226 let n = n.min(32);
227227 self.recent_book[..n].copy_from_slice(&buf[..n]);
···3434pub type Launcher = crate::kernel::app::Launcher<AppId>;
35353636pub use crate::kernel::app::{App, AppContext, PendingSetting, RECENT_FILE, Redraw};
3737+3838+// Unified error types — available to all app code as `crate::apps::Error` etc.
3939+pub use crate::kernel::{Error, ErrorKind, Result, ResultExt};
4040+4141+// Backward-compatible alias; old app code referencing `StorageError`
4242+// keeps compiling — it is now `type StorageError = Error`.
4343+pub use crate::kernel::StorageError;
+136-267
src/apps/reader/epub_pipeline.rs
···66use smol_epub::cache;
77use smol_epub::epub;
8899+use crate::error::{Error, ErrorKind};
910use crate::kernel::KernelHandle;
1011use crate::kernel::work_queue;
11121213use super::{BgCacheState, CHAPTER_CACHE_MAX, EOCD_TAIL, PAGE_BUF, ReaderApp, ZipIndex};
13141515+// one cell shared between reader and writer; safe because
1616+// stream_strip_entry_async never borrows both simultaneously
1717+struct CellReader<'a, 'k>(&'a RefCell<&'a mut KernelHandle<'k>>, &'a str);
1818+struct CellWriter<'a, 'k>(&'a RefCell<&'a mut KernelHandle<'k>>, &'a str, &'a str);
1919+2020+impl smol_epub::async_io::AsyncReadAt for CellReader<'_, '_> {
2121+ async fn read_at(&mut self, offset: u32, buf: &mut [u8]) -> Result<usize, &'static str> {
2222+ self.0
2323+ .borrow_mut()
2424+ .read_chunk(self.1, offset, buf)
2525+ .map_err(|e: Error| -> &'static str { e.into() })
2626+ }
2727+}
2828+2929+impl smol_epub::async_io::AsyncWriteChunk for CellWriter<'_, '_> {
3030+ async fn write_chunk(&mut self, data: &[u8]) -> Result<(), &'static str> {
3131+ self.0
3232+ .borrow_mut()
3333+ .append_app_subdir(self.1, self.2, data)
3434+ .map_err(|e: Error| -> &'static str { e.into() })
3535+ }
3636+}
3737+1438impl ReaderApp {
1515- pub(super) fn epub_init_zip(&mut self, k: &mut KernelHandle<'_>) -> Result<(), &'static str> {
3939+ pub(super) fn epub_init_zip(&mut self, k: &mut KernelHandle<'_>) -> crate::error::Result<()> {
1640 let (nb, nl) = self.name_copy();
1741 let name = core::str::from_utf8(&nb[..nl]).unwrap_or("");
18421919- let epub_size = k.sync_file_size(name)?;
4343+ let epub_size = k.file_size(name)?;
2044 if epub_size < 22 {
2121- return Err("epub: file too small");
4545+ return Err(Error::new(
4646+ ErrorKind::InvalidData,
4747+ "epub_init_zip: too small",
4848+ ));
2249 }
2350 self.epub_file_size = epub_size;
2451 self.epub_name_hash = cache::fnv1a(name.as_bytes());
···26532754 let tail_size = (epub_size as usize).min(EOCD_TAIL);
2855 let tail_offset = epub_size - tail_size as u32;
2929- let n = k.sync_read_chunk(name, tail_offset, &mut self.buf[..tail_size])?;
3030- let (cd_offset, cd_size) = ZipIndex::parse_eocd(&self.buf[..n], epub_size)?;
5656+ let n = k.read_chunk(name, tail_offset, &mut self.buf[..tail_size])?;
5757+ let (cd_offset, cd_size) = ZipIndex::parse_eocd(&self.buf[..n], epub_size)
5858+ .map_err(|_| Error::new(ErrorKind::ParseFailed, "epub_init_zip: EOCD"))?;
31593260 log::info!(
3361 "epub: CD at offset {} size {} ({} file bytes)",
···3967 let mut cd_buf = Vec::new();
4068 cd_buf
4169 .try_reserve_exact(cd_size as usize)
4242- .map_err(|_| "epub: CD too large for memory")?;
7070+ .map_err(|_| Error::new(ErrorKind::OutOfMemory, "epub_init_zip: CD alloc"))?;
4371 cd_buf.resize(cd_size as usize, 0);
4472 super::read_full(k, name, cd_offset, &mut cd_buf)?;
4573 self.zip.clear();
4646- self.zip.parse_central_directory(&cd_buf)?;
7474+ self.zip
7575+ .parse_central_directory(&cd_buf)
7676+ .map_err(|_| Error::new(ErrorKind::ParseFailed, "epub_init_zip: CD parse"))?;
4777 drop(cd_buf);
48784979 log::info!("epub: {} entries in ZIP", self.zip.count());
···5181 Ok(())
5282 }
53835454- pub(super) fn epub_init_opf(&mut self, k: &mut KernelHandle<'_>) -> Result<(), &'static str> {
8484+ pub(super) fn epub_init_opf(&mut self, k: &mut KernelHandle<'_>) -> crate::error::Result<()> {
5585 let (nb, nl) = self.name_copy();
5686 let name = core::str::from_utf8(&nb[..nl]).unwrap_or("");
57875888 let mut opf_path_buf = [0u8; epub::OPF_PATH_CAP];
5989 let opf_path_len = if let Some(container_idx) = self.zip.find("META-INF/container.xml") {
6060- let container_data = super::extract_zip_entry(k, name, &self.zip, container_idx)?;
6161- let len = epub::parse_container(&container_data, &mut opf_path_buf)?;
9090+ let container_data = super::extract_zip_entry(k, name, &self.zip, container_idx)
9191+ .map_err(|_| Error::new(ErrorKind::ReadFailed, "epub_init_opf: container read"))?;
9292+ let len = epub::parse_container(&container_data, &mut opf_path_buf).map_err(|_| {
9393+ Error::new(ErrorKind::ParseFailed, "epub_init_opf: container parse")
9494+ })?;
6295 drop(container_data);
6396 len
6497 } else {
6598 log::warn!("epub: no container.xml, scanning for .opf");
6666- epub::find_opf_in_zip(&self.zip, &mut opf_path_buf)?
9999+ epub::find_opf_in_zip(&self.zip, &mut opf_path_buf)
100100+ .map_err(|_| Error::new(ErrorKind::NotFound, "epub_init_opf: no .opf in zip"))?
67101 };
6810269103 let opf_path = core::str::from_utf8(&opf_path_buf[..opf_path_len])
7070- .map_err(|_| "epub: bad opf path")?;
104104+ .map_err(|_| Error::new(ErrorKind::BadEncoding, "epub_init_opf: OPF path"))?;
7110572106 log::info!("epub: OPF at {}", opf_path);
73107···75109 .zip
76110 .find(opf_path)
77111 .or_else(|| self.zip.find_icase(opf_path))
7878- .ok_or("epub: opf not found in zip")?;
7979- let opf_data = super::extract_zip_entry(k, name, &self.zip, opf_idx)?;
112112+ .ok_or(Error::new(ErrorKind::NotFound, "epub_init_opf: OPF entry"))?;
113113+ let opf_data = super::extract_zip_entry(k, name, &self.zip, opf_idx)
114114+ .map_err(|_| Error::new(ErrorKind::ReadFailed, "epub_init_opf: OPF read"))?;
8011581116 let opf_dir = opf_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
82117 epub::parse_opf(
···85120 &self.zip,
86121 &mut self.meta,
87122 &mut self.spine,
8888- )?;
123123+ )
124124+ .map_err(|_| Error::new(ErrorKind::ParseFailed, "epub_init_opf: OPF parse"))?;
8912590126 // defer TOC to NeedToc to avoid stack overflow while OPF is live
91127 self.toc_source = epub::find_toc_source(&opf_data, opf_dir, &self.zip);
···104140 self.title[..n].copy_from_slice(&self.meta.title[..n]);
105141 self.title_len = n;
106142107107- if let Err(e) = k.sync_save_title(name, self.meta.title_str()) {
143143+ if let Err(e) = k.save_title(name, self.meta.title_str()) {
108144 log::warn!("epub: failed to save title mapping: {}", e);
109145 }
110146 }
···117153 pub(super) fn epub_check_cache(
118154 &mut self,
119155 k: &mut KernelHandle<'_>,
120120- ) -> Result<bool, &'static str> {
156156+ ) -> crate::error::Result<bool> {
121157 let dir_buf = self.cache_dir;
122158 let dir = cache::dir_name_str(&dir_buf);
123159124160 // read into self.buf to avoid ~2 KB stack temporaries
125161 let meta_cap = cache::META_MAX_SIZE.min(self.buf.len());
126126- if let Ok(n) =
127127- k.sync_read_app_subdir_chunk(dir, cache::META_FILE, 0, &mut self.buf[..meta_cap])
162162+ if let Ok(n) = k.read_app_subdir_chunk(dir, cache::META_FILE, 0, &mut self.buf[..meta_cap])
128163 && let Ok(count) = cache::parse_cache_meta(
129164 &self.buf[..n],
130165 self.epub_file_size,
···142177 }
143178144179 log::info!("epub: building cache for {} chapters", self.spine.len());
145145- k.sync_ensure_app_subdir(dir)?;
180180+ k.ensure_app_subdir(dir)?;
146181 self.cache_chapter = 0;
147182 Ok(false)
148183 }
···150185 pub(super) fn epub_finish_cache(
151186 &mut self,
152187 k: &mut KernelHandle<'_>,
153153- ) -> Result<bool, &'static str> {
188188+ ) -> crate::error::Result<bool> {
154189 let dir_buf = self.cache_dir;
155190 let dir = cache::dir_name_str(&dir_buf);
156191 let spine_len = self.spine.len();
···162197 &self.chapter_sizes[..spine_len],
163198 &mut meta_buf,
164199 );
165165- k.sync_write_app_subdir(dir, cache::META_FILE, &meta_buf[..meta_len])?;
200200+ k.write_app_subdir(dir, cache::META_FILE, &meta_buf[..meta_len])?;
166201167202 self.chapters_cached = true;
168203 log::info!("epub: cache complete");
169204 Ok(false)
170205 }
171206172172- // synchronously cache a single chapter by index; skipped if already cached
173173- pub(super) fn epub_cache_single_chapter(
207207+ // async streaming chapter cache; used for both initial and background
208208+ // caching. decompresses, strips html, and writes chunks to sd without
209209+ // ever holding full xhtml in ram. yields between decompression
210210+ // iterations so the scheduler's select(run_background, input) can
211211+ // interrupt on user input (e.g. pressing back during book open)
212212+ pub(super) async fn epub_cache_chapter_async(
174213 &mut self,
175214 k: &mut KernelHandle<'_>,
176215 ch: usize,
177177- ) -> Result<(), &'static str> {
216216+ ) -> crate::error::Result<()> {
178217 if ch >= self.spine.len() || self.ch_cached[ch] {
179218 return Ok(());
180219 }
···183222 let dir = cache::dir_name_str(&dir_buf);
184223 let (nb, nl) = self.name_copy();
185224 let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or("");
186186-187225 let entry_idx = self.spine.items[ch] as usize;
188226 let entry = *self.zip.entry(entry_idx);
189189-190227 let ch_file = cache::chapter_file_name(ch as u16);
191228 let ch_str = cache::chapter_file_str(&ch_file);
192229193193- k.sync_write_app_subdir(dir, ch_str, &[])?;
194194- let text_size = {
195195- let k_cell = RefCell::new(&mut *k);
196196- cache::stream_strip_entry(
197197- &entry,
198198- entry.local_offset,
199199- |offset, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, offset, buf),
200200- |chunk| {
201201- k_cell
202202- .borrow_mut()
203203- .sync_append_app_subdir(dir, ch_str, chunk)
204204- },
205205- )?
206206- };
230230+ // truncate stale data before streaming begins
231231+ k.write_app_subdir(dir, ch_str, &[])?;
232232+233233+ let k_cell = RefCell::new(&mut *k);
234234+235235+ let mut reader = CellReader(&k_cell, epub_name);
236236+ let mut writer = CellWriter(&k_cell, dir, ch_str);
237237+238238+ let text_size = smol_epub::async_io::stream_strip_entry_async(
239239+ &entry,
240240+ entry.local_offset,
241241+ &mut reader,
242242+ &mut writer,
243243+ )
244244+ .await
245245+ .map_err(|msg| Error::from(msg).with_source("epub_cache_chapter_async: stream"))?;
207246208247 self.chapter_sizes[ch] = text_size;
209248 self.ch_cached[ch] = true;
210249211250 log::info!(
212212- "epub: sync-cached ch{}/{} = {} bytes",
251251+ "epub: cached ch{}/{} = {} bytes",
213252 ch,
214253 self.spine.len(),
215254 text_size
···217256 Ok(())
218257 }
219258220220- // extract chapter XHTML from ZIP and dispatch to worker for HTML stripping
221221- pub(super) fn epub_dispatch_chapter_strip(
222222- &mut self,
223223- k: &mut KernelHandle<'_>,
224224- ) -> Result<bool, &'static str> {
225225- let spine_len = self.spine.len();
226226-227227- // advance past chapters that were already sync-cached
228228- while (self.cache_chapter as usize) < spine_len
229229- && self.ch_cached[self.cache_chapter as usize]
230230- {
231231- self.cache_chapter += 1;
232232- }
233233-234234- // priority: sync-cache chapters adjacent to the reading position
235235- // before continuing the sequential scan, so forward/backward
236236- // chapter navigation is always instant
237237- let reading_ch = self.chapter as usize;
238238- for &adj in &[reading_ch + 1, reading_ch.saturating_sub(1)] {
239239- if adj < spine_len && adj != reading_ch && !self.ch_cached[adj] {
240240- log::info!(
241241- "epub: priority cache ch{} (adjacent to ch{})",
242242- adj,
243243- reading_ch,
244244- );
245245- if let Err(e) = self.epub_cache_single_chapter(k, adj) {
246246- log::warn!("epub: priority cache ch{} failed: {}", adj, e);
247247- }
248248- }
249249- }
250250-251251- let ch = self.cache_chapter as usize;
252252- if ch >= spine_len {
253253- return self.epub_finish_cache(k);
254254- }
255255-256256- // large chapters need ~2x their uncompressed size in heap
257257- // (extract Vec + strip output Vec simultaneously); on a 140 KB
258258- // heap anything over ~32 KB risks OOM in the worker; fall back
259259- // to the streaming pipeline which uses fixed ~51 KB overhead
260260- const ASYNC_THRESHOLD: u32 = 32768;
261261- let entry_idx = self.spine.items[ch] as usize;
262262- let uncomp = self.zip.entry(entry_idx).uncomp_size;
263263- if uncomp > ASYNC_THRESHOLD {
264264- log::info!(
265265- "epub: ch{}/{} large ({} bytes), sync-caching",
266266- ch,
267267- spine_len,
268268- uncomp,
269269- );
270270- self.epub_cache_single_chapter(k, ch)?;
271271- self.cache_chapter += 1;
272272- return Ok(true);
273273- }
274274-275275- let dir_buf = self.cache_dir;
276276- let dir = cache::dir_name_str(&dir_buf);
277277- let ch_file = cache::chapter_file_name(ch as u16);
278278- let ch_str = cache::chapter_file_str(&ch_file);
279279-280280- // truncate any stale data before the worker produces output
281281- k.sync_write_app_subdir(dir, ch_str, &[])?;
282282-283283- let (nb, nl) = self.name_copy();
284284- let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or("");
285285-286286- // extract full XHTML into memory; if OOM fall back to sync
287287- let xhtml = match super::extract_zip_entry(k, epub_name, &self.zip, entry_idx) {
288288- Ok(data) => data,
289289- Err(e) => {
290290- log::info!(
291291- "epub: ch{}/{} extract failed ({}), sync-caching",
292292- ch,
293293- spine_len,
294294- e,
295295- );
296296- self.epub_cache_single_chapter(k, ch)?;
297297- self.cache_chapter += 1;
298298- return Ok(true);
299299- }
300300- };
301301-302302- log::info!(
303303- "epub: dispatch ch{}/{} ({} bytes XHTML) to worker",
304304- ch,
305305- spine_len,
306306- xhtml.len()
307307- );
308308-309309- let task = work_queue::WorkTask::StripChapter {
310310- chapter_idx: ch as u16,
311311- xhtml,
312312- };
313313- if !work_queue::submit(self.work_gen, task) {
314314- return Err("cache: worker channel full");
315315- }
316316- Ok(true)
317317- }
318318-319319- // poll worker for a completed chapter-strip result
320320- pub(super) fn epub_recv_chapter_strip(
321321- &mut self,
322322- k: &mut KernelHandle<'_>,
323323- ) -> Result<Option<bool>, &'static str> {
324324- let result = match work_queue::try_recv() {
325325- Some(r) if r.is_current() => r,
326326- Some(_) => return Ok(None), // stale generation -- discard
327327- None => return Ok(None), // worker still busy
328328- };
329329-330330- match result.outcome {
331331- work_queue::WorkOutcome::ChapterReady { chapter_idx, text } => {
332332- let ch = chapter_idx as usize;
333333- let text_size = text.len() as u32;
334334-335335- // if the user sync-cached this chapter while the worker
336336- // was processing, skip the SD write
337337- if !self.ch_cached[ch] {
338338- let dir_buf = self.cache_dir;
339339- let dir = cache::dir_name_str(&dir_buf);
340340- let ch_file = cache::chapter_file_name(chapter_idx);
341341- let ch_str = cache::chapter_file_str(&ch_file);
342342-343343- k.sync_write_app_subdir(dir, ch_str, &text)?;
344344- self.chapter_sizes[ch] = text_size;
345345- }
346346- self.ch_cached[ch] = true;
347347- drop(text);
348348-349349- log::info!(
350350- "epub: cached ch{}/{} = {} bytes",
351351- ch,
352352- self.spine.len(),
353353- text_size
354354- );
355355-356356- self.cache_chapter += 1;
357357-358358- if (self.cache_chapter as usize) < self.spine.len() {
359359- Ok(Some(true))
360360- } else {
361361- self.epub_finish_cache(k)?;
362362- Ok(Some(false))
363363- }
364364- }
365365- work_queue::WorkOutcome::ChapterFailed { chapter_idx, error } => {
366366- let ch = chapter_idx as usize;
367367- log::warn!(
368368- "epub: worker failed ch{} ({}), falling back to sync",
369369- ch,
370370- error,
371371- );
372372- // streaming pipeline uses fixed ~51 KB overhead -- won't OOM
373373- if let Err(e) = self.epub_cache_single_chapter(k, ch) {
374374- log::warn!("epub: sync fallback also failed ch{}: {}", ch, e);
375375- }
376376- self.cache_chapter += 1;
377377-378378- if (self.cache_chapter as usize) < self.spine.len() {
379379- Ok(Some(true))
380380- } else {
381381- self.epub_finish_cache(k)?;
382382- Ok(Some(false))
383383- }
384384- }
385385- _ => {
386386- // unexpected result type -- discard and keep waiting
387387- log::warn!("epub: unexpected result while waiting for chapter strip");
388388- Ok(None)
389389- }
390390- }
391391- }
392392-393259 pub(super) fn epub_index_chapter(&mut self) {
394260 self.reset_paging();
395395- // force reload -- ch_cache may hold a different chapter's data
261261+ // force reload; ch_cache may hold a different chapter's data
396262 // with the same byte count (try_cache_chapter only checks len)
397263 self.ch_cache = Vec::new();
398264 let ch = self.chapter as usize;
···446312 let mut pos = 0usize;
447313 while pos < ch_size {
448314 let chunk = (ch_size - pos).min(PAGE_BUF);
449449- match k.sync_read_app_subdir_chunk(
315315+ match k.read_app_subdir_chunk(
450316 dir,
451317 ch_str,
452318 pos as u32,
···470336 true
471337 }
472338473473- // run one step of background caching; returns true if self.buf was dirtied
474474- pub(super) fn bg_cache_step(&mut self, k: &mut KernelHandle<'_>) -> bool {
339339+ // run one step of background caching; async because CacheChapter
340340+ // awaits epub_cache_chapter_async which yields during deflate
341341+ pub(super) async fn bg_cache_step(&mut self, k: &mut KernelHandle<'_>) {
475342 match self.bg_cache {
476343 BgCacheState::CacheChapter => {
477477- match self.epub_dispatch_chapter_strip(k) {
478478- Ok(true) => self.bg_cache = BgCacheState::WaitChapter,
479479- Ok(false) => {
480480- // all chapters cached; start image scan from
481481- // the current reading chapter
482482- self.img_cache_ch = self.chapter;
483483- self.img_cache_offset = 0;
484484- self.img_scan_wrapped = false;
485485- self.bg_cache = BgCacheState::CacheImage;
486486- }
487487- Err(e) => {
488488- log::warn!("bg: ch dispatch failed: {}, skipping", e);
489489- self.cache_chapter += 1;
490490- // stay in CacheChapter; next tick tries the next one
344344+ let spine_len = self.spine.len();
345345+346346+ // skip chapters already cached
347347+ while (self.cache_chapter as usize) < spine_len
348348+ && self.ch_cached[self.cache_chapter as usize]
349349+ {
350350+ self.cache_chapter += 1;
351351+ }
352352+353353+ // priority: cache chapters adjacent to reading position
354354+ // before continuing the sequential scan; forward/backward
355355+ // nav stays instant
356356+ let reading_ch = self.chapter as usize;
357357+ for &adj in &[reading_ch + 1, reading_ch.saturating_sub(1)] {
358358+ if adj < spine_len && adj != reading_ch && !self.ch_cached[adj] {
359359+ log::info!(
360360+ "epub: priority cache ch{} (adjacent to ch{})",
361361+ adj,
362362+ reading_ch,
363363+ );
364364+ if let Err(e) = self.epub_cache_chapter_async(k, adj).await {
365365+ log::warn!("epub: priority ch{} failed: {}", adj, e);
366366+ }
491367 }
492368 }
493493- false
494494- }
495495- BgCacheState::WaitChapter => {
496496- match self.epub_recv_chapter_strip(k) {
497497- Ok(Some(true)) => {
498498- // after caching a chapter, try dispatching a nearby
499499- // image before continuing with the next chapter
369369+370370+ let ch = self.cache_chapter as usize;
371371+ if ch >= spine_len {
372372+ let _ = self.epub_finish_cache(k);
373373+ self.img_cache_ch = self.chapter;
374374+ self.img_cache_offset = 0;
375375+ self.img_scan_wrapped = false;
376376+ self.bg_cache = BgCacheState::CacheImage;
377377+ return;
378378+ }
379379+380380+ match self.epub_cache_chapter_async(k, ch).await {
381381+ Ok(()) => {
382382+ self.cache_chapter += 1;
383383+ // try nearby image dispatch before next chapter
500384 if self.try_dispatch_nearby_image(k) {
501385 self.bg_cache = BgCacheState::WaitNearbyImage;
502502- } else {
503503- self.bg_cache = BgCacheState::CacheChapter;
504386 }
505505- }
506506- Ok(Some(false)) => {
507507- self.img_cache_ch = self.chapter;
508508- self.img_cache_offset = 0;
509509- self.img_scan_wrapped = false;
510510- self.bg_cache = BgCacheState::CacheImage;
387387+ // else stay in CacheChapter
511388 }
512512- Ok(None) => {}
513389 Err(e) => {
514514- log::warn!("bg: ch recv failed: {}, continuing", e);
515515- self.bg_cache = BgCacheState::CacheChapter;
390390+ log::warn!("bg: ch{} failed: {}, skipping", ch, e);
391391+ self.cache_chapter += 1;
516392 }
517393 }
518518- false
519394 }
395395+520396 BgCacheState::WaitNearbyImage => {
521397 match self.epub_recv_image_result(k) {
522398 Ok(Some(_)) => {
···532408 self.bg_cache = BgCacheState::CacheChapter;
533409 }
534410 }
535535- false
536411 }
537412 BgCacheState::CacheImage => {
538413 match self.epub_find_and_dispatch_image(k) {
···549424 // stay in CacheImage; next tick scans for the next one
550425 }
551426 }
552552- // image scanning uses the prefetch buffer, leaving
553553- // self.buf (current page data) untouched
554554- false
555427 }
556556- BgCacheState::WaitImage => {
557557- match self.epub_recv_image_result(k) {
558558- Ok(Some(_)) => self.bg_cache = BgCacheState::CacheImage,
559559- Ok(None) => {}
560560- Err(e) => {
561561- log::warn!("bg: image recv error: {}", e);
562562- self.bg_cache = BgCacheState::CacheImage;
563563- }
428428+ BgCacheState::WaitImage => match self.epub_recv_image_result(k) {
429429+ Ok(Some(_)) => self.bg_cache = BgCacheState::CacheImage,
430430+ Ok(None) => {}
431431+ Err(e) => {
432432+ log::warn!("bg: image recv error: {}", e);
433433+ self.bg_cache = BgCacheState::CacheImage;
564434 }
565565- false
566566- }
567567- BgCacheState::Idle => false,
435435+ },
436436+ BgCacheState::Idle => {}
568437 }
569438 }
570439}
+97-45
src/apps/reader/images.rs
···1717use smol_epub::html_strip::{IMG_REF, MARKER};
1818use smol_epub::zip::{self, ZipIndex};
19192020+use crate::error::{Error, ErrorKind};
2021use crate::kernel::KernelHandle;
2122use crate::kernel::work_queue;
2223···113114 return;
114115 }
115116117117+ // background precache will decode this image eventually;
118118+ // skip the blocking inline decode so the page renders
119119+ // immediately without it. once the user is in Ready state
120120+ // defer_image_decode is cleared and a page revisit will
121121+ // either hit the cache or do the full decode.
122122+ if self.defer_image_decode {
123123+ log::info!(
124124+ "reader: deferring image decode (bg will handle {})",
125125+ full_path
126126+ );
127127+ return;
128128+ }
129129+116130 let zip_idx = match self
117131 .zip
118132 .find(full_path)
···131145132146 let data_offset = {
133147 let mut hdr = [0u8; 30];
134134- if k.sync_read_chunk(epub_name, entry.local_offset, &mut hdr)
148148+ if k.read_chunk(epub_name, entry.local_offset, &mut hdr)
135149 .is_err()
136150 {
137151 log::warn!("reader: failed to read ZIP local header");
···157171 } else if entry.method == zip::METHOD_STORED {
158172 let mut magic = [0u8; 8];
159173 let n = k
160160- .sync_read_chunk(epub_name, data_offset, &mut magic)
174174+ .read_chunk(epub_name, data_offset, &mut magic)
161175 .unwrap_or(0);
162176 (
163177 n >= 2 && magic[0] == 0xFF && magic[1] == 0xD8,
···180194181195 let do_decode = |k_ref: &mut KernelHandle<'_>| -> Result<DecodedImage, &'static str> {
182196 let k_cell = RefCell::new(k_ref);
197197+ let read_err = |e: Error| -> &'static str { e.into() };
183198 if is_jpeg && entry.method == zip::METHOD_STORED {
184199 smol_epub::jpeg::decode_jpeg_sd(
185185- |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf),
200200+ |off, buf| {
201201+ k_cell
202202+ .borrow_mut()
203203+ .read_chunk(epub_name, off, buf)
204204+ .map_err(read_err)
205205+ },
186206 data_offset,
187207 entry.uncomp_size,
188208 TEXT_W as u16,
···190210 )
191211 } else if is_jpeg {
192212 smol_epub::jpeg::decode_jpeg_deflate_sd(
193193- |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf),
213213+ |off, buf| {
214214+ k_cell
215215+ .borrow_mut()
216216+ .read_chunk(epub_name, off, buf)
217217+ .map_err(read_err)
218218+ },
194219 data_offset,
195220 entry.comp_size,
196221 entry.uncomp_size,
···199224 )
200225 } else if entry.method == zip::METHOD_STORED {
201226 smol_epub::png::decode_png_sd(
202202- |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf),
227227+ |off, buf| {
228228+ k_cell
229229+ .borrow_mut()
230230+ .read_chunk(epub_name, off, buf)
231231+ .map_err(read_err)
232232+ },
203233 data_offset,
204234 entry.uncomp_size,
205235 TEXT_W as u16,
···207237 )
208238 } else {
209239 smol_epub::png::decode_png_deflate_sd(
210210- |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf),
240240+ |off, buf| {
241241+ k_cell
242242+ .borrow_mut()
243243+ .read_chunk(epub_name, off, buf)
244244+ .map_err(read_err)
245245+ },
211246 data_offset,
212247 entry.comp_size,
213248 TEXT_W as u16,
···263298 k: &mut KernelHandle<'_>,
264299 ch: usize,
265300 start_offset: usize,
266266- ) -> Result<ScanResult, &'static str> {
301301+ ) -> crate::error::Result<ScanResult> {
267302 if ch >= cache::MAX_CACHE_CHAPTERS || !self.ch_cached[ch] {
268303 return Ok(ScanResult::NoneFound);
269304 }
···285320 let mut offset = start_offset;
286321 while offset < ch_size {
287322 let read_len = PAGE_BUF.min(ch_size - offset);
288288- let n = k.sync_read_app_subdir_chunk(
323323+ let n = k.read_app_subdir_chunk(
289324 dir,
290325 ch_str,
291326 offset as u32,
···341376 let resume = (offset + path_start + path_len) as u32;
342377343378 // already cached or skip-marked
344344- if k.sync_file_size_app_subdir(dir, img_file).is_ok() {
379379+ if k.file_size_app_subdir(dir, img_file).is_ok() {
345380 i = path_start + path_len;
346381 continue;
347382 }
···351386352387 if !is_jpeg && !is_png {
353388 log::info!("precache: skip unsupported: {}", full_path);
354354- let _ = k.sync_write_app_subdir(dir, img_file, &[]);
389389+ let _ = k.write_app_subdir(dir, img_file, &[]);
355390 i = path_start + path_len;
356391 continue;
357392 }
···397432 }
398433 Err(e) => {
399434 log::warn!("precache: streaming failed: {}", e);
400400- let _ = k.sync_write_app_subdir(dir, img_file, &[]);
435435+ let _ = k.write_app_subdir(dir, img_file, &[]);
401436 }
402437 }
403438 return Ok(ScanResult::DecodedInline {
···405440 });
406441 }
407442443443+ // wait for worker to have capacity before expensive
444444+ // extraction; caller sees Dispatched + worker busy
445445+ // and transitions to WaitImage, retrying after drain
446446+ if !work_queue::is_idle() {
447447+ return Ok(ScanResult::Dispatched {
448448+ resume_offset: (offset + i) as u32,
449449+ });
450450+ }
451451+408452 // small images: extract to memory for worker dispatch
409453 let data = match super::extract_zip_entry(k, epub_name, &self.zip, zip_idx) {
410454 Ok(d) => d,
411455 Err(e) => {
412456 log::warn!("precache: extract failed: {}", e);
413413- let _ = k.sync_write_app_subdir(dir, img_file, &[]);
457457+ let _ = k.write_app_subdir(dir, img_file, &[]);
414458 i = path_start + path_len;
415459 continue;
416460 }
···430474 resume_offset: resume,
431475 });
432476 }
433433- return Err("cache: worker channel full");
477477+ // queue full despite idle check; skip this image,
478478+ // it will be decoded on demand if the user views it
479479+ log::warn!("precache: worker queue full, skipping {}", full_path);
480480+ i = path_start + path_len;
481481+ continue;
434482 }
435483436484 // advance with overlap so markers at chunk boundaries are not missed
···449497 pub(super) fn epub_find_and_dispatch_image(
450498 &mut self,
451499 k: &mut KernelHandle<'_>,
452452- ) -> Result<bool, &'static str> {
500500+ ) -> crate::error::Result<bool> {
453501 let spine_len = self.spine.len();
454502455503 while (self.img_cache_ch as usize) < spine_len {
···493541 pub(super) fn epub_recv_image_result(
494542 &mut self,
495543 k: &mut KernelHandle<'_>,
496496- ) -> Result<Option<bool>, &'static str> {
544544+ ) -> crate::error::Result<Option<bool>> {
497545 let result = match work_queue::try_recv() {
498546 Some(r) if r.is_current() => r,
499499- Some(_) => return Ok(None), // stale generation -- discard
547547+ Some(_) => return Ok(None), // stale generation; discard
500548 None => return Ok(None),
501549 };
502550···524572 log::warn!("precache: image {:#010X} failed: {}", path_hash, error);
525573 Ok(Some(true))
526574 }
527527- _ => {
528528- log::warn!("precache: unexpected result while waiting for image decode");
529529- Ok(None)
530530- }
531575 }
532576 }
533577···602646 is_jpeg: bool,
603647 max_w: u16,
604648 max_h: u16,
605605-) -> Result<DecodedImage, &'static str> {
649649+) -> crate::error::Result<DecodedImage> {
606650 let mut hdr = [0u8; 30];
607607- k.sync_read_chunk(epub_name, entry.local_offset, &mut hdr)
608608- .map_err(|_| "read local header failed")?;
609609- let skip = ZipIndex::local_header_data_skip(&hdr)?;
651651+ k.read_chunk(epub_name, entry.local_offset, &mut hdr)?;
652652+ let skip = ZipIndex::local_header_data_skip(&hdr)
653653+ .map_err(|_| Error::new(ErrorKind::ParseFailed, "decode_image: local header"))?;
610654 let data_offset = entry.local_offset + skip;
611655612612- if is_jpeg && entry.method == zip::METHOD_STORED {
656656+ let read_err = |_: Error| -> &'static str { "read failed" };
657657+658658+ let result = if is_jpeg && entry.method == zip::METHOD_STORED {
613659 smol_epub::jpeg::decode_jpeg_sd(
614614- |off, buf| k.sync_read_chunk(epub_name, off, buf),
660660+ |off, buf| k.read_chunk(epub_name, off, buf).map_err(read_err),
615661 data_offset,
616662 entry.uncomp_size,
617663 max_w,
···619665 )
620666 } else if is_jpeg {
621667 smol_epub::jpeg::decode_jpeg_deflate_sd(
622622- |off, buf| k.sync_read_chunk(epub_name, off, buf),
668668+ |off, buf| k.read_chunk(epub_name, off, buf).map_err(read_err),
623669 data_offset,
624670 entry.comp_size,
625671 entry.uncomp_size,
···628674 )
629675 } else if entry.method == zip::METHOD_STORED {
630676 smol_epub::png::decode_png_sd(
631631- |off, buf| k.sync_read_chunk(epub_name, off, buf),
677677+ |off, buf| k.read_chunk(epub_name, off, buf).map_err(read_err),
632678 data_offset,
633679 entry.uncomp_size,
634680 max_w,
···636682 )
637683 } else {
638684 smol_epub::png::decode_png_deflate_sd(
639639- |off, buf| k.sync_read_chunk(epub_name, off, buf),
685685+ |off, buf| k.read_chunk(epub_name, off, buf).map_err(read_err),
640686 data_offset,
641687 entry.comp_size,
642688 max_w,
643689 max_h,
644690 )
645645- }
691691+ };
692692+ result.map_err(|msg| Error::from(msg).with_source("decode_image_streaming"))
646693}
647694648695pub(super) fn load_cached_image(
649696 k: &mut KernelHandle<'_>,
650697 dir: &str,
651698 name: &str,
652652-) -> Result<DecodedImage, &'static str> {
653653- let size = k
654654- .sync_file_size_app_subdir(dir, name)
655655- .map_err(|_| "no cache file")?;
699699+) -> crate::error::Result<DecodedImage> {
700700+ let size = k.file_size_app_subdir(dir, name)?;
656701 if size < 5 {
657657- return Err("cache file too small");
702702+ return Err(Error::new(
703703+ ErrorKind::InvalidData,
704704+ "load_cached_image: too small",
705705+ ));
658706 }
659707 let mut header = [0u8; 4];
660660- k.sync_read_app_subdir_chunk(dir, name, 0, &mut header)
661661- .map_err(|_| "read header failed")?;
708708+ k.read_app_subdir_chunk(dir, name, 0, &mut header)?;
662709 let width = u16::from_le_bytes([header[0], header[1]]);
663710 let height = u16::from_le_bytes([header[2], header[3]]);
664711 if width == 0 || height == 0 {
665665- return Err("zero dimensions in cache");
712712+ return Err(Error::new(
713713+ ErrorKind::InvalidData,
714714+ "load_cached_image: zero dimensions",
715715+ ));
666716 }
667717 let stride = (width as usize).div_ceil(8);
668718 let data_len = stride * height as usize;
669719 if size as usize != 4 + data_len {
670670- return Err("cache size mismatch");
720720+ return Err(Error::new(
721721+ ErrorKind::InvalidData,
722722+ "load_cached_image: size mismatch",
723723+ ));
671724 }
672725 let mut data = Vec::new();
673726 data.try_reserve_exact(data_len)
674674- .map_err(|_| "OOM for cached image")?;
727727+ .map_err(|_| Error::new(ErrorKind::OutOfMemory, "load_cached_image"))?;
675728 data.resize(data_len, 0);
676676- k.sync_read_app_subdir_chunk(dir, name, 4, &mut data)
677677- .map_err(|_| "read data failed")?;
729729+ k.read_app_subdir_chunk(dir, name, 4, &mut data)?;
678730 Ok(DecodedImage {
679731 width,
680732 height,
···688740 dir: &str,
689741 name: &str,
690742 img: &DecodedImage,
691691-) -> Result<(), &'static str> {
743743+) -> crate::error::Result<()> {
692744 let mut header = [0u8; 4];
693745 header[0..2].copy_from_slice(&img.width.to_le_bytes());
694746 header[2..4].copy_from_slice(&img.height.to_le_bytes());
695695- k.sync_write_app_subdir(dir, name, &header)?;
696696- k.sync_append_app_subdir(dir, name, &img.data)?;
747747+ k.write_app_subdir(dir, name, &header)?;
748748+ k.append_app_subdir(dir, name, &img.data)?;
697749 Ok(())
698750}
+123-50
src/apps/reader/mod.rs
···2323use crate::board::action::{Action, ActionEvent};
2424use crate::board::{SCREEN_H, SCREEN_W};
2525use crate::drivers::strip::StripBuffer;
2626+use crate::error::{Error, ErrorKind};
2627use crate::fonts;
2728use crate::kernel::KernelHandle;
2829use crate::kernel::QuickAction;
2930use crate::kernel::bookmarks;
3031use crate::kernel::work_queue;
3131-use crate::ui::{Alignment, BUTTON_BAR_H, CONTENT_TOP, Region, StackFmt};
3232+use crate::ui::{Alignment, BUTTON_BAR_H, CONTENT_TOP, Region, StackFmt, draw_progress_bar};
3233use smol_epub::DecodedImage;
3334use smol_epub::cache;
3435use smol_epub::epub::{self, EpubMeta, EpubSpine, EpubToc, TocSource};
···7980);
80818182pub(super) const LOADING_REGION: Region = Region::new(MARGIN, TEXT_Y, 464, 20);
8383+pub(super) const LOADING_BAR_REGION: Region = Region::new(MARGIN, TEXT_Y + 26, 200, 8);
82848385pub const QA_FONT_SIZE: u8 = 1;
8486pub(super) const QA_PREV_CHAPTER: u8 = 3;
···87898890pub(super) const QA_MAX: usize = 4;
89919090-#[derive(Clone, Copy, PartialEq)]
9292+#[derive(Clone, Copy, PartialEq, Debug)]
9193pub(super) enum State {
9294 NeedBookmark,
9395 NeedInit,
···101103 Error,
102104}
103105106106+impl State {
107107+ pub(super) fn loading_pct(self) -> u8 {
108108+ match self {
109109+ Self::NeedBookmark => 0,
110110+ Self::NeedInit => 5,
111111+ Self::NeedOpf => 15,
112112+ Self::NeedToc => 30,
113113+ Self::NeedCache => 45,
114114+ Self::NeedIndex => 70,
115115+ Self::NeedPage => 90,
116116+ Self::Ready | Self::ShowToc | Self::Error => 100,
117117+ }
118118+ }
119119+}
120120+104121// background caching progress, runs independently of the reading
105122// state so the user can read while chapters/images are cached
106123#[derive(Clone, Copy, PartialEq)]
···108125 // nothing to do
109126 Idle,
110127 CacheChapter,
111111- WaitChapter,
112128 WaitNearbyImage,
113129 CacheImage,
114130 WaitImage,
···190206 pub(super) prefetch_page: usize,
191207192208 pub(super) state: State,
193193- pub(super) error: Option<&'static str>,
209209+ pub(super) error: Option<Error>,
194210 pub(super) show_position: bool,
195211196212 pub(super) is_epub: bool,
···218234 pub(super) ch_cache: Vec<u8>,
219235 pub(super) page_img: Option<DecodedImage>,
220236 pub(super) fullscreen_img: bool,
237237+ pub(super) defer_image_decode: bool,
221238 pub(super) toc: EpubToc,
222239 pub(super) toc_source: Option<TocSource>,
223240 pub(super) toc_selected: usize,
···289306290307 page_img: None,
291308 fullscreen_img: false,
309309+ defer_image_decode: false,
292310293311 toc: EpubToc::new(),
294312 toc_source: None,
···325343 self.is_epub && self.bg_cache != BgCacheState::Idle
326344 }
327345328328- // run one step of background caching while suspended
346346+ // run one step of image work queue polling while suspended;
347347+ // chapter caching is async and only runs during active background,
348348+ // so this only handles the sync image recv states
329349 pub fn bg_work_tick(&mut self, k: &mut KernelHandle<'_>) {
330330- if self.bg_cache != BgCacheState::Idle {
331331- self.bg_cache_step(k);
350350+ match self.bg_cache {
351351+ BgCacheState::WaitNearbyImage => match self.epub_recv_image_result(k) {
352352+ Ok(Some(_)) => {
353353+ if !self.try_dispatch_nearby_image(k) {
354354+ self.bg_cache = BgCacheState::CacheChapter;
355355+ }
356356+ }
357357+ Ok(None) => {}
358358+ Err(e) => {
359359+ log::warn!("bg: nearby image error (suspended): {}", e);
360360+ self.bg_cache = BgCacheState::CacheChapter;
361361+ }
362362+ },
363363+ BgCacheState::WaitImage => match self.epub_recv_image_result(k) {
364364+ Ok(Some(_)) => self.bg_cache = BgCacheState::CacheImage,
365365+ Ok(None) => {}
366366+ Err(e) => {
367367+ log::warn!("bg: image recv error (suspended): {}", e);
368368+ self.bg_cache = BgCacheState::CacheImage;
369369+ }
370370+ },
371371+ _ => {}
332372 }
333373 }
334374···468508 name: &str,
469509 offset: u32,
470510 buf: &mut [u8],
471471-) -> Result<(), &'static str> {
511511+) -> crate::error::Result<()> {
472512 let mut total = 0usize;
473513 while total < buf.len() {
474474- let n = k.sync_read_chunk(name, offset + total as u32, &mut buf[total..])?;
514514+ let n = k.read_chunk(name, offset + total as u32, &mut buf[total..])?;
475515 if n == 0 {
476476- return Err("epub: unexpected EOF");
516516+ return Err(Error::new(
517517+ ErrorKind::ReadFailed,
518518+ "read_full: unexpected EOF",
519519+ ));
477520 }
478521 total += n;
479522 }
···491534 let entry = zip_index.entry(entry_idx);
492535 let k = RefCell::new(k);
493536 zip::extract_entry(entry, entry.local_offset, |offset, buf| {
494494- k.borrow_mut().sync_read_chunk(name, offset, buf)
537537+ k.borrow_mut()
538538+ .read_chunk(name, offset, buf)
539539+ .map_err(|e: Error| -> &'static str { e.into() })
495540 })
496541}
497542···523568}
524569525570impl App<AppId> for ReaderApp {
526526- async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
571571+ fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
527572 let msg = ctx.message();
528573 let len = msg.len().min(32);
529574 self.filename[..len].copy_from_slice(&msg[..len]);
···549594 self.chapter = 0;
550595 self.error = None;
551596 self.show_position = false;
597597+ self.defer_image_decode = true;
552598 self.goto_last_page = false;
553599 self.restore_offset = None;
554600···589635 // task runs independently and our work_gen stays valid
590636 }
591637592592- async fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
638638+ fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {
593639 // Restore our generation so the worker considers in-flight
594640 // results current again (another app may have submitted work
595641 // under a different generation while we were suspended).
···616662 State::NeedBookmark => {
617663 self.bookmark_load(k.bookmark_cache());
618664619619- let _ = k.sync_write_app_data(RECENT_FILE, &self.filename[..self.filename_len]);
665665+ let _ = k.write_app_data(RECENT_FILE, &self.filename[..self.filename_len]);
620666621667 if self.is_epub {
622668 self.zip.clear();
···633679634680 State::NeedInit => match self.epub_init_zip(k) {
635681 Ok(()) => {
636636- self.state = State::NeedOpf; // yield; CD heap freed
682682+ self.state = State::NeedOpf;
683683+ ctx.mark_dirty(LOADING_BAR_REGION);
637684 }
638685 Err(e) => {
639686 log::info!("reader: epub init (zip) failed: {}", e);
···645692646693 State::NeedOpf => match self.epub_init_opf(k) {
647694 Ok(()) => {
648648- self.state = State::NeedToc; // yield; OPF heap freed
695695+ self.state = State::NeedToc;
696696+ ctx.mark_dirty(LOADING_BAR_REGION);
649697 }
650698 Err(e) => {
651699 log::info!("reader: epub init (opf) failed: {}", e);
···684732 );
685733 log::info!("epub: TOC has {} entries", self.toc.len());
686734 }
687687- Err(e) => {
688688- log::warn!("epub: failed to read TOC: {}", e);
735735+ Err(_e) => {
736736+ log::warn!("epub: failed to read TOC");
689737 }
690738 }
691739 }
692740 self.rebuild_quick_actions();
693741 self.state = State::NeedCache;
694694- continue;
742742+ // break instead of continue so the progress bar
743743+ // renders before potentially heavy chapter caching;
744744+ // for cached books this mark coalesces with Ready
745745+ // because both complete during the initial waveform
746746+ ctx.mark_dirty(LOADING_BAR_REGION);
695747 }
696748697749 State::NeedCache => match self.epub_check_cache(k) {
···700752 continue;
701753 }
702754 Ok(false) => {
703703- // Cache only the current chapter synchronously
704704- // so the user can start reading immediately.
755755+ // cache the current chapter; async version yields
756756+ // during deflate so the scheduler's select can
757757+ // interrupt if the user presses back
705758 let ch = self.chapter as usize;
706706- match self.epub_cache_single_chapter(k, ch) {
759759+ match self.epub_cache_chapter_async(k, ch).await {
707760 Ok(()) => {
708761 self.chapters_cached = true;
709762 self.cache_chapter = 0;
710763711711- // Eagerly dispatch nearby images to
764764+ // eagerly dispatch nearby images to
712765 // the worker so they decode while the
713713- // user reads the first page. The
714714- // worker is idle at this point so the
715715- // dispatch is immediate.
766766+ // user reads the first page
716767 if self.try_dispatch_nearby_image(k) {
717768 self.bg_cache = BgCacheState::WaitNearbyImage;
718769 } else {
···723774 continue;
724775 }
725776 Err(e) => {
726726- log::info!("reader: sync cache ch{} failed: {}", ch, e);
777777+ log::info!("reader: cache ch{} failed: {}", ch, e);
727778 self.error = Some(e);
728779 self.state = State::Error;
729780 ctx.mark_dirty(PAGE_REGION);
···739790 },
740791741792 State::NeedIndex => {
742742- // Ensure the target chapter is cached before
793793+ // ensure the target chapter is cached before
743794 // indexing (it may not be if background caching
744744- // hasn't reached it yet).
795795+ // hasn't reached it yet)
745796 if self.is_epub
746797 && self.chapters_cached
747798 && !self.ch_cached[self.chapter as usize]
748799 {
749749- if let Err(e) = self.epub_cache_single_chapter(k, self.chapter as usize) {
800800+ // async version yields during deflate so the
801801+ // scheduler's select can interrupt on input
802802+ if let Err(e) = self
803803+ .epub_cache_chapter_async(k, self.chapter as usize)
804804+ .await
805805+ {
750806 self.error = Some(e);
751807 self.state = State::Error;
752808 ctx.mark_dirty(PAGE_REGION);
···766822 if want_last {
767823 match self.scan_to_last_page(k) {
768824 Ok(()) => {
825825+ self.defer_image_decode = false;
769826 self.state = State::Ready;
770827 ctx.mark_dirty(PAGE_REGION);
771828 }
···803860 self.page += 1;
804861 }
805862 if self.state != State::Error {
863863+ self.defer_image_decode = false;
806864 self.state = State::Ready;
807865 ctx.mark_dirty(PAGE_REGION);
808866 }
809867 } else {
810868 match self.load_and_prefetch(k) {
811869 Ok(()) => {
870870+ self.defer_image_decode = false;
812871 self.state = State::Ready;
813872 ctx.mark_dirty(PAGE_REGION);
814873 }
···827886 break;
828887 }
829888830830- // background caching (runs while the user reads)
831831- // runs in any stable state -- page turns momentarily leave
832832- // Ready, but background work resumes on the next tick
833833- if matches!(self.state, State::Ready | State::ShowToc)
834834- && self.bg_cache != BgCacheState::Idle
889889+ // background caching; runs whenever the page content is
890890+ // settled and there is work to do. NeedIndex is included so
891891+ // adjacent-chapter caching can overlap with page indexing
892892+ // after a chapter jump. the scheduler wraps run_background
893893+ // in select(run_background, input) so every .await inside
894894+ // bg_cache_step is interruptible by user input.
895895+ if matches!(
896896+ self.state,
897897+ State::Ready | State::ShowToc | State::NeedIndex | State::NeedPage
898898+ ) && self.bg_cache != BgCacheState::Idle
835899 {
836836- self.bg_cache_step(k);
900900+ self.bg_cache_step(k).await;
837901 }
838902 }
839903···842906 match event {
843907 ActionEvent::Press(Action::Back) => {
844908 self.state = State::Ready;
845845- ctx.mark_dirty(PAGE_REGION);
909909+ ctx.mark_dirty_immediate(PAGE_REGION);
846910 return Transition::None;
847911 }
848912 ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => {
···858922 if self.toc_selected >= self.toc_scroll + vis {
859923 self.toc_scroll = self.toc_selected + 1 - vis;
860924 }
861861- ctx.mark_dirty(PAGE_REGION);
925925+ ctx.mark_dirty_immediate(PAGE_REGION);
862926 }
863927 return Transition::None;
864928 }
···877941 if self.toc_selected < self.toc_scroll {
878942 self.toc_scroll = self.toc_selected;
879943 }
880880- ctx.mark_dirty(PAGE_REGION);
944944+ ctx.mark_dirty_immediate(PAGE_REGION);
881945 }
882946 return Transition::None;
883947 }
···893957 self.page = 0;
894958 self.goto_last_page = false;
895959 self.state = State::NeedIndex;
896896- ctx.mark_dirty(PAGE_REGION);
960960+ ctx.mark_dirty_immediate(PAGE_REGION);
897961 } else {
898962 log::warn!(
899963 "toc: entry \"{}\" unresolved (spine_idx=0xFFFF), ignoring",
900964 entry.title_str()
901965 );
902966 self.state = State::Ready;
903903- ctx.mark_dirty(PAGE_REGION);
967967+ ctx.mark_dirty_immediate(PAGE_REGION);
904968 }
905969 return Transition::None;
906970 }
···917981 self.show_position = true;
918982 }
919983 if self.page_forward() {
920920- ctx.mark_dirty(PAGE_REGION);
984984+ ctx.mark_dirty_immediate(PAGE_REGION);
921985 }
922986 Transition::None
923987 }
···926990 self.show_position = true;
927991 }
928992 if self.page_backward() {
929929- ctx.mark_dirty(PAGE_REGION);
993993+ ctx.mark_dirty_immediate(PAGE_REGION);
930994 }
931995 Transition::None
932996 }
···934998 ActionEvent::Release(Action::Next) | ActionEvent::Release(Action::Prev) => {
935999 if self.show_position {
9361000 self.show_position = false;
937937- ctx.mark_dirty(POSITION_OVERLAY);
10011001+ ctx.mark_dirty_immediate(POSITION_OVERLAY);
9381002 }
9391003 Transition::None
9401004 }
94110059421006 ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => {
9431007 if self.page_forward() {
944944- ctx.mark_dirty(PAGE_REGION);
10081008+ ctx.mark_dirty_immediate(PAGE_REGION);
9451009 }
9461010 Transition::None
9471011 }
94810129491013 ActionEvent::Press(Action::Prev) | ActionEvent::Repeat(Action::Prev) => {
9501014 if self.page_backward() {
951951- ctx.mark_dirty(PAGE_REGION);
10151015+ ctx.mark_dirty_immediate(PAGE_REGION);
9521016 }
9531017 Transition::None
9541018 }
95510199561020 ActionEvent::Press(Action::NextJump) | ActionEvent::Repeat(Action::NextJump) => {
9571021 if self.jump_forward() {
958958- ctx.mark_dirty(PAGE_REGION);
10221022+ ctx.mark_dirty_immediate(PAGE_REGION);
9591023 }
9601024 Transition::None
9611025 }
96210269631027 ActionEvent::Press(Action::PrevJump) | ActionEvent::Repeat(Action::PrevJump) => {
9641028 if self.jump_backward() {
965965- ctx.mark_dirty(PAGE_REGION);
10291029+ ctx.mark_dirty_immediate(PAGE_REGION);
9661030 }
9671031 Transition::None
9681032 }
···11101174 );
11111175 }
1112117611131113- if let Some(msg) = self.error {
11141114- draw_chrome_text(strip, LOADING_REGION, msg, Alignment::CenterLeft, cf);
11771177+ if let Some(e) = self.error {
11781178+ let mut ebuf = StackFmt::<32>::new();
11791179+ let _ = write!(ebuf, "{}", e);
11801180+ draw_chrome_text(
11811181+ strip,
11821182+ LOADING_REGION,
11831183+ ebuf.as_str(),
11841184+ Alignment::CenterLeft,
11851185+ cf,
11861186+ );
11151187 return;
11161188 }
11171189···11391211 Alignment::CenterLeft,
11401212 cf,
11411213 );
12141214+ draw_progress_bar(strip, LOADING_BAR_REGION, self.state.loading_pct());
11421215 return;
11431216 }
11441217
+7-8
src/apps/reader/paging.rs
···9999 pub(super) fn load_and_prefetch(
100100 &mut self,
101101 k: &mut KernelHandle<'_>,
102102- ) -> Result<(), &'static str> {
102102+ ) -> crate::error::Result<()> {
103103 if !self.ch_cache.is_empty() {
104104 let start = (self.offsets[self.page] as usize).min(self.ch_cache.len());
105105 let end = (start + PAGE_BUF).min(self.ch_cache.len());
···128128 let dir = cache::dir_name_str(&dir_buf);
129129 let ch_file = cache::chapter_file_name(self.chapter);
130130 let ch_str = cache::chapter_file_str(&ch_file);
131131- let n =
132132- k.sync_read_app_subdir_chunk(dir, ch_str, self.offsets[self.page], &mut self.buf)?;
131131+ let n = k.read_app_subdir_chunk(dir, ch_str, self.offsets[self.page], &mut self.buf)?;
133132 self.buf_len = n;
134133 } else if self.file_size == 0 {
135135- let (size, n) = k.sync_read_file_start(name, &mut self.buf)?;
134134+ let (size, n) = k.read_file_start(name, &mut self.buf)?;
136135 self.file_size = size;
137136 self.buf_len = n;
138137 log::info!("reader: opened {} ({} bytes)", name, size);
···143142 return Ok(());
144143 }
145144 } else {
146146- let n = k.sync_read_chunk(name, self.offsets[self.page], &mut self.buf)?;
145145+ let n = k.read_chunk(name, self.offsets[self.page], &mut self.buf)?;
147146 self.buf_len = n;
148147 }
149148···170169 let dir = cache::dir_name_str(&dir_buf);
171170 let ch_file = cache::chapter_file_name(self.chapter);
172171 let ch_str = cache::chapter_file_str(&ch_file);
173173- k.sync_read_app_subdir_chunk(dir, ch_str, pf_offset, &mut self.prefetch)
172172+ k.read_app_subdir_chunk(dir, ch_str, pf_offset, &mut self.prefetch)
174173 } else {
175175- k.sync_read_chunk(name, pf_offset, &mut self.prefetch)
174174+ k.read_chunk(name, pf_offset, &mut self.prefetch)
176175 };
177176 match pf_result {
178177 Ok(n) => {
···228227 pub(super) fn scan_to_last_page(
229228 &mut self,
230229 k: &mut KernelHandle<'_>,
231231- ) -> Result<(), &'static str> {
230230+ ) -> crate::error::Result<()> {
232231 while !self.fully_indexed && self.total_pages < MAX_PAGES {
233232 self.page = self.total_pages - 1;
234233 self.load_and_prefetch(k)?;
···55extern crate alloc;
6677// kernel crate re-exports -- keeps crate::board, crate::drivers,
88-// crate::kernel paths working in app code without import changes
88+// crate::kernel, crate::error paths working in app code without
99+// import changes
910pub use pulp_kernel::board;
1011pub use pulp_kernel::drivers;
1212+pub use pulp_kernel::error;
1113pub use pulp_kernel::kernel;
12141315pub mod apps;