A fork of pulp-os for the xteink4 adding custom apps
2
fork

Configure Feed

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

improve reader loading, files, and bookmarking

hansmrtn 41000d46 eab9f4c0

+759 -201
+121 -3
kernel/src/drivers/storage.rs
··· 16 16 17 17 pub const PULP_DIR: &str = "_PULP"; 18 18 pub const TITLES_FILE: &str = "TITLES.BIN"; 19 - pub const TITLE_CAP: usize = 48; 19 + pub const TITLE_CAP: usize = 96; 20 20 21 21 // backward-compatible alias 22 22 pub type StorageError = Error; ··· 46 46 } 47 47 48 48 pub fn display_name(&self) -> &str { 49 - if self.title_len > 0 { 50 - core::str::from_utf8(&self.title[..self.title_len as usize]).unwrap_or(self.name_str()) 49 + let len = (self.title_len & 0x7F) as usize; 50 + if len > 0 { 51 + core::str::from_utf8(&self.title[..len]).unwrap_or(self.name_str()) 51 52 } else { 52 53 self.name_str() 53 54 } 55 + } 56 + 57 + pub fn has_real_title(&self) -> bool { 58 + self.title_len > 0 && self.title_len & 0x80 == 0 54 59 } 55 60 56 61 pub fn set_title(&mut self, s: &[u8]) { 57 62 let n = s.len().min(TITLE_CAP); 58 63 self.title[..n].copy_from_slice(&s[..n]); 59 64 self.title_len = n as u8; 65 + } 66 + 67 + // write a humanized SFN into the title buffer as a soft fallback; 68 + // does not prevent the title scanner from resolving a real title 69 + pub fn humanize_sfn(&mut self) { 70 + let nlen = self.name_len as usize; 71 + if nlen == 0 || self.has_real_title() { 72 + return; 73 + } 74 + let src = &self.name[..nlen]; 75 + // check if name is all-uppercase (typical 8.3 SFN) 76 + let all_upper = src.iter().all(|&b| !b.is_ascii_lowercase()); 77 + if !all_upper { 78 + return; // mixed case: user-supplied LFN, leave as-is 79 + } 80 + let n = nlen.min(TITLE_CAP); 81 + let dot_pos = src.iter().position(|&b| b == b'.').unwrap_or(n); 82 + for i in 0..n { 83 + if i == 0 { 84 + self.title[i] = src[i]; // keep first char uppercase 85 + } else if i > dot_pos { 86 + self.title[i] = src[i].to_ascii_lowercase(); // lowercase ext 87 + } else { 88 + self.title[i] = src[i].to_ascii_lowercase(); 89 + } 90 + } 91 + self.title_len = 0x80 | n as u8; 60 92 } 61 93 } 62 94 ··· 579 611 let mut guard = borrow(sd)?; 580 612 let inner = &mut *guard; 581 613 in_subdir!(inner, PULP_DIR, dir, |sub_h| op_delete!(inner, sub_h, name)) 614 + }) 615 + } 616 + 617 + // _PULP/ direct file operations (cache files live directly in _PULP/) 618 + 619 + pub fn read_chunk_in_pulp( 620 + sd: &SdStorage, 621 + name: &str, 622 + offset: u32, 623 + buf: &mut [u8], 624 + ) -> crate::error::Result<usize> { 625 + poll_once(async { 626 + let mut guard = borrow(sd)?; 627 + let inner = &mut *guard; 628 + in_dir!(inner, PULP_DIR, |dir_h| op_read_chunk!( 629 + inner, dir_h, name, offset, buf 630 + )) 631 + }) 632 + } 633 + 634 + pub fn write_in_pulp(sd: &SdStorage, name: &str, data: &[u8]) -> crate::error::Result<()> { 635 + poll_once(async { 636 + let mut guard = borrow(sd)?; 637 + let inner = &mut *guard; 638 + in_dir!(inner, PULP_DIR, |dir_h| op_write!(inner, dir_h, name, data)) 639 + }) 640 + } 641 + 642 + pub fn append_in_pulp(sd: &SdStorage, name: &str, data: &[u8]) -> crate::error::Result<()> { 643 + poll_once(async { 644 + let mut guard = borrow(sd)?; 645 + let inner = &mut *guard; 646 + in_dir!(inner, PULP_DIR, |dir_h| op_append!( 647 + inner, dir_h, name, data 648 + )) 649 + }) 650 + } 651 + 652 + pub fn file_size_in_pulp(sd: &SdStorage, name: &str) -> crate::error::Result<u32> { 653 + poll_once(async { 654 + let mut guard = borrow(sd)?; 655 + let inner = &mut *guard; 656 + in_dir!(inner, PULP_DIR, |dir_h| op_file_size!(inner, dir_h, name)) 657 + }) 658 + } 659 + 660 + pub fn delete_in_pulp(sd: &SdStorage, name: &str) -> crate::error::Result<()> { 661 + poll_once(async { 662 + let mut guard = borrow(sd)?; 663 + let inner = &mut *guard; 664 + in_dir!(inner, PULP_DIR, |dir_h| op_delete!(inner, dir_h, name)) 665 + }) 666 + } 667 + 668 + // seek+write: open existing file, seek to offset, write data, close 669 + // used to update the chapter offset table after all chapters are appended 670 + pub fn write_at_in_pulp( 671 + sd: &SdStorage, 672 + name: &str, 673 + offset: u32, 674 + data: &[u8], 675 + ) -> crate::error::Result<()> { 676 + poll_once(async { 677 + let mut guard = borrow(sd)?; 678 + let inner = &mut *guard; 679 + in_dir!(inner, PULP_DIR, |dir_h| { 680 + match inner 681 + .mgr 682 + .open_file_in_dir(dir_h, name, Mode::ReadWriteCreateOrAppend) 683 + .await 684 + { 685 + Err(_) => Err(Error::new(ErrorKind::OpenFile, "write_at")), 686 + Ok(file) => { 687 + let result = match inner.mgr.file_seek_from_start(file, offset) { 688 + Ok(()) => inner 689 + .mgr 690 + .write(file, data) 691 + .await 692 + .map_err(|_| Error::new(ErrorKind::WriteFailed, "write_at")), 693 + Err(_) => Err(Error::new(ErrorKind::SeekFailed, "write_at")), 694 + }; 695 + let _ = inner.mgr.close_file(file).await; 696 + result 697 + } 698 + } 699 + }) 582 700 }) 583 701 } 584 702
+41 -1
kernel/src/kernel/bookmarks.rs
··· 7 7 // [16..48) filename [u8;32] 8 8 9 9 use crate::drivers::sdcard::SdStorage; 10 - use crate::drivers::storage; 10 + use crate::drivers::storage::{self, TITLE_CAP}; 11 11 // FNV-1a hash with ASCII case folding, used for bookmark filename lookups. 12 12 pub fn fnv1a_icase(data: &[u8]) -> u32 { 13 13 let mut h: u32 = 0x811c_9dc5; ··· 114 114 pub filename: [u8; FILENAME_CAP], 115 115 pub name_len: u8, 116 116 pub chapter: u16, 117 + pub title: [u8; TITLE_CAP], 118 + pub title_len: u8, 117 119 } 118 120 119 121 impl BmListEntry { ··· 121 123 filename: [0u8; FILENAME_CAP], 122 124 name_len: 0, 123 125 chapter: 0, 126 + title: [0u8; TITLE_CAP], 127 + title_len: 0, 124 128 }; 125 129 126 130 pub fn filename_str(&self) -> &str { 127 131 core::str::from_utf8(&self.filename[..self.name_len as usize]).unwrap_or("?") 132 + } 133 + 134 + pub fn display_name(&self) -> &str { 135 + if self.title_len > 0 { 136 + core::str::from_utf8(&self.title[..self.title_len as usize]) 137 + .unwrap_or(self.filename_str()) 138 + } else { 139 + self.filename_str() 140 + } 141 + } 142 + 143 + pub fn set_title(&mut self, s: &[u8]) { 144 + let n = s.len().min(TITLE_CAP); 145 + self.title[..n].copy_from_slice(&s[..n]); 146 + self.title_len = n as u8; 128 147 } 129 148 } 130 149 ··· 224 243 filename: slot.filename, 225 244 name_len: slot.name_len, 226 245 chapter: slot.chapter, 246 + title: [0u8; TITLE_CAP], 247 + title_len: 0, 227 248 }; 228 249 count += 1; 229 250 } ··· 326 347 generation, 327 348 core::str::from_utf8(filename).unwrap_or("?"), 328 349 ); 350 + } 351 + 352 + pub fn remove(&mut self, filename: &[u8]) { 353 + if !self.loaded { 354 + return; 355 + } 356 + let key = fnv1a_icase(filename); 357 + for i in 0..self.count { 358 + let slot = &mut self.slots[i]; 359 + if slot.valid && slot.name_hash == key && slot.matches_name(filename) { 360 + slot.valid = false; 361 + self.dirty = true; 362 + log::info!( 363 + "bookmark: removed {:?}", 364 + core::str::from_utf8(filename).unwrap_or("?") 365 + ); 366 + return; 367 + } 368 + } 329 369 } 330 370 331 371 pub fn flush(&mut self, sd: &SdStorage) {
+26 -3
kernel/src/kernel/dir_cache.rs
··· 3 3 4 4 use crate::drivers::sdcard::SdStorage; 5 5 use crate::drivers::storage::{ 6 - DirEntry, DirPage, PULP_DIR, TITLES_FILE, list_root_files, read_file_start_in_dir, 6 + list_root_files, read_file_start_in_dir, DirEntry, DirPage, PULP_DIR, TITLES_FILE, 7 7 }; 8 8 use crate::error::Result; 9 9 ··· 39 39 self.count = count; 40 40 sort_entries(&mut self.entries, self.count); 41 41 self.load_titles(sd); 42 + for i in 0..self.count { 43 + self.entries[i].humanize_sfn(); 44 + } 42 45 self.valid = true; 43 46 Ok(()) 44 47 } 45 48 46 49 fn load_titles(&mut self, sd: &SdStorage) { 47 - let mut buf = [0u8; 2048]; 50 + let mut buf = [0u8; 4096]; 48 51 let n = match read_file_start_in_dir(sd, PULP_DIR, TITLES_FILE, &mut buf) { 49 52 Ok((_, n)) => n, 50 53 Err(_) => return, ··· 106 109 pub fn next_untitled_epub(&self, from: usize) -> Option<(usize, [u8; 13], u8)> { 107 110 for i in from..self.count { 108 111 let e = &self.entries[i]; 109 - if e.title_len > 0 || e.is_dir { 112 + if e.has_real_title() || e.is_dir { 110 113 continue; 111 114 } 112 115 let name = e.name_str().as_bytes(); ··· 115 118 && name[name.len() - 4..].eq_ignore_ascii_case(b"EPUB") 116 119 { 117 120 return Some((i, e.name, e.name_len)); 121 + } 122 + } 123 + None 124 + } 125 + 126 + // look up the display title for a filename (case-insensitive); 127 + // returns (title_bytes, title_len) including humanized SFN 128 + pub fn find_title(&self, filename: &[u8]) -> Option<(&[u8], u8)> { 129 + let name = match core::str::from_utf8(filename) { 130 + Ok(s) => s, 131 + Err(_) => return None, 132 + }; 133 + for i in 0..self.count { 134 + let e = &self.entries[i]; 135 + if e.name_str().eq_ignore_ascii_case(name) { 136 + let len = (e.title_len & 0x7F) as usize; 137 + if len > 0 { 138 + return Some((&e.title[..len], len as u8)); 139 + } 140 + return None; 118 141 } 119 142 } 120 143 None
+43
kernel/src/kernel/handle.rs
··· 131 131 storage::delete_in_pulp_subdir(&self.kernel.sd, dir, name) 132 132 } 133 133 134 + // _PULP/ direct file ops (v3 unified cache files) 135 + 136 + #[inline] 137 + pub fn read_cache_chunk(&mut self, name: &str, offset: u32, buf: &mut [u8]) -> Result<usize> { 138 + storage::read_chunk_in_pulp(&self.kernel.sd, name, offset, buf) 139 + } 140 + 141 + #[inline] 142 + pub fn write_cache(&mut self, name: &str, data: &[u8]) -> Result<()> { 143 + storage::write_in_pulp(&self.kernel.sd, name, data) 144 + } 145 + 146 + #[inline] 147 + pub fn append_cache(&mut self, name: &str, data: &[u8]) -> Result<()> { 148 + storage::append_in_pulp(&self.kernel.sd, name, data) 149 + } 150 + 151 + #[inline] 152 + pub fn write_cache_at(&mut self, name: &str, offset: u32, data: &[u8]) -> Result<()> { 153 + storage::write_at_in_pulp(&self.kernel.sd, name, offset, data) 154 + } 155 + 156 + #[inline] 157 + pub fn delete_cache(&mut self, name: &str) -> Result<()> { 158 + storage::delete_in_pulp(&self.kernel.sd, name) 159 + } 160 + 161 + #[inline] 162 + pub fn cache_file_size(&mut self, name: &str) -> Result<u32> { 163 + storage::file_size_in_pulp(&self.kernel.sd, name) 164 + } 165 + 166 + // root directory file deletion 167 + #[inline] 168 + pub fn delete_file(&mut self, name: &str) -> Result<()> { 169 + storage::delete_file(&self.kernel.sd, name) 170 + } 171 + 134 172 pub fn dir_page(&mut self, offset: usize, buf: &mut [DirEntry]) -> Result<DirPage> { 135 173 let k = &mut *self.kernel; 136 174 k.dir_cache.ensure_loaded(&k.sd)?; ··· 156 194 #[inline] 157 195 pub fn sd_ok(&self) -> bool { 158 196 self.kernel.sd_ok 197 + } 198 + 199 + pub fn ensure_dir_cache_loaded(&mut self) -> Result<()> { 200 + let k = &mut *self.kernel; 201 + k.dir_cache.ensure_loaded(&k.sd) 159 202 } 160 203 161 204 // direct cache accessors
+136 -12
src/apps/files.rs
··· 16 16 use crate::error::{Error, ErrorKind}; 17 17 use crate::fonts; 18 18 use crate::kernel::KernelHandle; 19 + use crate::kernel::QuickAction; 19 20 use crate::ui::{ 20 21 Alignment, BitmapDynLabel, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, HEADER_W, LARGE_MARGIN, 21 22 Region, SECTION_GAP, TITLE_Y_OFFSET, 22 23 }; 24 + use smol_epub::cache; 23 25 use smol_epub::epub::{self, EpubMeta, EpubSpine}; 24 26 use smol_epub::zip::ZipIndex; 25 27 26 - const PAGE_SIZE: usize = 7; 28 + const MAX_PAGE_SIZE: usize = 14; 29 + 30 + const QA_DELETE_FILE: u8 = 1; 31 + const QA_DELETE_CACHE: u8 = 2; 32 + const QA_MAX: usize = 2; 27 33 28 34 const LIST_X: u16 = LARGE_MARGIN; 29 35 const LIST_W: u16 = FULL_CONTENT_W; ··· 38 44 39 45 const ROW_H: u16 = 52; 40 46 const ROW_GAP: u16 = 4; 47 + const ROW_STRIDE: u16 = ROW_H + ROW_GAP; 41 48 42 49 const HEADER_LIST_GAP: u16 = SECTION_GAP; 43 50 51 + fn compute_page_size(list_y: u16) -> usize { 52 + let available = SCREEN_H.saturating_sub(list_y); 53 + let rows = (available / ROW_STRIDE) as usize; 54 + rows.min(MAX_PAGE_SIZE) 55 + } 56 + 44 57 impl Default for FilesApp { 45 58 fn default() -> Self { 46 59 Self::new() ··· 48 61 } 49 62 50 63 pub struct FilesApp { 51 - entries: [DirEntry; PAGE_SIZE], 64 + entries: [DirEntry; MAX_PAGE_SIZE], 65 + page_size: usize, 52 66 count: usize, 53 67 total: usize, 54 68 scroll: usize, ··· 62 76 title_scan_idx: usize, 63 77 title_scanning: bool, 64 78 title_reload: bool, 79 + 80 + qa_buf: [QuickAction; QA_MAX], 81 + qa_count: usize, 82 + pending_delete_file: bool, 83 + pending_delete_cache: bool, 65 84 } 66 85 67 86 impl FilesApp { 68 87 pub fn new() -> Self { 69 88 let uf = fonts::UiFonts::for_size(0); 89 + let list_y = TITLE_Y + uf.heading.line_height + HEADER_LIST_GAP; 70 90 Self { 71 - entries: [DirEntry::EMPTY; PAGE_SIZE], 91 + entries: [DirEntry::EMPTY; MAX_PAGE_SIZE], 92 + page_size: compute_page_size(list_y), 72 93 count: 0, 73 94 total: 0, 74 95 scroll: 0, ··· 77 98 stale_cache: false, 78 99 error: None, 79 100 ui_fonts: uf, 80 - list_y: TITLE_Y + uf.heading.line_height + HEADER_LIST_GAP, 101 + list_y, 81 102 title_scan_idx: 0, 82 103 title_scanning: false, 83 104 title_reload: false, 105 + qa_buf: [QuickAction::trigger(0, "", ""); QA_MAX], 106 + qa_count: 0, 107 + pending_delete_file: false, 108 + pending_delete_cache: false, 84 109 } 85 110 } 86 111 87 112 pub fn set_ui_font_size(&mut self, idx: u8) { 88 113 self.ui_fonts = fonts::UiFonts::for_size(idx); 89 114 self.list_y = TITLE_Y + self.ui_fonts.heading.line_height + HEADER_LIST_GAP; 115 + self.page_size = compute_page_size(self.list_y); 90 116 } 91 117 92 118 // Session state accessors for RTC persistence ··· 127 153 } 128 154 129 155 fn load_page(&mut self, entries: &[DirEntry], total: usize) { 130 - let n = entries.len().min(PAGE_SIZE); 156 + let n = entries.len().min(self.page_size); 131 157 self.entries[..n].clone_from_slice(&entries[..n]); 132 158 self.count = n; 133 159 self.total = total; ··· 136 162 if self.selected >= self.count && self.count > 0 { 137 163 self.selected = self.count - 1; 138 164 } 165 + self.rebuild_quick_actions(); 139 166 } 140 167 141 168 fn load_failed(&mut self, e: Error) { ··· 158 185 LIST_X, 159 186 self.list_y, 160 187 LIST_W, 161 - (ROW_H + ROW_GAP) * PAGE_SIZE as u16, 188 + ROW_STRIDE * self.page_size as u16, 162 189 ) 163 190 } 164 191 ··· 168 195 self.selected -= 1; 169 196 ctx.mark_dirty(self.row_region(self.selected)); 170 197 ctx.mark_dirty(STATUS_REGION); 198 + self.rebuild_quick_actions(); 171 199 } else if self.scroll > 0 { 172 200 self.scroll = self.scroll.saturating_sub(1); 173 201 self.needs_load = true; 174 202 } else if self.total > 0 { 175 - self.scroll = self.total.saturating_sub(PAGE_SIZE); 203 + self.scroll = self.total.saturating_sub(self.page_size); 176 204 self.selected = self.total.saturating_sub(self.scroll) - 1; 177 205 self.needs_load = true; 178 206 } ··· 184 212 self.selected += 1; 185 213 ctx.mark_dirty(self.row_region(self.selected)); 186 214 ctx.mark_dirty(STATUS_REGION); 215 + self.rebuild_quick_actions(); 187 216 } else if self.scroll + self.count < self.total { 188 217 self.scroll += 1; 189 218 self.needs_load = true; ··· 196 225 197 226 fn jump_up(&mut self) { 198 227 if self.scroll > 0 { 199 - self.scroll = self.scroll.saturating_sub(PAGE_SIZE); 228 + self.scroll = self.scroll.saturating_sub(self.page_size); 200 229 self.selected = 0; 201 230 self.needs_load = true; 202 231 } else { ··· 204 233 } 205 234 } 206 235 236 + fn rebuild_quick_actions(&mut self) { 237 + let mut n = 0usize; 238 + let (is_file, is_epub) = if self.selected < self.count { 239 + let e = &self.entries[self.selected]; 240 + let nm = &e.name[..e.name_len as usize]; 241 + let epub = !e.is_dir 242 + && nm.len() >= 5 243 + && nm[nm.len() - 5] == b'.' 244 + && nm[nm.len() - 4..].eq_ignore_ascii_case(b"EPUB"); 245 + (!e.is_dir, epub) 246 + } else { 247 + (false, false) 248 + }; 249 + 250 + if is_file { 251 + self.qa_buf[n] = QuickAction::trigger(QA_DELETE_FILE, "Delete File", "Delete"); 252 + n += 1; 253 + if is_epub { 254 + self.qa_buf[n] = QuickAction::trigger(QA_DELETE_CACHE, "Delete Cache", "Delete"); 255 + n += 1; 256 + } 257 + } 258 + self.qa_count = n; 259 + } 260 + 207 261 fn jump_down(&mut self) { 208 262 let remaining = self.total.saturating_sub(self.scroll + self.count); 209 263 if remaining > 0 { 210 - self.scroll += PAGE_SIZE.min(remaining + self.count - 1); 264 + self.scroll += self.page_size.min(remaining + self.count - 1); 211 265 self.selected = 0; 212 266 self.needs_load = true; 213 267 } else if self.count > 0 { ··· 250 304 } 251 305 252 306 async fn background(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) { 307 + if self.pending_delete_file { 308 + self.pending_delete_file = false; 309 + if let Some(entry) = self.selected_entry() { 310 + if !entry.is_dir { 311 + let mut nb = [0u8; 13]; 312 + let nl = entry.name_len as usize; 313 + nb[..nl].copy_from_slice(&entry.name[..nl]); 314 + let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 315 + log::info!("files: deleting {}", name); 316 + 317 + // also remove bookmark 318 + k.bookmark_cache_mut().remove(&nb[..nl]); 319 + 320 + match k.delete_file(name) { 321 + Ok(()) => { 322 + log::info!("files: deleted {}", name); 323 + k.invalidate_dir_cache(); 324 + self.needs_load = true; 325 + self.stale_cache = true; 326 + self.title_scan_idx = 0; 327 + self.title_scanning = true; 328 + } 329 + Err(e) => { 330 + log::warn!("files: delete failed: {}", e); 331 + } 332 + } 333 + } 334 + } 335 + ctx.mark_dirty(self.list_region()); 336 + ctx.mark_dirty(STATUS_REGION); 337 + return; 338 + } 339 + 340 + if self.pending_delete_cache { 341 + self.pending_delete_cache = false; 342 + if let Some(entry) = self.selected_entry() { 343 + if !entry.is_dir { 344 + let nl = entry.name_len as usize; 345 + let name = core::str::from_utf8(&entry.name[..nl]).unwrap_or(""); 346 + let hash = cache::fnv1a(name.as_bytes()); 347 + let cf = cache::cache_filename(hash); 348 + let cf_str = cache::cache_filename_str(&cf); 349 + log::info!("files: deleting cache for {} ({})", name, cf_str); 350 + 351 + // delete v3 flat cache file (best effort) 352 + match k.delete_cache(cf_str) { 353 + Ok(()) => log::info!("files: cache deleted for {}", name), 354 + Err(e) => log::warn!("files: cache delete failed: {}", e), 355 + } 356 + } 357 + } 358 + return; 359 + } 360 + 253 361 if self.needs_load { 254 362 if self.stale_cache { 255 363 k.invalidate_dir_cache(); 256 364 self.stale_cache = false; 257 365 } 258 366 259 - let mut buf = [DirEntry::EMPTY; PAGE_SIZE]; 260 - match k.dir_page(self.scroll, &mut buf) { 367 + let mut buf = [DirEntry::EMPTY; MAX_PAGE_SIZE]; 368 + match k.dir_page(self.scroll, &mut buf[..self.page_size]) { 261 369 Ok(page) => { 262 370 self.load_page(&buf[..page.count], page.total); 263 371 } ··· 384 492 return; 385 493 } 386 494 387 - for i in 0..PAGE_SIZE { 495 + for i in 0..self.page_size { 388 496 let region = self.row_region(i); 389 497 390 498 if i < self.count { ··· 403 511 .draw(strip) 404 512 .unwrap(); 405 513 } 514 + } 515 + } 516 + 517 + fn quick_actions(&self) -> &[QuickAction] { 518 + &self.qa_buf[..self.qa_count] 519 + } 520 + 521 + fn on_quick_trigger(&mut self, id: u8, _ctx: &mut AppContext) { 522 + match id { 523 + QA_DELETE_FILE => { 524 + self.pending_delete_file = true; 525 + } 526 + QA_DELETE_CACHE => { 527 + self.pending_delete_cache = true; 528 + } 529 + _ => {} 406 530 } 407 531 } 408 532 }
+123 -89
src/apps/home.rs
··· 2 2 3 3 use core::fmt::Write as _; 4 4 5 - use crate::apps::widgets::selectable_row::draw_selection; 6 5 use crate::apps::{App, AppContext, AppId, RECENT_FILE, Transition}; 7 6 use crate::board::action::{Action, ActionEvent}; 8 7 use crate::board::{SCREEN_H, SCREEN_W}; 9 8 use crate::drivers::strip::StripBuffer; 10 9 use crate::fonts; 11 - use crate::fonts::bitmap::byte_to_char; 12 10 use crate::kernel::KernelHandle; 13 11 use crate::kernel::bookmarks::{self, BmListEntry}; 14 - use crate::ui::{Alignment, BUTTON_BAR_H, BitmapDynLabel, BitmapLabel, CONTENT_TOP, Region}; 12 + use crate::ui::{ 13 + Alignment, BitmapDynLabel, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, HEADER_W, LARGE_MARGIN, 14 + Region, SECTION_GAP, TITLE_Y_OFFSET, 15 + }; 15 16 16 17 const ITEM_W: u16 = 280; 17 18 const ITEM_H: u16 = 52; ··· 21 22 const TITLE_ITEM_GAP: u16 = 24; 22 23 const MAX_ITEMS: usize = 5; 23 24 24 - const BM_MARGIN: u16 = 8; 25 - const BM_HEADER_GAP: u16 = 4; 26 - const BM_BOTTOM: u16 = SCREEN_H - BUTTON_BAR_H; 25 + // bookmark list layout (matches Files app) 26 + const BM_ROW_H: u16 = 52; 27 + const BM_ROW_GAP: u16 = 4; 28 + const BM_ROW_STRIDE: u16 = BM_ROW_H + BM_ROW_GAP; 29 + const BM_TITLE_Y: u16 = CONTENT_TOP + TITLE_Y_OFFSET; 30 + const BM_HEADER_LIST_GAP: u16 = SECTION_GAP; 31 + const BM_STATUS_W: u16 = 144; 32 + const BM_STATUS_X: u16 = SCREEN_W - LARGE_MARGIN - BM_STATUS_W; 27 33 28 34 const CONTENT_REGION: Region = Region::new(0, CONTENT_TOP, SCREEN_W, SCREEN_H - CONTENT_TOP); 29 35 ··· 226 232 } 227 233 } 228 234 229 - fn bm_text_y(&self) -> u16 { 230 - CONTENT_TOP + 4 + self.ui_fonts.heading.line_height + BM_HEADER_GAP 235 + fn bm_list_y(&self) -> u16 { 236 + BM_TITLE_Y + self.ui_fonts.heading.line_height + BM_HEADER_LIST_GAP 231 237 } 232 238 233 239 fn bm_visible_lines(&self) -> usize { 234 - let area_h = BM_BOTTOM.saturating_sub(self.bm_text_y()); 235 - (area_h / self.ui_fonts.body.line_height).max(1) as usize 240 + let available = SCREEN_H.saturating_sub(self.bm_list_y()); 241 + let rows = (available / BM_ROW_STRIDE) as usize; 242 + rows.max(1).min(bookmarks::SLOTS) 243 + } 244 + 245 + fn bm_row_region(&self, i: usize) -> Region { 246 + Region::new( 247 + LARGE_MARGIN, 248 + self.bm_list_y() + i as u16 * BM_ROW_STRIDE, 249 + FULL_CONTENT_W, 250 + BM_ROW_H, 251 + ) 236 252 } 237 253 238 - fn bm_page_region(&self) -> Region { 239 - Region::new(0, self.bm_text_y(), SCREEN_W, BM_BOTTOM - self.bm_text_y()) 254 + fn bm_list_region(&self) -> Region { 255 + let vis = self.bm_visible_lines(); 256 + Region::new( 257 + LARGE_MARGIN, 258 + self.bm_list_y(), 259 + FULL_CONTENT_W, 260 + BM_ROW_STRIDE * vis as u16, 261 + ) 262 + } 263 + 264 + fn bm_status_region(&self) -> Region { 265 + Region::new( 266 + BM_STATUS_X, 267 + BM_TITLE_Y, 268 + BM_STATUS_W, 269 + self.ui_fonts.heading.line_height, 270 + ) 240 271 } 241 272 } 242 273 ··· 278 309 279 310 if self.needs_load_bookmarks { 280 311 self.bm_count = k.bookmark_cache().load_all(&mut self.bm_entries); 312 + // resolve titles from dir cache 313 + let _ = k.ensure_dir_cache_loaded(); 314 + for i in 0..self.bm_count { 315 + let entry = &self.bm_entries[i]; 316 + let fname = &entry.filename[..entry.name_len as usize]; 317 + if let Some((title, len)) = k.dir_cache_mut().find_title(fname) { 318 + let mut tbuf = [0u8; 96]; 319 + let n = (len as usize).min(96); 320 + tbuf[..n].copy_from_slice(&title[..n]); 321 + self.bm_entries[i].set_title(&tbuf[..n]); 322 + } else { 323 + // inline humanize: lowercase all-upper SFN filenames 324 + humanize_bm_entry(&mut self.bm_entries[i]); 325 + } 326 + } 281 327 self.needs_load_bookmarks = false; 282 328 if self.state == HomeState::ShowBookmarks { 283 - ctx.mark_dirty(self.bm_page_region()); 329 + ctx.mark_dirty(self.bm_list_region()); 284 330 } 285 331 } 286 332 } ··· 342 388 343 389 ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => { 344 390 if self.bm_count > 0 { 391 + let old = self.bm_selected; 392 + let vis = self.bm_visible_lines(); 345 393 if self.bm_selected + 1 < self.bm_count { 346 394 self.bm_selected += 1; 395 + if self.bm_selected >= self.bm_scroll + vis { 396 + self.bm_scroll = self.bm_selected + 1 - vis; 397 + ctx.mark_dirty(self.bm_list_region()); 398 + } else { 399 + ctx.mark_dirty(self.bm_row_region(old - self.bm_scroll)); 400 + ctx.mark_dirty(self.bm_row_region(self.bm_selected - self.bm_scroll)); 401 + } 347 402 } else { 348 403 self.bm_selected = 0; 349 404 self.bm_scroll = 0; 405 + ctx.mark_dirty(self.bm_list_region()); 350 406 } 351 - let vis = self.bm_visible_lines(); 352 - if self.bm_selected >= self.bm_scroll + vis { 353 - self.bm_scroll = self.bm_selected + 1 - vis; 354 - } 355 - ctx.mark_dirty(self.bm_page_region()); 407 + ctx.mark_dirty(self.bm_status_region()); 356 408 } 357 409 Transition::None 358 410 } 359 411 360 412 ActionEvent::Press(Action::Prev) | ActionEvent::Repeat(Action::Prev) => { 361 413 if self.bm_count > 0 { 414 + let old = self.bm_selected; 415 + let vis = self.bm_visible_lines(); 362 416 if self.bm_selected > 0 { 363 417 self.bm_selected -= 1; 418 + if self.bm_selected < self.bm_scroll { 419 + self.bm_scroll = self.bm_selected; 420 + ctx.mark_dirty(self.bm_list_region()); 421 + } else { 422 + ctx.mark_dirty(self.bm_row_region(old - self.bm_scroll)); 423 + ctx.mark_dirty(self.bm_row_region(self.bm_selected - self.bm_scroll)); 424 + } 364 425 } else { 365 426 self.bm_selected = self.bm_count - 1; 366 - let vis = self.bm_visible_lines(); 367 427 if self.bm_selected >= vis { 368 428 self.bm_scroll = self.bm_selected + 1 - vis; 369 429 } 430 + ctx.mark_dirty(self.bm_list_region()); 370 431 } 371 - if self.bm_selected < self.bm_scroll { 372 - self.bm_scroll = self.bm_selected; 373 - } 374 - ctx.mark_dirty(self.bm_page_region()); 432 + ctx.mark_dirty(self.bm_status_region()); 375 433 } 376 434 Transition::None 377 435 } ··· 383 441 if self.bm_selected >= self.bm_scroll + vis { 384 442 self.bm_scroll = self.bm_selected + 1 - vis; 385 443 } 386 - ctx.mark_dirty(self.bm_page_region()); 444 + ctx.mark_dirty(self.bm_list_region()); 445 + ctx.mark_dirty(self.bm_status_region()); 387 446 } 388 447 Transition::None 389 448 } ··· 394 453 if self.bm_selected < self.bm_scroll { 395 454 self.bm_scroll = self.bm_selected; 396 455 } 397 - ctx.mark_dirty(self.bm_page_region()); 456 + ctx.mark_dirty(self.bm_list_region()); 457 + ctx.mark_dirty(self.bm_status_region()); 398 458 Transition::None 399 459 } 400 460 ··· 439 499 440 500 fn draw_bookmarks(&self, strip: &mut StripBuffer) { 441 501 let header_region = Region::new( 442 - BM_MARGIN, 443 - CONTENT_TOP + 4, 444 - SCREEN_W - BM_MARGIN * 2, 502 + LARGE_MARGIN, 503 + BM_TITLE_Y, 504 + HEADER_W, 445 505 self.ui_fonts.heading.line_height, 446 506 ); 447 507 BitmapLabel::new(header_region, "Bookmarks", self.ui_fonts.heading) ··· 450 510 .unwrap(); 451 511 452 512 if self.bm_count > 0 { 453 - let status_region = Region::new( 454 - SCREEN_W / 2, 455 - CONTENT_TOP + 4, 456 - SCREEN_W / 2 - BM_MARGIN, 457 - self.ui_fonts.heading.line_height, 458 - ); 459 - let mut status = BitmapDynLabel::<20>::new(status_region, self.ui_fonts.body) 460 - .alignment(Alignment::CenterRight); 513 + let mut status = 514 + BitmapDynLabel::<20>::new(self.bm_status_region(), self.ui_fonts.body) 515 + .alignment(Alignment::CenterRight); 461 516 let _ = write!(status, "{}/{}", self.bm_selected + 1, self.bm_count); 462 517 status.draw(strip).unwrap(); 463 518 } 464 519 465 520 if self.bm_count == 0 { 466 - let r = Region::new( 467 - BM_MARGIN, 468 - self.bm_text_y(), 469 - 300, 470 - self.ui_fonts.body.line_height, 471 - ); 472 - BitmapLabel::new(r, "No bookmarks saved", self.ui_fonts.body) 521 + BitmapLabel::new(self.bm_row_region(0), "No bookmarks", self.ui_fonts.body) 473 522 .alignment(Alignment::CenterLeft) 474 523 .draw(strip) 475 524 .unwrap(); 476 525 return; 477 526 } 478 527 479 - let font = self.ui_fonts.body; 480 - let line_h = font.line_height as i32; 481 - let ascent = font.ascent as i32; 482 - let text_y = self.bm_text_y() as i32; 483 528 let vis = self.bm_visible_lines(); 484 529 let visible = vis.min(self.bm_count.saturating_sub(self.bm_scroll)); 485 530 486 - for i in 0..visible { 487 - let idx = self.bm_scroll + i; 488 - let entry = &self.bm_entries[idx]; 489 - let y_top = text_y + i as i32 * line_h; 490 - let baseline = y_top + ascent; 491 - let selected = idx == self.bm_selected; 492 - 493 - let row_region = Region::new(0, y_top as u16, SCREEN_W, line_h as u16); 494 - let fg = draw_selection(strip, row_region, selected); 531 + for i in 0..vis { 532 + let region = self.bm_row_region(i); 533 + if i < visible { 534 + let idx = self.bm_scroll + i; 535 + let entry = &self.bm_entries[idx]; 536 + let name = entry.display_name(); 495 537 496 - let mut cx = BM_MARGIN as i32; 497 - 498 - if entry.chapter > 0 { 499 - let mut ch_buf = [0u8; 10]; 500 - let ch_len = fmt_chapter_prefix(&mut ch_buf, entry.chapter); 501 - for &b in &ch_buf[..ch_len] { 502 - cx += font.draw_char_fg(strip, byte_to_char(b), fg, cx, baseline) as i32; 503 - } 538 + BitmapLabel::new(region, name, self.ui_fonts.body) 539 + .alignment(Alignment::CenterLeft) 540 + .inverted(idx == self.bm_selected) 541 + .draw(strip) 542 + .unwrap(); 504 543 } 505 - 506 - font.draw_str_fg(strip, entry.filename_str(), fg, cx, baseline); 507 544 } 508 545 } 509 546 } 510 547 511 - // format "Ch{N} " into buf (1-based), return byte count 512 - fn fmt_chapter_prefix(buf: &mut [u8; 10], chapter: u16) -> usize { 513 - let n = chapter.saturating_add(1); 514 - buf[0] = b'C'; 515 - buf[1] = b'h'; 516 - let mut pos = 2; 517 - if n >= 10000 { 518 - buf[pos] = b'0' + ((n / 10000) % 10) as u8; 519 - pos += 1; 548 + // humanize an all-uppercase SFN bookmark filename into the title field 549 + fn humanize_bm_entry(entry: &mut BmListEntry) { 550 + let nlen = entry.name_len as usize; 551 + if nlen == 0 || entry.title_len > 0 { 552 + return; 520 553 } 521 - if n >= 1000 { 522 - buf[pos] = b'0' + ((n / 1000) % 10) as u8; 523 - pos += 1; 554 + let src = &entry.filename[..nlen]; 555 + let all_upper = src.iter().all(|&b| !b.is_ascii_lowercase()); 556 + if !all_upper { 557 + return; 524 558 } 525 - if n >= 100 { 526 - buf[pos] = b'0' + ((n / 100) % 10) as u8; 527 - pos += 1; 528 - } 529 - if n >= 10 { 530 - buf[pos] = b'0' + ((n / 10) % 10) as u8; 531 - pos += 1; 559 + let n = nlen.min(entry.title.len()); 560 + let dot_pos = src.iter().position(|&b| b == b'.').unwrap_or(n); 561 + for i in 0..n { 562 + entry.title[i] = if i == 0 { 563 + src[i] 564 + } else if i > dot_pos { 565 + src[i].to_ascii_lowercase() 566 + } else { 567 + src[i].to_ascii_lowercase() 568 + }; 532 569 } 533 - buf[pos] = b'0' + (n % 10) as u8; 534 - pos += 1; 535 - buf[pos] = b' '; 536 - pos + 1 570 + entry.title_len = n as u8; 537 571 }
+152 -53
src/apps/reader/epubs.rs
··· 20 20 // one cell shared between reader and writer; safe because 21 21 // stream_strip_entry_async never borrows both simultaneously 22 22 struct CellReader<'a, 'k>(&'a RefCell<&'a mut KernelHandle<'k>>, &'a str); 23 - struct CellWriter<'a, 'k>(&'a RefCell<&'a mut KernelHandle<'k>>, &'a str, &'a str); 23 + // CellWriter appends to a flat cache file in _PULP/ (v3 format) 24 + struct CellWriter<'a, 'k>(&'a RefCell<&'a mut KernelHandle<'k>>, &'a str); 24 25 25 26 impl smol_epub::async_io::AsyncReadAt for CellReader<'_, '_> { 26 27 async fn read_at(&mut self, offset: u32, buf: &mut [u8]) -> Result<usize, &'static str> { ··· 35 36 async fn write_chunk(&mut self, data: &[u8]) -> Result<(), &'static str> { 36 37 self.0 37 38 .borrow_mut() 38 - .append_app_subdir(self.1, self.2, data) 39 + .append_cache(self.1, data) 39 40 .map_err(|e: Error| -> &'static str { e.into() }) 40 41 } 41 42 } ··· 56 57 } 57 58 self.archive_size = epub_size; 58 59 self.name_hash = cache::fnv1a(name.as_bytes()); 60 + self.cache_file = cache::cache_filename(self.name_hash); 59 61 self.cache_dir = cache::dir_name_for_hash(self.name_hash); 60 62 61 63 let tail_size = (epub_size as usize).min(EOCD_TAIL); ··· 93 95 k: &mut KernelHandle<'_>, 94 96 scratch: &mut [u8], 95 97 ) -> crate::error::Result<bool> { 96 - let dir_buf = self.cache_dir; 97 - let dir = cache::dir_name_str(&dir_buf); 98 + let cf = self.cache_file; 99 + let cf_str = cache::cache_filename_str(&cf); 98 100 99 - let meta_cap = cache::META_MAX_SIZE.min(scratch.len()); 100 - if let Ok(n) = k.read_app_subdir_chunk(dir, cache::META_FILE, 0, &mut scratch[..meta_cap]) 101 - && let Ok(count) = cache::parse_cache_meta( 102 - &scratch[..n], 103 - self.archive_size, 104 - self.name_hash, 105 - self.spine.len(), 106 - &mut self.chapter_sizes, 107 - ) 101 + // try reading v3 header 102 + let hdr_cap = cache::HEADER_SIZE.min(scratch.len()); 103 + if let Ok(n) = k.read_cache_chunk(cf_str, 0, &mut scratch[..hdr_cap]) 104 + && n >= cache::HEADER_SIZE 108 105 { 109 - self.chapters_cached = true; 110 - for i in 0..count { 111 - self.ch_cached[i] = true; 106 + let hdr_buf: &[u8; cache::HEADER_SIZE] = 107 + scratch[..cache::HEADER_SIZE].try_into().unwrap(); 108 + if let Ok(hdr) = cache::parse_v3_header(hdr_buf) { 109 + if cache::validate_v3_header( 110 + &hdr, 111 + self.archive_size, 112 + self.name_hash, 113 + self.spine.len(), 114 + ).is_ok() && hdr.chapters_complete() { 115 + // read chapter table 116 + let count = hdr.chapter_count as usize; 117 + let tbl_bytes = count * cache::CHAPTER_ENTRY_SIZE; 118 + let tbl_offset = hdr.table_offset(); 119 + if tbl_bytes <= scratch.len() { 120 + if let Ok(tn) = k.read_cache_chunk( 121 + cf_str, 122 + tbl_offset, 123 + &mut scratch[..tbl_bytes], 124 + ) && tn >= tbl_bytes 125 + { 126 + if cache::parse_chapter_table( 127 + &scratch[..tbl_bytes], 128 + count, 129 + &mut self.chapter_table, 130 + ).is_ok() { 131 + self.chapters_cached = true; 132 + for i in 0..count { 133 + self.ch_cached[i] = true; 134 + } 135 + // ensure image subdir exists for skip markers 136 + let dir_buf = self.cache_dir; 137 + let dir = cache::dir_name_str(&dir_buf); 138 + let _ = k.ensure_app_subdir(dir); 139 + log::info!("epub: v3 cache hit ({} chapters)", count); 140 + return Ok(true); 141 + } 142 + } 143 + } 144 + } 112 145 } 113 - log::info!("epub: cache hit ({} chapters)", count); 114 - return Ok(true); 115 146 } 116 147 117 - log::info!("epub: building cache for {} chapters", self.spine.len()); 148 + log::info!("epub: building v3 cache for {} chapters", self.spine.len()); 149 + // ensure image subdir exists (images stay in _PULP/_XXXXXXX/) 150 + let dir_buf = self.cache_dir; 151 + let dir = cache::dir_name_str(&dir_buf); 118 152 k.ensure_app_subdir(dir)?; 119 153 self.cache_chapter = 0; 120 154 Ok(false) 121 155 } 122 156 123 - pub(super) fn finish_cache(&mut self, k: &mut KernelHandle<'_>) -> crate::error::Result<bool> { 124 - let dir_buf = self.cache_dir; 125 - let dir = cache::dir_name_str(&dir_buf); 157 + pub(super) fn finish_cache( 158 + &mut self, 159 + k: &mut KernelHandle<'_>, 160 + title: &[u8], 161 + filename: &[u8], 162 + ) -> crate::error::Result<bool> { 163 + let cf = self.cache_file; 164 + let cf_str = cache::cache_filename_str(&cf); 126 165 let spine_len = self.spine.len(); 127 166 128 - let mut meta_buf = [0u8; cache::META_MAX_SIZE]; 129 - let meta_len = cache::encode_cache_meta( 130 - self.archive_size, 131 - self.name_hash, 132 - &self.chapter_sizes[..spine_len], 133 - &mut meta_buf, 134 - ); 135 - k.write_app_subdir(dir, cache::META_FILE, &meta_buf[..meta_len])?; 167 + // build v3 header with chapters_complete flag 168 + let mut hdr = cache::CacheHeader::empty(); 169 + hdr.version = cache::CACHE_V3; 170 + hdr.chapter_count = spine_len as u16; 171 + hdr.flags = cache::FLAG_CHAPTERS_COMPLETE; 172 + hdr.epub_size = self.archive_size; 173 + hdr.name_hash = self.name_hash; 174 + 175 + let tlen = title.len().min(cache::TITLE_CAP); 176 + hdr.title[..tlen].copy_from_slice(&title[..tlen]); 177 + hdr.title_len = tlen as u8; 178 + let nlen = filename.len().min(cache::NAME_CAP); 179 + hdr.name[..nlen].copy_from_slice(&filename[..nlen]); 180 + hdr.name_len = nlen as u8; 181 + 182 + let mut hdr_buf = [0u8; cache::HEADER_SIZE]; 183 + cache::encode_v3_header(&hdr, &mut hdr_buf); 184 + k.write_cache_at(cf_str, 0, &hdr_buf)?; 185 + 186 + // write chapter table 187 + let tbl_size = spine_len * cache::CHAPTER_ENTRY_SIZE; 188 + // encode in chunks to avoid large stack buffers 189 + let mut tbl_buf = [0u8; 8]; // one entry at a time 190 + for i in 0..spine_len { 191 + cache::encode_chapter_table(&self.chapter_table[i..i + 1], &mut tbl_buf); 192 + let offset = cache::HEADER_SIZE as u32 + (i * cache::CHAPTER_ENTRY_SIZE) as u32; 193 + k.write_cache_at(cf_str, offset, &tbl_buf[..cache::CHAPTER_ENTRY_SIZE])?; 194 + } 195 + let _ = tbl_size; // used for clarity above 136 196 137 197 self.chapters_cached = true; 138 - log::info!("epub: cache complete"); 198 + log::info!("epub: v3 cache complete ({} chapters)", spine_len); 139 199 Ok(false) 140 200 } 141 201 142 - // async streaming chapter cache: decompress, strip HTML, write to SD. 202 + // async streaming chapter cache: decompress, strip HTML, append to v3 flat file. 203 + // tracks offset/size in chapter_table for random access reads. 143 204 pub(super) async fn cache_chapter_async( 144 205 &mut self, 145 206 k: &mut KernelHandle<'_>, ··· 150 211 return Ok(()); 151 212 } 152 213 153 - let dir_buf = self.cache_dir; 154 - let dir = cache::dir_name_str(&dir_buf); 214 + let cf = self.cache_file; 215 + let cf_str = cache::cache_filename_str(&cf); 155 216 let entry_idx = self.spine.items[ch] as usize; 156 217 let entry = *self.zip.entry(entry_idx); 157 - let ch_file = cache::chapter_file_name(ch as u16); 158 - let ch_str = cache::chapter_file_str(&ch_file); 159 218 160 - // truncate stale data before streaming begins 161 - k.write_app_subdir(dir, ch_str, &[])?; 219 + // if this is the first chapter, create the file with a 220 + // placeholder header + empty chapter table so appends start 221 + // at the correct data offset 222 + if self.cache_chapter == 0 && ch == 0 { 223 + let spine_len = self.spine.len(); 224 + let mut init_buf = [0u8; cache::HEADER_SIZE]; 225 + // write a minimal header (will be overwritten by finish_cache) 226 + let mut hdr = cache::CacheHeader::empty(); 227 + hdr.version = cache::CACHE_V3; 228 + hdr.chapter_count = spine_len as u16; 229 + hdr.epub_size = self.archive_size; 230 + hdr.name_hash = self.name_hash; 231 + cache::encode_v3_header(&hdr, &mut init_buf); 232 + k.write_cache(cf_str, &init_buf)?; 233 + // pad with zeroes for the chapter table 234 + let tbl_size = spine_len * cache::CHAPTER_ENTRY_SIZE; 235 + let zeros = [0u8; 64]; 236 + let mut remaining = tbl_size; 237 + while remaining > 0 { 238 + let chunk = remaining.min(zeros.len()); 239 + k.append_cache(cf_str, &zeros[..chunk])?; 240 + remaining -= chunk; 241 + } 242 + } 243 + 244 + // record the offset where this chapter's data starts 245 + let ch_offset = k.cache_file_size(cf_str)?; 246 + self.chapter_table[ch].0 = ch_offset; 162 247 163 248 let k_cell = RefCell::new(&mut *k); 164 249 165 250 let mut reader = CellReader(&k_cell, epub_name); 166 - let mut writer = CellWriter(&k_cell, dir, ch_str); 251 + let mut writer = CellWriter(&k_cell, cf_str); 167 252 168 253 let text_size = smol_epub::async_io::stream_strip_entry_async( 169 254 &entry, ··· 174 259 .await 175 260 .map_err(|msg| Error::from(msg).with_source("cache_chapter_async: stream"))?; 176 261 177 - self.chapter_sizes[ch] = text_size; 262 + self.chapter_table[ch] = (ch_offset, text_size); 178 263 self.ch_cached[ch] = true; 179 264 180 265 log::info!( 181 - "epub: cached ch{}/{} = {} bytes", 266 + "epub: cached ch{}/{} = {} bytes at offset {}", 182 267 ch, 183 268 self.spine.len(), 184 - text_size 269 + text_size, 270 + ch_offset, 185 271 ); 186 272 Ok(()) 187 273 } ··· 192 278 } 193 279 194 280 let ch = self.chapter as usize; 195 - let ch_size = if ch < cache::MAX_CACHE_CHAPTERS { 196 - self.chapter_sizes[ch] as usize 281 + let (ch_off, ch_size_u32) = if ch < cache::MAX_CACHE_CHAPTERS { 282 + self.chapter_table[ch] 197 283 } else { 198 284 return false; 199 285 }; 286 + let ch_size = ch_size_u32 as usize; 200 287 201 288 if ch_size == 0 || ch_size > CHAPTER_CACHE_MAX { 202 289 self.ch_cache = Vec::new(); ··· 215 302 } 216 303 self.ch_cache.resize(ch_size, 0); 217 304 218 - let dir_buf = self.cache_dir; 219 - let dir = cache::dir_name_str(&dir_buf); 220 - let ch_file = cache::chapter_file_name(self.chapter); 221 - let ch_str = cache::chapter_file_str(&ch_file); 305 + let cf = self.cache_file; 306 + let cf_str = cache::cache_filename_str(&cf); 222 307 223 308 let mut pos = 0usize; 224 309 while pos < ch_size { 225 310 let chunk = (ch_size - pos).min(PAGE_BUF); 226 - match k.read_app_subdir_chunk( 227 - dir, 228 - ch_str, 229 - pos as u32, 311 + match k.read_cache_chunk( 312 + cf_str, 313 + ch_off + pos as u32, 230 314 &mut self.ch_cache[pos..pos + chunk], 231 315 ) { 232 316 Ok(n) if n > 0 => pos += n, ··· 376 460 377 461 let ch = self.epub.cache_chapter as usize; 378 462 if ch >= spine_len { 379 - let _ = self.epub.finish_cache(k); 463 + let _ = self.epub.finish_cache( 464 + k, 465 + &self.title[..self.title_len], 466 + &self.filename[..self.filename_len], 467 + ); 380 468 self.epub.img_cache_ch = self.epub.chapter; 381 469 self.epub.img_cache_offset = 0; 382 470 self.epub.img_scan_wrapped = false; 471 + self.epub.skip_large_img = false; 472 + self.epub.img_found_count = 0; 473 + self.epub.img_cached_count = 0; 383 474 self.epub.bg_cache = BgCacheState::CacheImage; 384 475 return; 385 476 } ··· 409 500 self.epub.bg_cache = BgCacheState::CacheChapter; 410 501 } 411 502 } 503 + Ok(None) if work_queue::is_idle() => { 504 + log::warn!("bg: worker idle with no result, recovering"); 505 + self.epub.bg_cache = BgCacheState::CacheChapter; 506 + } 412 507 Ok(None) => {} 413 508 Err(e) => { 414 509 log::warn!("bg: nearby image error: {}, continuing", e); ··· 434 529 } 435 530 BgCacheState::WaitImage => match self.epub_recv_image_result(k) { 436 531 Ok(Some(_)) => self.epub.bg_cache = BgCacheState::CacheImage, 532 + Ok(None) if work_queue::is_idle() => { 533 + log::warn!("bg: worker idle with no result, recovering"); 534 + self.epub.bg_cache = BgCacheState::CacheImage; 535 + } 437 536 Ok(None) => {} 438 537 Err(e) => { 439 538 log::warn!("bg: image recv error: {}", e);
+34 -9
src/apps/reader/images.rs
··· 16 16 use smol_epub::zip::{self, ZipIndex}; 17 17 18 18 use crate::error::{Error, ErrorKind}; 19 - use crate::kernel::KernelHandle; 20 19 use crate::kernel::work_queue; 20 + use crate::kernel::KernelHandle; 21 21 22 22 use super::{ 23 - DEFAULT_IMG_H, MAX_IMAGES_PER_PAGE, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, ReaderApp, 23 + ReaderApp, DEFAULT_IMG_H, MAX_IMAGES_PER_PAGE, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, 24 24 }; 25 25 26 26 fn from_smol_image(img: smol_epub::DecodedImage) -> DecodedImage { ··· 433 433 let (nb, nl) = self.name_copy(); 434 434 let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 435 435 436 - let ch_file = cache::chapter_file_name(ch as u16); 437 - let ch_str = cache::chapter_file_str(&ch_file); 436 + let cf = self.epub.cache_file; 437 + let cf_str = cache::cache_filename_str(&cf); 438 + let ch_base = self.epub.chapter_table[ch].0; 438 439 439 440 let mut offset = start_offset; 440 441 while offset < ch_size { 441 442 let read_len = PAGE_BUF.min(ch_size - offset); 442 - let n = k.read_app_subdir_chunk( 443 - dir, 444 - ch_str, 445 - offset as u32, 443 + let n = k.read_cache_chunk( 444 + cf_str, 445 + ch_base + offset as u32, 446 446 &mut self.pg.prefetch[..read_len], 447 447 )?; 448 448 if n == 0 { ··· 496 496 497 497 // already cached or skip-marked 498 498 if k.file_size_app_subdir(dir, img_file).is_ok() { 499 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 500 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 499 501 i = path_start + path_len; 500 502 continue; 501 503 } ··· 506 508 if !is_jpeg && !is_png { 507 509 log::info!("precache: skip unsupported: {}", full_path); 508 510 let _ = k.write_app_subdir(dir, img_file, &[]); 511 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 512 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 509 513 i = path_start + path_len; 510 514 continue; 511 515 } ··· 519 523 Some(idx) => idx, 520 524 None => { 521 525 log::warn!("precache: {} not in ZIP", full_path); 526 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 527 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 522 528 i = path_start + path_len; 523 529 continue; 524 530 } ··· 526 532 527 533 let entry = *self.epub.zip.entry(zip_idx); 528 534 529 - // large images: decode via streaming SD reads on main loop 535 + // large images: decode via streaming SD reads on main loop. 536 + // if a previous streaming decode failed (OOM), skip all 537 + // remaining large images so the device stays responsive 530 538 if entry.uncomp_size > PRECACHE_IMG_MAX { 539 + if self.epub.skip_large_img { 540 + let _ = k.write_app_subdir(dir, img_file, &[]); 541 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 542 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 543 + i = path_start + path_len; 544 + continue; 545 + } 546 + 531 547 log::info!( 532 548 "precache: streaming {} ({} bytes)", 533 549 full_path, ··· 566 582 Err(e) => { 567 583 log::warn!("precache: streaming failed: {}", e); 568 584 let _ = k.write_app_subdir(dir, img_file, &[]); 585 + // stop trying large images this session 586 + self.epub.skip_large_img = true; 569 587 } 570 588 } 589 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 590 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 571 591 return Ok(ScanResult::DecodedInline { 572 592 resume_offset: resume, 573 593 }); ··· 589 609 Err(e) => { 590 610 log::warn!("precache: extract failed: {}", e); 591 611 let _ = k.write_app_subdir(dir, img_file, &[]); 612 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 613 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 592 614 i = path_start + path_len; 593 615 continue; 594 616 } ··· 604 626 max_h: self.text_area_h, 605 627 }; 606 628 if work_queue::submit(self.epub.work_gen, task) { 629 + self.epub.img_found_count = self.epub.img_found_count.saturating_add(1); 607 630 return Ok(ScanResult::Dispatched { 608 631 resume_offset: resume, 609 632 }); ··· 682 705 Some(_) => return Ok(None), // stale generation; discard 683 706 None => return Ok(None), 684 707 }; 708 + 709 + self.epub.img_cached_count = self.epub.img_cached_count.saturating_add(1); 685 710 686 711 match result.outcome { 687 712 work_queue::WorkOutcome::ImageReady { path_hash, image } => {
+71 -17
src/apps/reader/mod.rs
··· 239 239 pub(super) spine: EpubSpine, 240 240 pub(super) chapter: u16, 241 241 242 + pub(super) cache_file: [u8; 12], 242 243 pub(super) cache_dir: [u8; 8], 243 - chapter_sizes: [u32; cache::MAX_CACHE_CHAPTERS], 244 + pub(super) chapter_table: [(u32, u32); cache::MAX_CACHE_CHAPTERS], 244 245 pub(super) chapters_cached: bool, 245 246 pub(super) cache_chapter: u16, 246 247 pub(super) ch_cached: [bool; cache::MAX_CACHE_CHAPTERS], ··· 252 253 pub(super) img_cache_ch: u16, 253 254 pub(super) img_cache_offset: u32, 254 255 pub(super) img_scan_wrapped: bool, 256 + pub(super) skip_large_img: bool, 257 + pub(super) img_found_count: u16, 258 + pub(super) img_cached_count: u16, 255 259 256 260 pub(super) toc: EpubToc, 257 261 pub(super) toc_source: Option<TocSource>, ··· 270 274 meta: EpubMeta::new(), 271 275 spine: EpubSpine::new(), 272 276 chapter: 0, 277 + cache_file: [0u8; 12], 273 278 cache_dir: [0u8; 8], 274 279 name_hash: 0, 275 280 archive_size: 0, 276 - chapter_sizes: [0u32; cache::MAX_CACHE_CHAPTERS], 281 + chapter_table: [(0u32, 0u32); cache::MAX_CACHE_CHAPTERS], 277 282 chapters_cached: false, 278 283 cache_chapter: 0, 279 284 ch_cached: [false; cache::MAX_CACHE_CHAPTERS], ··· 283 288 img_cache_ch: 0, 284 289 img_cache_offset: 0, 285 290 img_scan_wrapped: false, 291 + skip_large_img: false, 292 + img_found_count: 0, 293 + img_cached_count: 0, 286 294 toc: EpubToc::new(), 287 295 toc_source: None, 288 296 toc_selected: 0, ··· 291 299 } 292 300 293 301 #[inline] 302 + pub(super) fn cache_file_str(&self) -> &str { 303 + cache::cache_filename_str(&self.cache_file) 304 + } 305 + 306 + #[inline] 294 307 pub(super) fn cache_dir_str(&self) -> &str { 295 308 cache::dir_name_str(&self.cache_dir) 296 309 } ··· 298 311 #[inline] 299 312 pub(super) fn chapter_size(&self, ch: usize) -> u32 { 300 313 if ch < cache::MAX_CACHE_CHAPTERS { 301 - self.chapter_sizes[ch] 314 + self.chapter_table[ch].1 302 315 } else { 303 316 0 304 317 } ··· 441 454 self.epub.ch_cached[..n].iter().filter(|&&c| c).count() 442 455 } 443 456 444 - // update the kernel loading indicator with current caching progress 457 + // update the kernel loading indicator with current caching progress. 458 + // uses a unified percentage: chapters contribute 0-80%, images 80-100%. 445 459 fn set_cache_loading(&self, ctx: &mut AppContext) { 446 - let cached = self.cached_chapter_count(); 447 - let total = self.epub.spine.len(); 460 + let cached_ch = self.cached_chapter_count(); 461 + let total_ch = self.epub.spine.len(); 462 + let img_found = self.epub.img_found_count as usize; 463 + let img_cached = self.epub.img_cached_count as usize; 464 + 448 465 let mut lbuf = StackFmt::<28>::new(); 449 - if matches!( 466 + 467 + let in_chapter_phase = matches!( 450 468 self.epub.bg_cache, 451 469 BgCacheState::CacheChapter | BgCacheState::WaitNearbyImage 452 - ) && cached < total 453 - { 454 - let _ = write!(lbuf, "Caching {}/{}", cached, total); 455 - } else { 456 - let _ = write!(lbuf, "Caching image(s)"); 457 - } 458 - let pct = if total > 0 { 459 - ((cached * 100) / total).min(100) as u8 470 + ) && cached_ch < total_ch; 471 + 472 + let pct = if in_chapter_phase { 473 + let _ = write!(lbuf, "Caching {}/{}", cached_ch, total_ch); 474 + // chapters: 0% to 80% 475 + if total_ch > 0 { 476 + ((cached_ch * 80) / total_ch).min(80) as u8 477 + } else { 478 + 80 479 + } 460 480 } else { 461 - 100 481 + // image phase: 80% to 100% 482 + if img_found > 0 { 483 + let _ = write!( 484 + lbuf, 485 + "Caching images {}/{}", 486 + img_cached, img_found 487 + ); 488 + (80 + (img_cached * 20) / img_found).min(100) as u8 489 + } else { 490 + let _ = write!(lbuf, "Caching images"); 491 + 80 492 + } 462 493 }; 494 + 463 495 ctx.set_loading(LOADING_REGION, lbuf.as_str(), pct); 464 496 } 465 497 ··· 482 514 self.epub.bg_cache = BgCacheState::CacheChapter; 483 515 } 484 516 } 517 + Ok(None) if work_queue::is_idle() => { 518 + log::warn!("bg: worker idle with no result (suspended), recovering"); 519 + self.epub.bg_cache = BgCacheState::CacheChapter; 520 + } 485 521 Ok(None) => {} 486 522 Err(e) => { 487 523 log::warn!("bg: nearby image error (suspended): {}", e); ··· 490 526 }, 491 527 BgCacheState::WaitImage => match self.epub_recv_image_result(k) { 492 528 Ok(Some(_)) => self.epub.bg_cache = BgCacheState::CacheImage, 529 + Ok(None) if work_queue::is_idle() => { 530 + log::warn!("bg: worker idle with no result (suspended), recovering"); 531 + self.epub.bg_cache = BgCacheState::CacheImage; 532 + } 493 533 Ok(None) => {} 494 534 Err(e) => { 495 535 log::warn!("bg: image recv error (suspended): {}", e); ··· 793 833 self.epub.bg_cache = BgCacheState::Idle; 794 834 self.epub.ch_cached = [false; cache::MAX_CACHE_CHAPTERS]; 795 835 self.epub.img_scan_wrapped = false; 836 + self.epub.skip_large_img = false; 796 837 797 838 self.is_epub = epub::is_epub_filename(self.name()); 798 839 self.rebuild_quick_actions(); ··· 1111 1152 } 1112 1153 let prev_count = self.cached_chapter_count(); 1113 1154 let prev_bg = self.epub.bg_cache; 1155 + let prev_img_found = self.epub.img_found_count; 1156 + let prev_img_cached = self.epub.img_cached_count; 1114 1157 self.bg_cache_step(k).await; 1115 1158 if self.epub.bg_cache == BgCacheState::Idle { 1116 1159 ctx.clear_loading(); 1117 - } else if self.cached_chapter_count() != prev_count || self.epub.bg_cache != prev_bg { 1160 + } else if self.cached_chapter_count() != prev_count 1161 + || self.epub.bg_cache != prev_bg 1162 + || self.epub.img_found_count != prev_img_found 1163 + || self.epub.img_cached_count != prev_img_cached 1164 + { 1118 1165 self.set_cache_loading(ctx); 1119 1166 } 1120 1167 } ··· 1392 1439 let total = self.epub.spine.len(); 1393 1440 if cached < total { 1394 1441 let _ = write!(sbuf, " [{}/{}]", cached, total); 1442 + } else if self.epub.img_found_count > 0 { 1443 + let _ = write!( 1444 + sbuf, 1445 + " [img {}/{}]", 1446 + self.epub.img_cached_count, 1447 + self.epub.img_found_count, 1448 + ); 1395 1449 } else { 1396 1450 let _ = write!(sbuf, " [img]"); 1397 1451 }
+12 -14
src/apps/reader/paging.rs
··· 1 1 // text wrapping, page navigation, and load/prefetch 2 2 3 - use smol_epub::cache; 4 3 use smol_epub::html_strip::{ 5 4 BOLD_OFF, BOLD_ON, HEADING_OFF, HEADING_ON, IMG_REF, ITALIC_OFF, ITALIC_ON, MARKER, QUOTE_OFF, 6 5 QUOTE_ON, ··· 10 9 use crate::kernel::KernelHandle; 11 10 12 11 use super::{ 13 - DEFAULT_IMG_H, INDENT_PX, LINES_PER_PAGE, LineSpan, MAX_PAGES, NO_PREFETCH, PAGE_BUF, 14 - ReaderApp, State, decode_utf8_char, 12 + decode_utf8_char, LineSpan, ReaderApp, State, DEFAULT_IMG_H, INDENT_PX, LINES_PER_PAGE, 13 + MAX_PAGES, NO_PREFETCH, PAGE_BUF, 15 14 }; 16 15 17 16 impl ReaderApp { ··· 133 132 self.pg.prefetch_page = NO_PREFETCH; 134 133 self.pg.prefetch_len = 0; 135 134 } else if self.is_epub && self.epub.chapters_cached { 136 - let dir = self.epub.cache_dir_str(); 137 - let ch_file = cache::chapter_file_name(self.epub.chapter); 138 - let ch_str = cache::chapter_file_str(&ch_file); 139 - let n = k.read_app_subdir_chunk( 140 - dir, 141 - ch_str, 142 - self.pg.offsets[self.pg.page], 135 + let cf_str = self.epub.cache_file_str(); 136 + let ch = self.epub.chapter as usize; 137 + let ch_base = self.epub.chapter_table[ch].0; 138 + let n = k.read_cache_chunk( 139 + cf_str, 140 + ch_base + self.pg.offsets[self.pg.page], 143 141 &mut self.pg.buf, 144 142 )?; 145 143 self.pg.buf_len = n; ··· 179 177 if self.pg.page + 1 < self.pg.total_pages { 180 178 let pf_offset = self.pg.offsets[self.pg.page + 1]; 181 179 let pf_result = if self.is_epub && self.epub.chapters_cached { 182 - let dir = self.epub.cache_dir_str(); 183 - let ch_file = cache::chapter_file_name(self.epub.chapter); 184 - let ch_str = cache::chapter_file_str(&ch_file); 185 - k.read_app_subdir_chunk(dir, ch_str, pf_offset, &mut self.pg.prefetch) 180 + let cf_str = self.epub.cache_file_str(); 181 + let ch = self.epub.chapter as usize; 182 + let ch_base = self.epub.chapter_table[ch].0; 183 + k.read_cache_chunk(cf_str, ch_base + pf_offset, &mut self.pg.prefetch) 186 184 } else { 187 185 k.read_chunk(name, pf_offset, &mut self.pg.prefetch) 188 186 };