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.

fix: cargo fmt after comment cleanup

hans ac48cc74 1067c946

+31 -404
+1 -18
src/apps/bookmarks.rs
··· 1 - // RAM-resident bookmark cache: 16 slots x 48 bytes held permanently in RAM (~780B). 2 - // Loaded from SD once; reads served from RAM with zero SD I/O. 3 - // Writes mark dirty; flush() persists to SD only when dirty. 1 + // Bookmark cache: 16 slots, RAM-resident, flushed to SD on dirty. 4 2 // 5 3 // Record layout (little-endian, 48 bytes per slot): 6 4 // [0..4) name_hash u32 ··· 16 14 use crate::drivers::storage; 17 15 pub use smol_epub::cache::fnv1a; 18 16 19 - // case-insensitive FNV-1a; FAT filenames are case-insensitive 20 17 fn fnv1a_icase(data: &[u8]) -> u32 { 21 18 let mut h: u32 = 0x811c_9dc5; 22 19 for &b in data { ··· 105 102 } 106 103 } 107 104 108 - // lightweight bookmark list entry (35B vs 48B for BookmarkSlot) 109 105 #[derive(Clone, Copy)] 110 106 pub struct BmListEntry { 111 107 pub filename: [u8; FILENAME_CAP], ··· 125 121 } 126 122 } 127 123 128 - // in-memory bookmark table; loaded once, reads from RAM, writes mark dirty (~780B) 129 124 pub struct BookmarkCache { 130 125 slots: [BookmarkSlot; SLOTS], 131 126 count: usize, // slots present in file; new saves past this extend count ··· 149 144 } 150 145 } 151 146 152 - // true if in-memory state has changed since the last flush 153 147 pub fn is_dirty(&self) -> bool { 154 148 self.dirty 155 149 } ··· 158 152 self.loaded 159 153 } 160 154 161 - // read bookmark file from SD; idempotent; no-op if already loaded 162 155 pub fn ensure_loaded<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 163 156 if self.loaded { 164 157 return; ··· 166 159 self.force_load(sd); 167 160 } 168 161 169 - // reload from SD, discarding in-memory changes 170 162 pub fn force_load<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 171 163 let mut buf = [0u8; FILE_LEN]; 172 164 let slot_count = match storage::read_pulp_file_start(sd, BOOKMARK_FILE, &mut buf) { ··· 205 197 None 206 198 } 207 199 208 - // copy valid bookmarks into out, sorted by generation descending; return count written 209 200 pub fn load_all(&self, out: &mut [BmListEntry]) -> usize { 210 201 if !self.loaded { 211 202 return 0; 212 203 } 213 204 214 - // collect valid entries with their generation for sorting 215 205 let mut gens = [0u16; SLOTS]; 216 206 let mut count = 0usize; 217 207 ··· 231 221 } 232 222 } 233 223 234 - // insertion sort by generation descending (most recent first) 235 224 for i in 1..count { 236 225 let key_gen = gens[i]; 237 226 let key_entry = out[i]; ··· 248 237 count 249 238 } 250 239 251 - // save bookmark; update cache + mark dirty; call flush() to persist. 252 - // handles LRU eviction, generation increment, hash+name matching. 253 240 pub fn save(&mut self, filename: &[u8], byte_offset: u32, chapter: u16) { 254 241 if !self.loaded { 255 242 log::warn!("bookmarks: save called before load, ignoring"); ··· 258 245 259 246 let key = fnv1a_icase(filename); 260 247 261 - // scan for: target slot, max generation, first free, LRU 262 248 let mut max_gen: u16 = 0; 263 249 let mut target: Option<usize> = None; 264 250 let mut first_free: Option<usize> = None; ··· 311 297 312 298 self.slots[write_slot] = new_slot; 313 299 314 - // extend count if we wrote past the current end 315 300 if write_slot >= self.count { 316 301 self.count = write_slot + 1; 317 302 } ··· 327 312 ); 328 313 } 329 314 330 - // write cache to SD if dirty; no-op if clean; 768B stack buffer 331 315 pub fn flush<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 332 316 if !self.dirty || !self.loaded { 333 317 return; ··· 349 333 } 350 334 Err(e) => { 351 335 log::warn!("bookmarks: flush failed: {}", e); 352 - // leave dirty=true so we retry next time 353 336 } 354 337 } 355 338 }
-6
src/apps/files.rs
··· 1 1 // Paginated file browser for SD card root directory. 2 - // In-page scroll marks two rows dirty; cross-page sets needs_load 3 - // and defers to AppWork for SD read + render decision. 4 2 5 3 use core::fmt::Write as _; 6 4 ··· 19 17 20 18 const PAGE_SIZE: usize = 7; 21 19 22 - // centered list column: 448px wide, 16px margins 23 20 const LIST_X: u16 = 16; 24 21 const LIST_W: u16 = 448; 25 22 ··· 127 124 self.scroll = self.scroll.saturating_sub(1); 128 125 self.needs_load = true; 129 126 } else if self.total > 0 { 130 - // wrap to bottom 131 127 self.scroll = self.total.saturating_sub(PAGE_SIZE); 132 128 self.selected = self.total.saturating_sub(self.scroll) - 1; 133 129 self.needs_load = true; ··· 144 140 self.scroll += 1; 145 141 self.needs_load = true; 146 142 } else if self.total > 0 { 147 - // wrap to top 148 143 self.scroll = 0; 149 144 self.selected = 0; 150 145 self.needs_load = true; ··· 334 329 .draw(strip) 335 330 .unwrap(); 336 331 } else { 337 - // clear phantom rows below list end 338 332 region 339 333 .to_rect() 340 334 .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
+1 -8
src/apps/home.rs
··· 1 - // Launcher screen, entry point after boot. 2 - // Menu: Continue (if recent) / Files / Bookmarks / Settings / Upload. 3 - // Bookmarks shows a scrollable list of saved positions, most-recent-first. 1 + // Launcher screen: menu, bookmarks browser. 4 2 5 3 use core::fmt::Write as _; 6 4 ··· 19 17 use crate::fonts::bitmap::byte_to_char; 20 18 use crate::ui::{Alignment, BUTTON_BAR_H, BitmapDynLabel, BitmapLabel, CONTENT_TOP, Region}; 21 19 22 - // menu layout constants 23 20 const ITEM_W: u16 = 280; 24 21 const ITEM_H: u16 = 52; 25 22 const ITEM_GAP: u16 = 14; ··· 28 25 const TITLE_ITEM_GAP: u16 = 24; 29 26 const MAX_ITEMS: usize = 5; 30 27 31 - // bookmark list layout 32 28 const BM_MARGIN: u16 = 8; 33 29 const BM_HEADER_GAP: u16 = 4; 34 30 const BM_BOTTOM: u16 = SCREEN_H - BUTTON_BAR_H; ··· 64 60 item_regions: [Region; MAX_ITEMS], 65 61 item_count: usize, 66 62 67 - // recent book for "Continue" button 68 63 recent_book: [u8; 32], 69 64 recent_book_len: usize, 70 65 needs_load_recent: bool, 71 66 72 - // bookmark browser state 73 67 bm_entries: [BmListEntry; bookmarks::SLOTS], 74 68 bm_count: usize, 75 69 bm_selected: usize, ··· 110 104 self.item_regions = compute_item_regions(self.heading_font.line_height); 111 105 } 112 106 113 - // called once at boot before first render 114 107 pub fn load_recent<SPI: embedded_hal::spi::SpiDevice>( 115 108 &mut self, 116 109 services: &mut Services<'_, SPI>,
+1 -12
src/apps/mod.rs
··· 1 - // Application framework and launcher. 2 - // App trait + nav stack (max 4). No dyn, no heap. 3 - // needs_work()/on_work() decouple SD I/O from rendering. 4 - // Services is the syscall boundary into storage. 1 + // App trait, nav stack, and Services syscall boundary. 5 2 6 3 pub mod bookmarks; 7 4 pub mod files; ··· 28 25 Upload, 29 26 } 30 27 31 - // nav transitions: Push suspends current + pushes new; Pop exits + resumes parent; 32 - // Replace swaps in place; Home unwinds to root. 33 28 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 34 29 pub enum Transition { 35 30 None, ··· 46 41 Full, 47 42 } 48 43 49 - // 8.3 filename or short path context message 50 44 const MSG_BUF_SIZE: usize = 64; 51 45 52 46 pub struct AppContext { ··· 118 112 } 119 113 } 120 114 121 - // each call re-opens volume/dir/file; reader uses prefetch to amortize cost 122 115 pub struct Services<'a, SPI: embedded_hal::spi::SpiDevice> { 123 116 dir_cache: &'a mut DirCache, 124 117 bookmarks: &'a mut BookmarkCache, ··· 194 187 storage::read_file_chunk_in_dir(self.sd, dir, name, offset, buf) 195 188 } 196 189 197 - // _PULP app-data directory 198 - 199 190 pub fn ensure_pulp_dir(&self) -> Result<(), &'static str> { 200 191 storage::ensure_pulp_dir(self.sd) 201 192 } ··· 221 212 storage::write_pulp_file(self.sd, name, data) 222 213 } 223 214 224 - // nested ops inside _PULP/<dir>/ 225 215 pub fn ensure_pulp_subdir(&self, name: &str) -> Result<(), &'static str> { 226 216 storage::ensure_pulp_subdir(self.sd, name) 227 217 } ··· 272 262 273 263 fn on_quick_cycle_update(&mut self, _id: u8, _value: u8, _ctx: &mut AppContext) {} 274 264 275 - // called once per strip during refresh 276 265 fn draw(&self, strip: &mut StripBuffer); 277 266 278 267 fn needs_work(&self) -> bool {
+8 -101
src/apps/reader.rs
··· 1 - // Plain text and EPUB reader. 2 - // TXT: lazy page-indexed with prefetch. EPUB: ZIP/OPF parsed once, 3 - // chapters stream-decompressed + HTML-stripped to SD cache; then both 4 - // formats read identically. Cache keyed on file size + name hash. 1 + // Reader: TXT (lazy page-indexed) or EPUB (ZIP/OPF → SD cache). 5 2 6 3 extern crate alloc; 7 4 ··· 54 51 const TEXT_W: u32 = (SCREEN_W - 2 * MARGIN) as u32; 55 52 const TEXT_AREA_H: u16 = SCREEN_H - TEXT_Y - BUTTON_BAR_H; 56 53 const EOCD_TAIL: usize = 512; 57 - const INDENT_PX: u32 = 24; // px per blockquote indent level 58 - 59 - // fixed display height for inline images; scaled to fit TEXT_W x IMAGE_DISPLAY_H 54 + const INDENT_PX: u32 = 24; 60 55 const IMAGE_DISPLAY_H: u16 = 200; 61 - 62 - // when chapter stripped text fits in this limit, load into RAM once; 63 - // page turns become zero-SD-I/O memcpy + word-wrap (~96KB covers most chapters) 64 56 const CHAPTER_CACHE_MAX: usize = 98304; 65 57 66 58 const PROGRESS_H: u16 = 2; 67 59 const PROGRESS_Y: u16 = SCREEN_H - PROGRESS_H - 1; 68 60 const PROGRESS_W: u16 = SCREEN_W - 2 * MARGIN; 69 61 70 - // position overlay: centered banner shown while Next/Prev is held 71 62 const POSITION_OVERLAY_W: u16 = 280; 72 63 const POSITION_OVERLAY_H: u16 = 40; 73 64 const POSITION_OVERLAY: Region = Region::new( ··· 107 98 struct LineSpan { 108 99 start: u16, 109 100 len: u16, 110 - flags: u8, // bit 0 = bold, bit 1 = italic, bit 2 = heading 111 - indent: u8, // blockquote indent depth (0 = none) 101 + flags: u8, 102 + indent: u8, 112 103 } 113 104 114 105 impl LineSpan { ··· 122 113 const FLAG_BOLD: u8 = 1 << 0; 123 114 const FLAG_ITALIC: u8 = 1 << 1; 124 115 const FLAG_HEADING: u8 = 1 << 2; 125 - // first line of an inline image block; start/len point to src path in buf. 126 - // continuation lines have FLAG_IMAGE set with len == 0. 116 + // image origin: len > 0, start/len = src path; continuation lines: len == 0 127 117 const FLAG_IMAGE: u8 = 1 << 3; 128 118 129 119 #[inline] ··· 131 121 self.flags & Self::FLAG_IMAGE != 0 132 122 } 133 123 134 - // true for the first line of an image block (carries the path) 135 124 #[inline] 136 125 fn is_image_origin(&self) -> bool { 137 126 self.is_image() && self.len > 0 ··· 185 174 error: Option<&'static str>, 186 175 show_position: bool, 187 176 188 - // EPUB state 189 177 is_epub: bool, 190 178 zip: ZipIndex, 191 179 meta: EpubMeta, ··· 194 182 goto_last_page: bool, 195 183 restore_offset: Option<u32>, 196 184 197 - // EPUB chapter cache (SD-backed) 198 185 cache_dir: [u8; 8], 199 186 epub_name_hash: u32, 200 187 epub_file_size: u32, ··· 202 189 chapters_cached: bool, 203 190 cache_chapter: u16, 204 191 205 - // RAM chapter cache: entire chapter held in heap; page turns are 206 - // zero-SD-I/O memcpy + word-wrap. cleared on chapter change/exit. 207 192 ch_cache: Vec<u8>, 208 - 209 - // decoded image for current page; cleared on page turn/chapter change 210 193 page_img: Option<DecodedImage>, 211 - 212 - // table of contents 213 194 toc: EpubToc, 214 195 toc_source: Option<TocSource>, 215 196 toc_selected: usize, 216 197 toc_scroll: usize, 217 198 218 - // fonts (None = FONT_6X13 fallback) 219 199 fonts: Option<fonts::FontSet>, 220 200 font_line_h: u16, 221 201 font_ascent: u16, 222 202 max_lines: usize, 223 203 224 - // persisted font preference; set by main before on_enter 225 204 book_font_size_idx: u8, 226 205 applied_font_idx: u8, 227 206 228 - // chrome font for header/status/loading text 229 207 chrome_font: Option<&'static BitmapFont>, 230 - 231 - // quick-action buffer (rebuilt on state changes) 232 208 qa_buf: [QuickAction; QA_MAX], 233 209 qa_count: usize, 234 210 } ··· 306 282 self.rebuild_quick_actions(); 307 283 } 308 284 309 - // set chrome font; called from main on UI font size change 310 285 pub fn set_chrome_font(&mut self, font: &'static BitmapFont) { 311 286 self.chrome_font = Some(font); 312 287 } ··· 322 297 ); 323 298 n += 1; 324 299 325 - // chapter nav only for multi-chapter EPUBs 326 300 if self.is_epub && self.spine.len() > 1 { 327 301 self.qa_buf[n] = QuickAction::trigger(QA_PREV_CHAPTER, "Prev Ch", "<<<"); 328 302 n += 1; ··· 338 312 self.qa_count = n; 339 313 } 340 314 341 - // reinit font metrics from book_font_size_idx 342 315 fn apply_font_metrics(&mut self) { 343 316 self.fonts = None; 344 317 self.font_line_h = LINE_H; ··· 415 388 let spine_len = self.spine.len() as u64; 416 389 let ch = self.chapter as u64; 417 390 418 - // last page of last chapter = 100% 419 391 if ch + 1 >= spine_len && self.fully_indexed && self.page + 1 >= self.total_pages { 420 392 return 100; 421 393 } 422 394 423 - // within-chapter progress (0-100) 424 395 let in_ch = if self.file_size == 0 { 425 396 0u64 426 397 } else { ··· 429 400 ((pos * 100) / size).min(100) 430 401 }; 431 402 432 - // overall: (chapter * 100 + in_chapter_pct) / spine_len 433 403 let overall = (ch * 100 + in_ch) / spine_len; 434 404 return overall.min(100) as u8; 435 405 } 436 406 437 - // TXT fallback 438 407 if self.file_size == 0 { 439 408 return 100; 440 409 } ··· 528 497 &mut self, 529 498 svc: &mut Services<'_, SPI>, 530 499 ) -> Result<(), &'static str> { 531 - // fast path: chapter in RAM; memcpy + wrap, zero SD I/O 532 500 if !self.ch_cache.is_empty() { 533 501 let start = (self.offsets[self.page] as usize).min(self.ch_cache.len()); 534 502 let end = (start + PAGE_BUF).min(self.ch_cache.len()); ··· 539 507 self.buf_len = n; 540 508 self.prefetch_page = NO_PREFETCH; 541 509 self.prefetch_len = 0; 542 - // offsets already known from preindex_all_pages 543 510 self.wrap_lines_counted(n); 544 511 self.decode_page_images(svc); 545 512 return Ok(()); ··· 549 516 let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 550 517 551 518 if self.prefetch_page == self.page { 552 - // prefetch hit 553 519 core::mem::swap(&mut self.buf, &mut self.prefetch); 554 520 self.buf_len = self.prefetch_len; 555 521 self.prefetch_page = NO_PREFETCH; ··· 623 589 Ok(()) 624 590 } 625 591 626 - // scan current page for image-origin lines, decode first image into page_img 627 592 fn decode_page_images<SPI: embedded_hal::spi::SpiDevice>( 628 593 &mut self, 629 594 svc: &mut Services<'_, SPI>, ··· 634 599 return; 635 600 } 636 601 637 - // find first image-origin line; copy src path to local buf to 638 - // avoid borrowing self.buf across &mut self calls below 602 + // copy src path to local buf to avoid borrowing self.buf below 639 603 let mut src_buf = [0u8; 128]; 640 604 let mut src_len = 0usize; 641 605 for i in 0..self.line_count { ··· 662 626 663 627 log::info!("reader: decoding image: {}", src_str); 664 628 665 - // resolve src path against chapter's directory 666 629 let ch_zip_idx = self.spine.items[self.chapter as usize] as usize; 667 630 let ch_path = self.zip.entry_name(ch_zip_idx); 668 631 let ch_dir = ch_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); ··· 674 637 Err(_) => return, 675 638 }; 676 639 677 - // try SD image cache first 678 640 let dir_buf = self.cache_dir; 679 641 let dir = cache::dir_name_str(&dir_buf); 680 642 let img_name = img_cache_name(cache::fnv1a(full_path.as_bytes())); ··· 691 653 return; 692 654 } 693 655 694 - // cache miss; decode from ZIP 695 656 let zip_idx = match self 696 657 .zip 697 658 .find(full_path) ··· 708 669 let (nb, nl) = self.name_copy(); 709 670 let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 710 671 711 - // absolute byte offset of the entry's raw data 712 672 let data_offset = { 713 673 let mut hdr = [0u8; 30]; 714 674 if svc ··· 727 687 } 728 688 }; 729 689 730 - // detect format from extension; fall back to magic bytes for STORED entries 731 690 let ext_jpeg = full_path.ends_with(".jpg") 732 691 || full_path.ends_with(".jpeg") 733 692 || full_path.ends_with(".JPG") ··· 754 713 return; 755 714 } 756 715 757 - // free chapter RAM cache to maximise heap for image decode 758 716 if !self.ch_cache.is_empty() { 759 717 log::info!( 760 718 "reader: releasing {} KB chapter cache for image decode", ··· 764 722 } 765 723 766 724 let result = if is_jpeg && entry.method == zip::METHOD_STORED { 767 - // stored JPEG: stream directly from SD 768 725 let svc_ref = &*svc; 769 726 smol_epub::jpeg::decode_jpeg_sd( 770 727 |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), ··· 774 731 IMAGE_DISPLAY_H, 775 732 ) 776 733 } else if is_jpeg { 777 - // deflate JPEG: stream-decompress + decode 778 734 let svc_ref = &*svc; 779 735 smol_epub::jpeg::decode_jpeg_deflate_sd( 780 736 |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), ··· 785 741 IMAGE_DISPLAY_H, 786 742 ) 787 743 } else if entry.method == zip::METHOD_STORED { 788 - // stored PNG: stream directly from SD 789 744 let svc_ref = &*svc; 790 745 smol_epub::png::decode_png_sd( 791 746 |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), ··· 795 750 IMAGE_DISPLAY_H, 796 751 ) 797 752 } else { 798 - // deflate PNG: stream-decompress + decode 799 753 let svc_ref = &*svc; 800 754 smol_epub::png::decode_png_deflate_sd( 801 755 |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), ··· 827 781 } 828 782 } 829 783 830 - // parse ZIP EOCD + central directory; heap freed on return 831 784 fn epub_init_zip<SPI: embedded_hal::spi::SpiDevice>( 832 785 &mut self, 833 786 svc: &mut Services<'_, SPI>, ··· 870 823 Ok(()) 871 824 } 872 825 873 - // container.xml -> OPF -> spine + metadata; heap freed between steps 874 826 fn epub_init_opf<SPI: embedded_hal::spi::SpiDevice>( 875 827 &mut self, 876 828 svc: &mut Services<'_, SPI>, ··· 927 879 self.title[..n].copy_from_slice(&self.meta.title[..n]); 928 880 self.title_len = n; 929 881 930 - // persist title mapping for Files view 931 882 if let Err(e) = svc.save_title(name, self.meta.title_str()) { 932 883 log::warn!("epub: failed to save title mapping: {}", e); 933 884 } ··· 938 889 Ok(()) 939 890 } 940 891 941 - // Ok(true) = cache hit; Ok(false) = miss (subdir created, cache_chapter=0) 942 892 fn epub_check_cache<SPI: embedded_hal::spi::SpiDevice>( 943 893 &mut self, 944 894 svc: &mut Services<'_, SPI>, ··· 946 896 let dir_buf = self.cache_dir; 947 897 let dir = cache::dir_name_str(&dir_buf); 948 898 949 - // read META.BIN into self.buf (already owned) to avoid 950 - // ~2KB of stack temporaries that overflowed under esp-rtos 899 + // read into self.buf to avoid ~2KB stack temporaries (esp-rtos overflow) 951 900 let meta_cap = cache::META_MAX_SIZE.min(self.buf.len()); 952 901 if let Ok(n) = svc.read_pulp_sub_chunk(dir, cache::META_FILE, 0, &mut self.buf[..meta_cap]) 953 902 && let Ok(count) = cache::parse_cache_meta( ··· 969 918 Ok(false) 970 919 } 971 920 972 - // decompress + strip one chapter to SD; ~47KB heap freed on return. 973 - // Ok(true) = more remain; Ok(false) = all done (META.BIN written). 974 921 fn epub_cache_one_chapter<SPI: embedded_hal::spi::SpiDevice>( 975 922 &mut self, 976 923 svc: &mut Services<'_, SPI>, ··· 1053 1000 ); 1054 1001 } 1055 1002 1056 - // load current chapter into ch_cache; returns true on success, false = fall back to paged SD 1057 1003 fn try_cache_chapter<SPI: embedded_hal::spi::SpiDevice>( 1058 1004 &mut self, 1059 1005 svc: &mut Services<'_, SPI>, ··· 1074 1020 return false; 1075 1021 } 1076 1022 1077 - // reuse existing buffer if it already holds this chapter's data 1078 - // (e.g. font-size change -> NeedIndex for the same chapter) 1079 1023 if self.ch_cache.len() == ch_size { 1080 1024 log::info!("chapter cache: reusing {} bytes in RAM", ch_size); 1081 1025 return true; 1082 1026 } 1083 1027 1084 - // reserve exact capacity; bail on OOM 1085 1028 self.ch_cache = Vec::new(); 1086 1029 if self.ch_cache.try_reserve_exact(ch_size).is_err() { 1087 1030 log::info!("chapter cache: OOM for {} bytes", ch_size); ··· 1121 1064 true 1122 1065 } 1123 1066 1124 - // compute all page offsets from cached chapter text; CPU-only, no SD I/O 1125 1067 fn preindex_all_pages(&mut self) { 1126 1068 if self.ch_cache.is_empty() { 1127 1069 return; ··· 1268 1210 } 1269 1211 } 1270 1212 1271 - // helpers 1272 - 1273 1213 /// Decode one UTF-8 character starting at `buf[pos]` (a lead byte >= 0xC0) 1274 1214 /// and map the codepoint to a printable ASCII replacement. 1275 1215 /// Returns `(ascii_byte, byte_length_consumed)`. ··· 1338 1278 let mut sp: usize = 0; 1339 1279 let mut sp_px: u32 = 0; 1340 1280 1341 - // style state; carried across lines, updated by markers 1342 1281 let mut bold = false; 1343 1282 let mut italic = false; 1344 1283 let mut heading = false; ··· 1377 1316 while i < n { 1378 1317 let b = buf[i]; 1379 1318 1380 - // 2-byte style markers: [MARKER, tag]; zero width, update state 1381 1319 if b == MARKER && i + 1 < n { 1382 - // image reference: [MARKER, IMG_REF, len, path...] 1383 1320 if buf[i + 1] == IMG_REF && i + 2 < n { 1384 1321 let path_len = buf[i + 2] as usize; 1385 1322 let path_start = i + 3; 1386 1323 if path_start + path_len <= n && path_len > 0 { 1387 - // flush text accumulated on current line 1388 1324 if ls < i { 1389 1325 emit!(ls, i); 1390 1326 if lc >= max_l { ··· 1392 1328 } 1393 1329 } 1394 1330 1395 - // how many text-line slots does the image occupy? 1396 1331 let line_h = fonts.line_height(fonts::Style::Regular); 1397 1332 let img_lines = (IMAGE_DISPLAY_H / line_h).max(1) as usize; 1398 1333 1399 - // origin line; carries the src-path location 1400 1334 if lc < max_l { 1401 1335 lines[lc] = LineSpan { 1402 1336 start: path_start as u16, ··· 1407 1341 lc += 1; 1408 1342 } 1409 1343 1410 - // continuation lines (empty; reserve vertical space) 1411 1344 for _ in 1..img_lines { 1412 1345 if lc >= max_l { 1413 1346 break; ··· 1472 1405 continue; 1473 1406 } 1474 1407 1475 - // UTF-8 multi-byte: decode entire sequence, use replacement char advance 1408 + // UTF-8 multi-byte 1476 1409 if b >= 0xC0 { 1477 1410 let (repl, seq_len) = decode_utf8_to_ascii(buf, i); 1478 1411 let sty = current_style(bold, italic, heading); ··· 1580 1513 Ok(()) 1581 1514 } 1582 1515 1583 - // draw text with bitmap font (FONT_6X13 fallback), clearing region background 1584 1516 fn draw_chrome_text( 1585 1517 strip: &mut StripBuffer, 1586 1518 region: Region, ··· 1608 1540 } 1609 1541 } 1610 1542 1611 - // image cache helpers 1612 - 1613 - // 8.3 filename for a cached 1-bit image: IMXXXXXX.BIN (lower 24 bits of hash) 1614 1543 fn img_cache_name(hash: u32) -> [u8; 12] { 1615 1544 let h = hash & 0x00FF_FFFF; 1616 1545 let mut n = *b"IM000000.BIN"; ··· 1630 1559 core::str::from_utf8(buf).unwrap_or("IM000000.BIN") 1631 1560 } 1632 1561 1633 - // load a cached 1-bit image from SD; format: [u16 LE width][u16 LE height][1-bit data] 1634 1562 fn load_cached_image<SPI: embedded_hal::spi::SpiDevice>( 1635 1563 svc: &Services<'_, SPI>, 1636 1564 dir: &str, ··· 1669 1597 }) 1670 1598 } 1671 1599 1672 - // write a decoded 1-bit image to SD cache 1673 1600 fn save_cached_image<SPI: embedded_hal::spi::SpiDevice>( 1674 1601 svc: &Services<'_, SPI>, 1675 1602 dir: &str, ··· 1751 1678 self.apply_font_metrics(); 1752 1679 if font_changed { 1753 1680 self.reset_paging(); 1754 - // page offsets depend on line height; SD cache is font-independent 1755 1681 if self.is_epub && self.chapters_cached { 1756 1682 self.state = State::NeedIndex; 1757 1683 } else { ··· 1882 1808 1883 1809 State::NeedCacheChapter => match self.epub_cache_one_chapter(svc) { 1884 1810 Ok(true) => { 1885 - // all cached; update loading indicator 1886 1811 ctx.mark_dirty(LOADING_REGION); 1887 1812 } 1888 1813 Ok(false) => { ··· 1903 1828 1904 1829 self.epub_index_chapter(); 1905 1830 1906 - // try to load entire chapter into RAM; if it fits, 1907 - // preindex all pages (~5ms for 50KB) for zero-SD-I/O turns 1908 1831 if self.try_cache_chapter(svc) { 1909 1832 self.preindex_all_pages(); 1910 1833 } ··· 1929 1852 1930 1853 State::NeedPage => { 1931 1854 if let Some(target_off) = self.restore_offset.take() { 1932 - // restore: scan to saved byte offset 1933 1855 self.page = 0; 1934 1856 loop { 1935 1857 match self.load_and_prefetch(svc) { ··· 1976 1898 } 1977 1899 1978 1900 fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition { 1979 - // TOC navigation 1980 1901 if self.state == State::ShowToc { 1981 1902 match event { 1982 1903 ActionEvent::Press(Action::Back) => { ··· 2046 1967 } 2047 1968 } 2048 1969 2049 - // normal reader navigation 2050 1970 match event { 2051 1971 ActionEvent::Press(Action::Back) => Transition::Pop, 2052 1972 ActionEvent::LongPress(Action::Back) => Transition::Home, 2053 1973 2054 - // long press Next/Prev: rapid paging + position overlay 2055 1974 ActionEvent::LongPress(Action::Next) => { 2056 1975 if self.state == State::Ready { 2057 1976 self.show_position = true; ··· 2067 1986 Transition::None 2068 1987 } 2069 1988 2070 - // release clears position overlay 2071 1989 ActionEvent::Release(Action::Next) | ActionEvent::Release(Action::Prev) => { 2072 1990 if self.show_position { 2073 1991 self.show_position = false; ··· 2125 2043 log::info!("toc: opening ({} entries)", self.toc.len()); 2126 2044 self.toc_selected = 0; 2127 2045 self.toc_scroll = 0; 2128 - // pre-select current chapter 2129 2046 for i in 0..self.toc.len() { 2130 2047 if self.toc.entries[i].spine_idx == self.chapter { 2131 2048 self.toc_selected = i; ··· 2228 2145 2229 2146 if self.state != State::Ready && self.state != State::Error && self.state != State::ShowToc 2230 2147 { 2231 - // loading indicator during work states 2232 2148 let mut lbuf = StackFmt::<48>::new(); 2233 2149 match self.state { 2234 2150 State::NeedCache | State::NeedCacheChapter => { ··· 2259 2175 return; 2260 2176 } 2261 2177 2262 - // table of contents screen 2263 2178 if self.state == State::ShowToc { 2264 2179 let toc_len = self.toc.len(); 2265 2180 if self.fonts.is_some() { ··· 2323 2238 for i in 0..self.line_count { 2324 2239 let span = self.lines[i]; 2325 2240 2326 - // inline image 2327 2241 if span.is_image() { 2328 2242 if span.is_image_origin() { 2329 2243 let y_top = TEXT_Y as i32 + i as i32 * line_h; 2330 2244 if let Some(ref img) = self.page_img { 2331 - // centre horizontally 2332 2245 let img_x = 2333 2246 MARGIN as i32 + ((TEXT_W as i32 - img.width as i32) / 2).max(0); 2334 2247 strip.blit_1bpp( ··· 2342 2255 true, 2343 2256 ); 2344 2257 } else { 2345 - // placeholder when image could not be decoded 2346 2258 let baseline = y_top + ascent; 2347 2259 fs.draw_str( 2348 2260 strip, ··· 2353 2265 ); 2354 2266 } 2355 2267 } 2356 - // continuation lines (and origin after blit) are blank 2357 2268 continue; 2358 2269 } 2359 2270 ··· 2379 2290 j += 2; 2380 2291 continue; 2381 2292 } 2382 - // UTF-8 lead byte: decode full sequence, render ASCII replacement 2383 2293 if b >= 0xC0 { 2384 2294 let (repl, seq_len) = decode_utf8_to_ascii(line, j); 2385 2295 if (bitmap::FIRST_CHAR..=bitmap::LAST_CHAR).contains(&repl) { ··· 2410 2320 } 2411 2321 } 2412 2322 2413 - // progress bar 2414 2323 if self.state == State::Ready && (self.file_size > 0 || self.is_epub) { 2415 2324 let pct = self.progress_pct() as u32; 2416 2325 let filled_w = (PROGRESS_W as u32 * pct / 100).min(PROGRESS_W as u32); ··· 2425 2334 } 2426 2335 } 2427 2336 2428 - // position overlay (long-press feedback) 2429 2337 if self.show_position 2430 2338 && self.state == State::Ready 2431 2339 && POSITION_OVERLAY.intersects(strip.logical_window()) ··· 2456 2364 let _ = write!(pbuf, "Page {} ({}%)", self.page + 1, self.progress_pct()); 2457 2365 } 2458 2366 2459 - // inverted banner: black bg, white text 2460 2367 POSITION_OVERLAY 2461 2368 .to_rect() 2462 2369 .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
+1 -19
src/apps/recent.rs
··· 1 - // RAM-resident bookmark cache: 16 slots x 48 bytes held permanently in RAM (~780B). 2 - // Loaded from SD once; reads served from RAM with zero SD I/O. 3 - // Writes mark dirty; flush() persists to SD only when dirty. 1 + // Bookmark cache: 16 slots, RAM-resident, flushed to SD on dirty. 4 2 // 5 3 // Record layout (little-endian, 48 bytes per slot): 6 4 // [0..4) name_hash u32 ··· 16 14 use crate::drivers::storage; 17 15 pub use smol_epub::cache::fnv1a; 18 16 19 - // case-insensitive FNV-1a; FAT filenames are case-insensitive 20 17 fn fnv1a_icase(data: &[u8]) -> u32 { 21 18 let mut h: u32 = 0x811c_9dc5; 22 19 for &b in data { ··· 105 102 } 106 103 } 107 104 108 - // lightweight bookmark list entry (35B vs 48B for BookmarkSlot) 109 105 #[derive(Clone, Copy)] 110 106 pub struct BmListEntry { 111 107 pub filename: [u8; FILENAME_CAP], ··· 125 121 } 126 122 } 127 123 128 - // in-memory bookmark table; loaded once, reads from RAM, writes mark dirty (~780B) 129 124 pub struct BookmarkCache { 130 125 slots: [BookmarkSlot; SLOTS], 131 126 count: usize, // slots present in file; new saves past this extend count ··· 149 144 } 150 145 } 151 146 152 - // true if in-memory state has changed since the last flush 153 147 pub fn is_dirty(&self) -> bool { 154 148 self.dirty 155 149 } ··· 158 152 self.loaded 159 153 } 160 154 161 - // read bookmark file from SD; idempotent; no-op if already loaded 162 155 pub fn ensure_loaded<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 163 156 if self.loaded { 164 157 return; ··· 166 159 self.force_load(sd); 167 160 } 168 161 169 - // reload from SD, discarding in-memory changes 170 162 pub fn force_load<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 171 163 let mut buf = [0u8; FILE_LEN]; 172 164 let slot_count = match storage::read_pulp_file_start(sd, BOOKMARK_FILE, &mut buf) { ··· 189 181 log::info!("bookmarks: loaded {} slots from SD", slot_count); 190 182 } 191 183 192 - // find bookmark by filename; None if not found or not loaded 193 184 pub fn find(&self, filename: &[u8]) -> Option<BookmarkSlot> { 194 185 if !self.loaded { 195 186 return None; ··· 205 196 None 206 197 } 207 198 208 - // copy valid bookmarks into out, sorted by generation descending; return count written 209 199 pub fn load_all(&self, out: &mut [BmListEntry]) -> usize { 210 200 if !self.loaded { 211 201 return 0; 212 202 } 213 203 214 - // collect valid entries with their generation for sorting 215 204 let mut gens = [0u16; SLOTS]; 216 205 let mut count = 0usize; 217 206 ··· 231 220 } 232 221 } 233 222 234 - // insertion sort by generation descending (most recent first) 235 223 for i in 1..count { 236 224 let key_gen = gens[i]; 237 225 let key_entry = out[i]; ··· 248 236 count 249 237 } 250 238 251 - // save bookmark; update cache + mark dirty; call flush() to persist. 252 - // handles LRU eviction, generation increment, hash+name matching. 253 239 pub fn save(&mut self, filename: &[u8], byte_offset: u32, chapter: u16) { 254 240 if !self.loaded { 255 241 log::warn!("bookmarks: save called before load, ignoring"); ··· 258 244 259 245 let key = fnv1a_icase(filename); 260 246 261 - // scan for: target slot, max generation, first free, LRU 262 247 let mut max_gen: u16 = 0; 263 248 let mut target: Option<usize> = None; 264 249 let mut first_free: Option<usize> = None; ··· 311 296 312 297 self.slots[write_slot] = new_slot; 313 298 314 - // extend count if we wrote past the current end 315 299 if write_slot >= self.count { 316 300 self.count = write_slot + 1; 317 301 } ··· 327 311 ); 328 312 } 329 313 330 - // write cache to SD if dirty; no-op if clean; 768B stack buffer 331 314 pub fn flush<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 332 315 if !self.dirty || !self.loaded { 333 316 return; ··· 349 332 } 350 333 Err(e) => { 351 334 log::warn!("bookmarks: flush failed: {}", e); 352 - // leave dirty=true so we retry next time 353 335 } 354 336 } 355 337 }
+1 -10
src/apps/settings.rs
··· 1 - // System settings with persistent storage. 2 - // Text-based key=value format in _PULP/SETTINGS.TXT. 1 + // System settings; key=value text in _PULP/SETTINGS.TXT. 3 2 use core::fmt::Write as _; 4 3 5 4 use crate::apps::{App, AppContext, Services, Transition}; ··· 22 21 23 22 const NUM_ITEMS: usize = 4; 24 23 const HEADING_ITEMS_GAP: u16 = 8; // gap between heading bottom and first row 25 - 26 - // persistent settings 27 24 28 25 const SETTINGS_FILE: &str = "SETTINGS.TXT"; 29 26 #[derive(Clone, Copy)] ··· 58 55 } 59 56 } 60 57 61 - // wifi config (in settings.txt) 62 58 pub const WIFI_SSID_CAP: usize = 32; 63 59 pub const WIFI_PASS_CAP: usize = 63; 64 60 ··· 104 100 } 105 101 } 106 102 107 - // Text format parser / writer 108 103 fn trim(s: &[u8]) -> &[u8] { 109 104 let mut start = 0; 110 105 let mut end = s.len(); ··· 173 168 } 174 169 } 175 170 176 - // tiny cursor writer for building the text representation 177 171 struct TxtWriter<'a> { 178 172 buf: &'a mut [u8], 179 173 pos: usize, ··· 239 233 wr.len() 240 234 } 241 235 242 - // SettingsApp 243 236 impl Default for SettingsApp { 244 237 fn default() -> Self { 245 238 Self::new() ··· 298 291 self.loaded 299 292 } 300 293 301 - // load settings from SD and apply font indices immediately; 302 - // called once at boot so saved preferences are in effect from the first frame 303 294 pub fn load_eager<SPI: embedded_hal::spi::SpiDevice>( 304 295 &mut self, 305 296 services: &mut Services<'_, SPI>,
+7 -84
src/apps/upload.rs
··· 1 - // WiFi upload mode: HTTP file upload server run from Home menu. 2 - // WiFi credentials are read from _PULP/SETTINGS.TXT (wifi_ssid / wifi_pass). 3 - // GET / -> HTML file-picker form. 4 - // POST /upload -> multipart/form-data streamed to SD root. 5 - // No embassy tasks; runner, server, mDNS and back-button multiplexed with select. 6 - // mDNS advertises as pulp.local so users don't need the DHCP IP. 1 + // WiFi upload server: GET / serves HTML, POST /upload streams to SD, mDNS as pulp.local. 7 2 8 3 use alloc::string::String; 9 4 use core::fmt::Write as FmtWrite; ··· 29 24 use crate::kernel::tasks; 30 25 use crate::ui::{Alignment, BitmapLabel, ButtonFeedback, CONTENT_TOP, Region, stack_fmt}; 31 26 32 - // layout 33 - 34 27 const HEADING_X: u16 = 16; 35 28 const HEADING_W: u16 = SCREEN_W - HEADING_X * 2; 36 29 ··· 40 33 41 34 const FOOTER_Y: u16 = SCREEN_H - 60; 42 35 43 - // HTTP response fragments 44 - 45 36 const HTTP_200_HTML: &[u8] = 46 37 b"HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n"; 47 38 const HTTP_200_JSON: &[u8] = ··· 52 43 b"HTTP/1.0 500 Internal Server Error\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n"; 53 44 const HTTP_404: &[u8] = b"HTTP/1.0 404 Not Found\r\nConnection: close\r\n\r\nNot Found"; 54 45 55 - // Embedded HTML page with drag-and-drop, file listing, and delete support. 56 - // JS is minimal: XHR upload with progress, fetch /files for listing, POST /delete. 57 46 const UPLOAD_PAGE: &[u8] = include_bytes!("../../assets/upload.html"); 58 - 59 - // mDNS constants 60 47 61 48 const MDNS_PORT: u16 = 5353; 62 49 ··· 69 56 70 57 const MDNS_RESPONSE_LEN: usize = 38; 71 58 72 - // max boundary string length 73 59 const MAX_BOUNDARY_LEN: usize = 120; 74 - 75 - // work buffer for upload data; larger = fewer SD writes 76 60 const WORK_BUF_SIZE: usize = 2048; 77 61 78 62 enum ServerEvent { ··· 121 105 122 106 let ssid = wifi_cfg.ssid(); 123 107 let password = wifi_cfg.password(); 124 - 125 - // screen 1: connecting 126 108 127 109 { 128 110 let mut msg_buf = [0u8; 64]; ··· 296 278 ) 297 279 .await; 298 280 299 - // phase 4: HTTP server + mDNS responder loop 300 - 301 281 let mut rx_buf = [0u8; 2048]; 302 282 let mut tx_buf = [0u8; 1536]; 303 283 ··· 346 326 info!("upload: exiting, tearing down WiFi"); 347 327 } 348 328 349 - // HTTP request handling 350 - 351 - // accept one TCP connection, route the request, send a response 352 329 async fn serve_one_request<SPI>( 353 330 stack: embassy_net::Stack<'_>, 354 331 rx_buf: &mut [u8], ··· 373 350 return ServerEvent::Nothing; 374 351 } 375 352 376 - // read HTTP request headers (accumulate until \r\n\r\n) 377 353 let mut hdr = [0u8; 1024]; 378 354 let mut hdr_len = 0usize; 379 355 ··· 403 379 } 404 380 } 405 381 406 - // locate end of headers; body data may follow in the same read 407 382 let headers_end = match find_subsequence(&hdr[..hdr_len], b"\r\n\r\n") { 408 383 Some(p) => p, 409 384 None => { ··· 415 390 let initial_body = &hdr[body_offset..hdr_len]; 416 391 let headers = &hdr[..headers_end]; 417 392 418 - // parse request line: "METHOD /path HTTP/x.x" 419 393 let first_line_end = headers 420 394 .iter() 421 395 .position(|&b| b == b'\r') ··· 427 401 428 402 let path = extract_path(request_line); 429 403 430 - // ── GET / ── serve the upload page HTML ────────────────────────── 431 404 if is_get && path == b"/" { 432 405 let _ = socket.write_all(HTTP_200_HTML).await; 433 406 let _ = socket.write_all(UPLOAD_PAGE).await; ··· 436 409 return ServerEvent::Nothing; 437 410 } 438 411 439 - // ── GET /files ── JSON array of {name, size} ──────────────────── 440 412 if is_get && path == b"/files" { 441 413 let _ = socket.write_all(HTTP_200_JSON).await; 442 414 ··· 466 438 let mid = b"\",\"size\":"; 467 439 json_buf[pos..pos + mid.len()].copy_from_slice(mid); 468 440 pos += mid.len(); 469 - // format u32 without alloc 441 + 470 442 pos += fmt_u32(e.size, &mut json_buf[pos..]); 471 443 json_buf[pos] = b'}'; 472 444 pos += 1; ··· 482 454 return ServerEvent::Nothing; 483 455 } 484 456 485 - // ── POST /upload ── multipart file upload ─────────────────────── 486 457 if is_post && path == b"/upload" { 487 458 let boundary = match find_boundary(headers) { 488 459 Some(b) => b, ··· 513 484 } 514 485 } 515 486 516 - // ── POST /delete ── body = filename to delete ─────────────────── 517 487 if is_post && path == b"/delete" { 518 - // read body (filename); may need to read more beyond initial_body 519 488 let content_len = extract_content_length(headers).unwrap_or(0); 520 489 let max_body = content_len.min(13); // 8.3 filename max 521 490 let mut body = [0u8; 16]; ··· 546 515 return ServerEvent::DeleteFailed; 547 516 } 548 517 549 - // copy name into fixed buffer before passing to storage 550 518 let mut name_buf = [0u8; 13]; 551 519 let name_bytes = name.as_bytes(); 552 520 name_buf[..name_bytes.len()].copy_from_slice(name_bytes); ··· 572 540 } 573 541 } 574 542 575 - // fallback: 404 576 543 let _ = socket.write_all(HTTP_404).await; 577 544 let _ = socket.flush().await; 578 545 close_socket(&mut socket).await; 579 546 ServerEvent::Nothing 580 547 } 581 548 582 - // stream a multipart upload body to SD; initial_body = body bytes read with headers. 583 - // returns sanitised 8.3 filename on success. 584 549 async fn handle_upload<SPI>( 585 550 socket: &mut TcpSocket<'_>, 586 551 sd: &SdStorage<SPI>, ··· 594 559 return Err("boundary too long"); 595 560 } 596 561 597 - // build end-of-file-data marker: \r\n--<boundary> 598 562 let em_len = 4 + boundary.len(); 599 563 let mut end_marker_buf = [0u8; MAX_BOUNDARY_LEN + 4]; 600 564 end_marker_buf[0] = b'\r'; ··· 604 568 end_marker_buf[4..em_len].copy_from_slice(boundary); 605 569 let end_marker = &end_marker_buf[..em_len]; 606 570 607 - // phase A: skip multipart preamble, find file data start. 608 - // accumulate until \r\n\r\n (blank line ending part headers); 609 - // everything after is file data. 610 - 611 571 let mut work = [0u8; WORK_BUF_SIZE]; 612 572 let init_len = initial_body.len().min(work.len()); 613 573 work[..init_len].copy_from_slice(&initial_body[..init_len]); ··· 617 577 if let Some(pos) = find_subsequence(&work[..filled], b"\r\n\r\n") { 618 578 let part_headers = &work[..pos]; 619 579 620 - // extract raw filename from Content-Disposition 621 580 let raw_name = extract_filename(part_headers).ok_or("no filename in upload")?; 622 581 let (name_buf, name_len) = sanitize_83(raw_name); 623 582 if name_len == 0 { 624 583 return Err("invalid filename"); 625 584 } 626 585 627 - // shift remaining file data to front of work buffer 628 586 let file_start = pos + 4; 629 587 work.copy_within(file_start..filled, 0); 630 588 filled -= file_start; ··· 651 609 652 610 info!("upload: receiving file '{}'", name_str); 653 611 654 - // create (or truncate) file on SD 655 612 storage::write_file(sd, name_str, &[])?; 656 613 657 - // phase B: stream file data to SD. 658 - // keep last end_marker.len() bytes unwritten (holdback) to detect 659 - // boundary spanning two TCP reads; everything before is safe to flush. 614 + // holdback last end_marker.len() bytes to detect boundary spanning two reads 660 615 661 616 let mut total_written: u32 = 0; 662 617 663 618 loop { 664 - // check for end marker in current buffer 665 619 if let Some(pos) = find_subsequence(&work[..filled], end_marker) { 666 - // write remaining file data before the marker 667 620 if pos > 0 { 668 621 storage::append_root_file(sd, name_str, &work[..pos])?; 669 622 total_written += pos as u32; ··· 672 625 return Ok((file_name_buf, file_name_len)); 673 626 } 674 627 675 - // flush safe prefix (everything except holdback zone) 676 628 if filled > end_marker.len() { 677 629 let safe = filled - end_marker.len(); 678 630 storage::append_root_file(sd, name_str, &work[..safe])?; 679 631 total_written += safe as u32; 680 632 681 - // compact: move holdback to front 682 633 work.copy_within(safe..filled, 0); 683 634 filled = end_marker.len(); 684 635 } 685 636 686 - // read more data from network 687 637 let n = socket 688 638 .read(&mut work[filled..]) 689 639 .await 690 640 .map_err(|_| "read error during upload")?; 691 641 if n == 0 { 692 - // connection closed before end marker found; 693 - // write what we have (may include partial boundary junk) 694 642 if filled > 0 { 695 643 let _ = storage::append_root_file(sd, name_str, &work[..filled]); 696 644 } ··· 700 648 } 701 649 } 702 650 703 - // HTTP helpers 704 - 705 - // extract request path from a request line like "GET /path HTTP/1.1" 706 651 fn extract_path(line: &[u8]) -> &[u8] { 707 - // skip method 708 652 let start = match line.iter().position(|&b| b == b' ') { 709 653 Some(p) => p + 1, 710 654 None => return b"/", 711 655 }; 712 - // find end of path 656 + 713 657 let rest = &line[start..]; 714 658 let end = rest.iter().position(|&b| b == b' ').unwrap_or(rest.len()); 715 - // strip query string 659 + 716 660 let path = &rest[..end]; 717 661 let qmark = path.iter().position(|&b| b == b'?').unwrap_or(path.len()); 718 662 &path[..qmark] 719 663 } 720 664 721 - // extract multipart boundary from headers block 722 665 fn find_boundary(headers: &[u8]) -> Option<&[u8]> { 723 - // search for "boundary=" case-insensitively 724 666 let marker = b"boundary="; 725 667 let pos = headers 726 668 .windows(marker.len()) ··· 732 674 return None; 733 675 } 734 676 735 - // handle quoted or unquoted value 736 677 if rest[0] == b'"' { 737 678 let inner = &rest[1..]; 738 679 let end = inner.iter().position(|&b| b == b'"')?; ··· 752 693 } 753 694 } 754 695 755 - // extract raw filename bytes from multipart part headers 756 696 fn extract_filename(headers: &[u8]) -> Option<&[u8]> { 757 697 let marker = b"filename=\""; 758 698 let pos = headers ··· 767 707 Some(&rest[..end]) 768 708 } 769 709 770 - // sanitise raw filename to valid FAT 8.3; returns (buf, len) 771 710 fn sanitize_83(raw: &[u8]) -> ([u8; 13], u8) { 772 - // strip path components 773 711 let name = match raw.iter().rposition(|&b| b == b'/' || b == b'\\') { 774 712 Some(p) => &raw[p + 1..], 775 713 None => raw, 776 714 }; 777 715 778 - // split into base and extension at last dot 779 716 let (base_src, ext_src) = match name.iter().rposition(|&b| b == b'.') { 780 717 Some(dot) => (&name[..dot], &name[dot + 1..]), 781 718 None => (name, &[] as &[u8]), ··· 784 721 let mut out = [0u8; 13]; 785 722 let mut pos: usize = 0; 786 723 787 - // base name: up to 8 chars, uppercased 788 724 for &b in base_src.iter() { 789 725 if pos >= 8 { 790 726 break; ··· 795 731 } 796 732 } 797 733 798 - // fallback if base is empty after filtering 799 734 if pos == 0 { 800 735 out[..6].copy_from_slice(b"UPLOAD"); 801 736 pos = 6; 802 737 } 803 738 804 - // extension: up to 3 chars, uppercased 805 739 if !ext_src.is_empty() { 806 740 out[pos] = b'.'; 807 741 pos += 1; ··· 815 749 pos += 1; 816 750 } 817 751 } 818 - // remove dot if no valid extension chars 752 + 819 753 if pos == ext_start { 820 754 pos -= 1; 821 755 } ··· 828 762 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'~' | b'!' | b'#' | b'$' | b'&') 829 763 } 830 764 831 - // send a plain-text error response 832 765 async fn send_error_response(socket: &mut TcpSocket<'_>, msg: &str) { 833 766 let _ = socket.write_all(HTTP_500_TEXT).await; 834 767 let _ = socket.write_all(msg.as_bytes()).await; 835 768 let _ = socket.flush().await; 836 769 } 837 770 838 - // format a u32 into decimal ASCII; returns number of bytes written 839 771 fn fmt_u32(mut n: u32, buf: &mut [u8]) -> usize { 840 772 if n == 0 { 841 773 buf[0] = b'0'; ··· 854 786 pos 855 787 } 856 788 857 - // extract Content-Length value from headers 858 789 fn extract_content_length(headers: &[u8]) -> Option<usize> { 859 790 let marker = b"content-length:"; 860 791 let pos = headers ··· 862 793 .position(|w| w.eq_ignore_ascii_case(marker))?; 863 794 let start = pos + marker.len(); 864 795 let rest = &headers[start..]; 865 - // skip whitespace 796 + 866 797 let trimmed = rest.iter().position(|&b| b != b' ' && b != b'\t')?; 867 798 let rest = &rest[trimmed..]; 868 799 let end = rest ··· 881 812 Some(val) 882 813 } 883 814 884 - // gracefully close a TCP socket 885 815 async fn close_socket(socket: &mut TcpSocket<'_>) { 886 816 Timer::after(Duration::from_millis(50)).await; 887 817 socket.close(); ··· 889 819 socket.abort(); 890 820 } 891 821 892 - // find first occurrence of needle in haystack 893 822 fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> { 894 823 if needle.is_empty() || needle.len() > haystack.len() { 895 824 return None; ··· 898 827 .windows(needle.len()) 899 828 .position(|window| window == needle) 900 829 } 901 - 902 - // mDNS responder 903 830 904 831 async fn mdns_respond_once(stack: embassy_net::Stack<'_>, ip_octets: [u8; 4]) { 905 832 let mut rx_meta = [PacketMetadata::EMPTY; 2]; ··· 988 915 MDNS_RESPONSE_LEN 989 916 } 990 917 991 - // input helpers 992 - 993 918 async fn drain_until_back() { 994 919 let mapper = ButtonMapper::new(); 995 920 loop { ··· 1003 928 } 1004 929 } 1005 930 } 1006 - 1007 - // display helpers 1008 931 1009 932 async fn render_screen( 1010 933 epd: &mut Epd,
+5 -38
src/bin/main.rs
··· 1 - // pulp-os entry point: Embassy multi-task architecture. 2 - // main: UI event loop, app dispatch, rendering. 3 - // input_task: 10ms button poll, publishes events + battery mv. 4 - // housekeeping_task: periodic signals (status 5s, SD/bookmarks 30s). 5 - // idle_timeout_task: fires IDLE_SLEEP_DUE after idle timeout. 6 - // CPU sleeps (WFI) whenever all tasks are waiting. 1 + // pulp-os: Embassy event loop, app dispatch, rendering. 7 2 8 3 #![no_std] 9 4 #![no_main] ··· 46 41 47 42 esp_bootloader_esp_idf::esp_app_desc!(); 48 43 49 - // on_work cadence: lets multi-step ops (EPUB init, caching) progress between events 50 44 const TICK_MS: u64 = 10; 51 45 52 46 const DEFAULT_GHOST_CLEAR_EVERY: u32 = 10; ··· 73 67 } 74 68 } 75 69 76 - // Static dispatch to the active app by AppId. 77 70 macro_rules! with_app { 78 71 ($id:expr, $apps:expr, |$app:ident| $body:expr) => { 79 72 match $id { ··· 100 93 }; 101 94 } 102 95 103 - // Execute a nav-stack transition: lifecycle callbacks, font propagation. 104 96 macro_rules! apply_transition { 105 97 ($nav:expr, $launcher:expr, $apps:expr, $bm_cache:expr, 106 98 $quick_menu:expr, $bumps:expr) => {{ ··· 123 115 } 124 116 } 125 117 126 - // propagate persisted prefs before lifecycle callbacks 127 118 $apps.propagate_fonts($quick_menu, $bumps); 128 119 129 120 if nav.to != AppId::Upload { ··· 140 131 }}; 141 132 } 142 133 143 - // busy-wait loop with input processing. 144 - // macro because it borrows multiple locals and .awaits inside the main async fn. 145 - // runs during full and partial waveforms; selects on BUSY pin, input channel, 146 - // and work ticker so page pre-loads happen concurrently. 147 - // non-trivial transitions (Back, Home) deferred until waveform ends. 134 + // busy-wait with input: selects on BUSY pin, input channel, work ticker. 135 + // macro because it .awaits inside the main async fn. 148 136 macro_rules! busy_wait_with_input { 149 137 ($epd:expr, $mapper:expr, 150 138 $quick_menu:expr, $launcher:expr, $apps:expr, ··· 152 140 let mut _deferred: Option<Transition> = None; 153 141 let mut _work_ticker = Ticker::every(Duration::from_millis(TICK_MS)); 154 142 loop { 155 - // level-check first; avoids creating futures if already done 156 143 if !$epd.is_busy() { 157 144 break; 158 145 } 159 146 160 - // wait for BUSY low, input event, or work tick 161 147 match select( 162 148 $epd.busy_pin().wait_for_low(), 163 149 select(tasks::INPUT_EVENTS.receive(), _work_ticker.next()), ··· 166 152 { 167 153 Either::First(_) => break, 168 154 169 - // input event from the channel 170 155 Either::Second(Either::First(hw_event)) => { 171 156 let event = $mapper.map_event(hw_event); 172 157 173 - // skip quick-menu during refresh; cosmetic, can wait 174 158 if $quick_menu.open { 175 159 continue; 176 160 } ··· 182 166 } 183 167 } 184 168 185 - // work tick 186 169 Either::Second(Either::Second(_)) => {} 187 170 } 188 171 189 - // pre-load next page while waveform runs 190 172 let active = $launcher.active(); 191 173 let needs = with_app!(active, $apps, |app| app.needs_work()); 192 174 if needs { ··· 199 181 }}; 200 182 } 201 183 202 - // flush bookmarks, render sleep screen, deep-sleep display + MCU. 203 - // macro because it borrows locals and .awaits inside the main async fn. 204 184 macro_rules! enter_sleep { 205 185 ($reason:expr, $bm_cache:expr, $board:expr, $strip:expr, $delay:expr) => {{ 206 186 info!("{}: entering sleep...", $reason); ··· 256 236 }; 257 237 } 258 238 259 - // Heavy statics kept out of the async future so Embassy's state machine stays ~200 B. 260 - // const-fn types → ConstStaticCell (value in .bss at link time; .take() panics on double-use). 261 - // runtime-init types → StaticCell (.init(val) panics on double-use). 239 + // heavy statics out of the async future; ConstStaticCell for const-fn types, StaticCell otherwise 262 240 263 241 static STRIP: ConstStaticCell<StripBuffer> = ConstStaticCell::new(StripBuffer::new()); 264 242 static STATUSBAR: ConstStaticCell<StatusBar> = ConstStaticCell::new(StatusBar::new()); ··· 279 257 let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); 280 258 let peripherals = esp_hal::init(config); 281 259 282 - // paint sentinel before any deep calls; measure peak via stack_high_water_mark() 283 260 paint_stack(); 284 261 285 - // 140KB heap (reduced from 200KB to fit WiFi firmware blobs in DRAM). 286 - // WiFi radio static data ~65KB; this leaves enough for stack + esp-radio. 262 + // 140KB: WiFi radio ~65KB static + stack + esp-radio leaves no room for more 287 263 esp_alloc::heap_allocator!(size: 143360); 288 264 289 265 info!("booting..."); 290 266 291 - // must run before first .await; sets up RTOS scheduler + Embassy timer driver 292 267 let timg0 = TimerGroup::new(unsafe { peripherals.TIMG0.clone_unchecked() }); 293 268 let sw_ints = 294 269 SoftwareInterruptControl::new(unsafe { peripherals.SW_INTERRUPT.clone_unchecked() }); ··· 310 285 .open_volume(embedded_sdmmc::VolumeIdx(0)) 311 286 .is_ok(); 312 287 313 - // ensure _PULP/ exists 314 288 if sd_ok && let Err(e) = storage::ensure_pulp_dir(&board.storage.sd) { 315 289 info!("warning: failed to create _PULP dir: {}", e); 316 290 } 317 291 318 - // created here for initial battery read, then moved into input_task 319 292 let mut input = InputDriver::new(board.input); 320 293 let mapper = ButtonMapper::new(); 321 294 ··· 335 308 336 309 bm_cache.ensure_loaded(&board.storage.sd); 337 310 338 - // load settings + recent book before first render 339 311 { 340 312 let mut svc = Services::new(dir_cache, bm_cache, &board.storage.sd); 341 313 apps.settings.load_eager(&mut svc); ··· 343 315 apps.home.load_recent(&mut svc); 344 316 } 345 317 346 - // signal idle timeout after settings load so persisted value is used 347 318 tasks::set_idle_timeout(apps.settings.system_settings().sleep_timeout); 348 319 349 320 let cached_battery_mv_init = battery::adc_to_battery_mv(input.read_battery_mv()); ··· 351 322 352 323 apps.home.on_enter(&mut launcher.ctx); 353 324 354 - // write both RAM planes, kick GC waveform, yield ~1.6s 355 325 board 356 326 .display 357 327 .epd ··· 361 331 }) 362 332 .await; 363 333 364 - // drain stale redraw left by on_enter 365 334 let _ = launcher.ctx.take_redraw(); 366 335 info!("ui ready."); 367 336 368 - // InputDriver moved into input_task; events arrive via INPUT_EVENTS from here on 369 337 spawner.spawn(tasks::input_task(input)).unwrap(); 370 338 spawner.spawn(tasks::housekeeping_task()).unwrap(); 371 339 spawner.spawn(tasks::idle_timeout_task()).unwrap(); 372 340 info!("tasks spawned (input_task, housekeeping_task, idle_timeout_task)."); 373 341 info!("kernel ready."); 374 342 375 - // main event loop: wakes on input event or work ticker (10ms) 376 343 let mut work_ticker = Ticker::every(Duration::from_millis(TICK_MS)); 377 344 378 345 let mut partial_refreshes: u32 = 0;
+2 -53
src/drivers/ssd1677.rs
··· 57 57 pub const SET_RAM_Y_COUNTER: u8 = 0x4F; 58 58 } 59 59 60 - // region params threaded between split-phase partial refresh steps 61 60 #[derive(Clone, Copy, Debug)] 62 61 pub struct RenderState { 63 62 pub px: u16, ··· 78 77 init_done: bool, 79 78 initial_refresh: bool, 80 79 } 81 - 82 - // blocking API 83 80 84 81 impl<SPI, DC, RST, BUSY, E> DisplayDriver<SPI, DC, RST, BUSY> 85 82 where ··· 115 112 self.init_display(delay); 116 113 } 117 114 118 - // strip helpers 119 - 120 115 #[allow(clippy::too_many_arguments)] 121 116 fn write_region_strips<F>( 122 117 &mut self, ··· 156 151 } 157 152 } 158 153 159 - // write BW normal + RED inverted; DU drives all pixels to BW target; corrects stale RED 160 154 #[allow(clippy::too_many_arguments)] 161 155 fn write_region_strips_bw_inv_red<F>( 162 156 &mut self, ··· 188 182 } 189 183 } 190 184 191 - // BW: normal 192 185 self.set_partial_ram_area(px, y, pw, rows); 193 186 self.send_command(cmd::WRITE_RAM_BW); 194 187 self.send_data(strip.data()); 195 188 196 - // RED: inverted; forces all pixels into DU diff 197 189 for byte in strip.data_mut().iter_mut() { 198 190 *byte = !*byte; 199 191 } 200 - // re-apply edge masks 201 192 if needs_mask && row_bytes > 0 { 202 193 for row in strip.data_mut().chunks_mut(row_bytes) { 203 194 row[0] |= left_mask; ··· 212 203 } 213 204 } 214 205 215 - // sync RED+BW with one draw per chunk (2N vs 3N SPI writes); DMA-backed 216 206 #[allow(clippy::too_many_arguments)] 217 207 fn write_region_strips_dual<F>( 218 208 &mut self, ··· 258 248 } 259 249 } 260 250 261 - // display init (matches GxEPD2 _InitDisplay) 262 - 263 251 fn init_display(&mut self, delay: &mut Delay) { 264 252 self.send_command(cmd::SW_RESET); 265 253 delay.delay_millis(10); ··· 280 268 281 269 self.init_done = true; 282 270 } 283 - 284 - // coordinate helpers 285 271 286 272 fn transform_region(&self, x: u16, y: u16, w: u16, h: u16) -> (u16, u16, u16, u16) { 287 273 match self.rotation { ··· 292 278 } 293 279 } 294 280 295 - // transform logical region to byte-aligned physical coords + edge masks 296 281 fn align_partial_region(&self, x: u16, y: u16, w: u16, h: u16) -> Option<RenderState> { 297 282 let (tx, ty, tw, th) = self.transform_region(x, y, w, h); 298 283 ··· 320 305 }) 321 306 } 322 307 323 - // gates wired in reverse; Y flipped per GxEPD2 308 + // gates wired in reverse; Y flipped, X inc / Y dec per GxEPD2 324 309 fn set_partial_ram_area(&mut self, x: u16, y: u16, w: u16, h: u16) { 325 310 let y_flipped = HEIGHT - y - h; 326 311 327 - // X increment, Y decrement; compensates gate reversal 328 312 self.send_command(cmd::DATA_ENTRY_MODE); 329 313 self.send_data(&[0x01]); 330 314 ··· 354 338 ]); 355 339 } 356 340 357 - // low-level SPI / busy 358 - 359 341 // WFI between polls; BUSY falling-edge IRQ wakes; timer backstop 360 342 fn wait_busy(&mut self, timeout_ms: u32) { 361 343 use esp_hal::time::{Duration, Instant}; ··· 386 368 let _ = self.spi.write(data); 387 369 } 388 370 389 - // split-phase partial refresh: 390 - // phase1_bw -> start_du -> (busy wait / input) -> phase3_sync -> power_off_async 391 - // use phase1_bw_inv_red when red_stale (phase 3 was skipped last cycle) 392 - 393 - // write new content to BW RAM; returns None on degenerate region or initial_refresh set 394 371 #[allow(clippy::too_many_arguments)] 395 372 pub fn partial_phase1_bw<F>( 396 373 &mut self, ··· 427 404 Some(rs) 428 405 } 429 406 430 - // write BW + inverted RED; use after skipped phase 3 to fix stale RED 431 407 #[allow(clippy::too_many_arguments)] 432 408 pub fn partial_phase1_bw_inv_red<F>( 433 409 &mut self, ··· 463 439 Some(rs) 464 440 } 465 441 466 - // kick DU waveform (non-blocking); caller polls is_busy 467 442 pub fn partial_start_du(&mut self, rs: &RenderState) { 468 443 self.set_partial_ram_area(rs.px, rs.py, rs.pw, rs.ph); 469 444 ··· 477 452 self.power_is_on = true; 478 453 } 479 454 480 - // true while controller is busy 481 455 #[inline] 482 456 pub fn is_busy(&mut self) -> bool { 483 457 self.busy.is_high().unwrap_or(false) 484 458 } 485 459 486 - // sync both RAM planes after DU completes; call when is_busy is false 487 460 pub fn partial_phase3_sync<F>(&mut self, strip: &mut StripBuffer, rs: &RenderState, draw: &F) 488 461 where 489 462 F: Fn(&mut StripBuffer), ··· 500 473 ); 501 474 } 502 475 503 - // true until first full refresh has been performed 504 476 pub fn needs_initial_refresh(&self) -> bool { 505 477 self.initial_refresh 506 478 } 507 479 508 - // split-phase full refresh: 509 - // write_full_frame -> start_full_update -> (busy wait) -> finish_full_update 510 - 511 - // write full frame to RED and BW RAM; does not kick GC waveform 512 480 pub fn write_full_frame<F>(&mut self, strip: &mut StripBuffer, delay: &mut Delay, draw: &F) 513 481 where 514 482 F: Fn(&mut StripBuffer), ··· 532 500 } 533 501 } 534 502 535 - // kick GC waveform (non-blocking); BUSY high ~1.6s; poll is_busy then call finish_full_update 536 503 pub fn start_full_update(&mut self) { 537 - // bypass RED=0, BW normal 538 504 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 539 505 self.send_data(&[0x40, 0x00]); 540 506 541 - // mode 1: GC full waveform 542 507 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 543 508 self.send_data(&[0xF7]); 544 509 545 510 self.send_command(cmd::MASTER_ACTIVATION); 546 511 } 547 512 548 - // finalise state after GC waveform completes 549 513 pub fn finish_full_update(&mut self) { 550 514 self.power_is_on = false; 551 515 self.initial_refresh = false; 552 516 } 553 517 554 - // deep-sleep mode 1: image retained, ~3uA; requires hw reset to wake 518 + // mode 1: image retained, ~3uA; requires hw reset to wake 555 519 pub fn enter_deep_sleep(&mut self) { 556 - // power off before sleeping 557 520 if self.power_is_on { 558 521 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 559 522 self.send_data(&[0x83]); ··· 562 525 self.power_is_on = false; 563 526 } 564 527 565 - // mode 1: RAM retained, <3uA 566 528 self.send_command(cmd::DEEP_SLEEP); 567 529 self.send_data(&[0x01]); 568 530 self.init_done = false; 569 531 } 570 532 } 571 533 572 - // async API: BUSY-wait replaced with .await; SPI writes remain synchronous 573 - 574 534 impl<SPI, DC, RST, BUSY, E> DisplayDriver<SPI, DC, RST, BUSY> 575 535 where 576 536 SPI: SpiDevice<Error = E>, ··· 578 538 RST: OutputPin, 579 539 BUSY: InputPin + embedded_hal_async::digital::Wait, 580 540 { 581 - // expose busy pin for external async wait 582 541 pub fn busy_pin(&mut self) -> &mut BUSY { 583 542 &mut self.busy 584 543 } ··· 587 546 let _ = self.busy.wait_for_low().await; 588 547 } 589 548 590 - // async write_full_frame; SPI writes are blocking (DMA), no .await needed 591 549 pub async fn write_full_frame_async<F>( 592 550 &mut self, 593 551 strip: &mut StripBuffer, ··· 599 557 self.write_full_frame(strip, delay, draw); 600 558 } 601 559 602 - // partial DU refresh in one await (~500ms); falls back to full GC on initial_refresh. 603 - // runs phase1 (SPI writes), kicks DU, awaits BUSY low, then syncs both RAM planes. 604 560 pub async fn partial_refresh_async<F>( 605 561 &mut self, 606 562 strip: &mut StripBuffer, ··· 626 582 None => return, 627 583 }; 628 584 629 - // phase 1: write BW RAM 630 585 self.write_region_strips( 631 586 strip, 632 587 rs.px, ··· 639 594 rs.right_mask, 640 595 ); 641 596 642 - // phase 2: kick DU waveform and await completion 643 597 self.partial_start_du(&rs); 644 598 self.wait_busy_async().await; 645 599 646 - // phase 3: sync both RAM planes 647 600 self.write_region_strips_dual( 648 601 strip, 649 602 rs.px, ··· 658 611 self.power_off_async().await; 659 612 } 660 613 661 - // full GC refresh in one await (~1.6s); prefer split-phase in event loops 662 614 pub async fn full_refresh_async<F>( 663 615 &mut self, 664 616 strip: &mut StripBuffer, ··· 672 624 self.initial_refresh = false; 673 625 } 674 626 675 - // async power-off (~200ms); awaits BUSY low 676 627 pub async fn power_off_async(&mut self) { 677 628 if self.power_is_on { 678 629 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); ··· 684 635 } 685 636 686 637 async fn update_full_async(&mut self) { 687 - // bypass RED=0, BW normal 688 638 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 689 639 self.send_data(&[0x40, 0x00]); 690 640 691 - // GC full waveform 692 641 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 693 642 self.send_data(&[0xF7]); 694 643
-2
src/drivers/storage.rs
··· 1 1 // SD card file operations and directory cache. 2 - // DirCache reads root entries once into RAM, serves pages from there. 3 2 4 3 use embedded_sdmmc::{Mode, VolumeIdx}; 5 4 ··· 18 17 pub name_len: u8, 19 18 pub is_dir: bool, 20 19 pub size: u32, 21 - // parsed display title from EPUB OPF metadata; empty = unavailable 22 20 pub title: [u8; TITLE_CAP], 23 21 pub title_len: u8, 24 22 }
-3
src/ui/bitmap_label.rs
··· 1 - // Proportional-font label widgets for the bitmap rendering pipeline. 2 - 3 1 use core::convert::Infallible; 4 2 5 3 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; ··· 49 47 } 50 48 } 51 49 52 - // owned fixed-size buffer variant; implements fmt::Write for formatted output 53 50 pub struct BitmapDynLabel<const N: usize> { 54 51 region: Region, 55 52 buffer: [u8; N],
-10
src/ui/button_feedback.rs
··· 1 - // Button feedback: plain text edge labels (no inversion). 2 - // Bottom tabs show the mapped action text; side labels hidden. 3 - // No visual change on press/release; purely informational. 4 - 5 1 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; 6 2 7 3 use super::widget::{Alignment, Region}; ··· 15 11 const TAB_W: u16 = 60; 16 12 const TAB_H: u16 = 22; 17 13 18 - // total height reserved at bottom of screen for button labels 19 14 pub const BUTTON_BAR_H: u16 = TAB_H + BOTTOM_INSET; 20 15 21 16 const RIDGE_W: u16 = 22; 22 17 const RIDGE_H: u16 = 36; 23 18 24 - // center positions of each button on the screen edge (px) 25 19 const CX_BACK: u16 = 84; 26 20 const CX_CONFIRM: u16 = 194; 27 21 const CX_LEFT: u16 = 286; ··· 128 122 } 129 123 } 130 124 131 - // set chrome font for button label text; call on UI font size change 132 125 pub fn set_chrome_font(&mut self, font: &'static BitmapFont) { 133 126 self.font = Some(font); 134 127 } 135 128 136 - // draw bottom-edge labels only; no side indicators or inversion 137 129 pub fn draw(&self, strip: &mut StripBuffer) { 138 130 let font = self.font.unwrap_or(&font_data::REGULAR_BODY_SMALL); 139 131 140 132 for def in BUMPS.iter() { 141 - // skip side-edge indicators (VolUp/VolDown) 142 133 if def.edge != Edge::Bottom { 143 134 continue; 144 135 } ··· 149 140 continue; 150 141 } 151 142 152 - // plain: white background, black text 153 143 r.to_rect() 154 144 .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off)) 155 145 .draw(strip)
+1 -19
src/ui/quick_menu.rs
··· 1 - // Quick-action overlay: summoned by Power (Menu) from any app. 2 - // Core actions (Refresh, Go Home) always present; apps inject up to MAX_APP_ACTIONS items. 3 - // Two kinds: Cycle (rotate options) and Trigger (fire on Select). Menu/Back dismisses. 4 - // Rendering: plain inverted-text rows, no borders or separators. 5 - 6 1 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; 7 2 8 3 use super::stack_fmt::StackFmt; ··· 13 8 use crate::fonts::bitmap::BitmapFont; 14 9 use crate::fonts::font_data; 15 10 16 - // layout constants 17 - 18 11 const OVERLAY_W: u16 = 400; 19 12 const OVERLAY_X: u16 = (SCREEN_W - OVERLAY_W) / 2; 20 - const OVERLAY_BOTTOM: u16 = 760; // above button widgets (~14px clearance) 13 + const OVERLAY_BOTTOM: u16 = 760; 21 14 const ITEM_H: u16 = 40; 22 15 const ITEM_GAP: u16 = 4; 23 16 const ITEM_STRIDE: u16 = ITEM_H + ITEM_GAP; ··· 36 29 37 30 #[derive(Debug, Clone, Copy)] 38 31 pub enum QuickActionKind { 39 - // rotates through named options; value is the current index 40 32 Cycle { 41 33 value: u8, 42 34 options: &'static [&'static str], 43 35 }, 44 - // fires immediately on Select; display is the right-column label 45 36 Trigger { 46 37 display: &'static str, 47 38 }, 48 39 } 49 40 50 - // app-provided quick action descriptor; id echoed in AppTrigger 51 41 #[derive(Debug, Clone, Copy)] 52 42 pub struct QuickAction { 53 43 pub id: u8, ··· 146 136 } 147 137 } 148 138 149 - // set chrome font for menu item text; call on UI font size change 150 139 pub fn set_chrome_font(&mut self, font: &'static BitmapFont) { 151 140 self.font = Some(font); 152 141 } 153 142 154 - // open overlay with app-provided items; core items appended automatically 155 143 pub fn show(&mut self, app_actions: &[QuickAction]) { 156 144 let n_app = app_actions.len().min(MAX_APP_ACTIONS); 157 145 self.app_count = n_app; ··· 268 256 } 269 257 } 270 258 271 - // cycle: advance value; trigger/core: fire and close 272 259 fn activate_selected(&mut self) -> QuickMenuResult { 273 260 match &mut self.items[self.selected].kind { 274 261 MenuItemKind::AppCycle { value, options, .. } => { ··· 350 337 351 338 let font = self.font.unwrap_or(&font_data::REGULAR_BODY_SMALL); 352 339 353 - // clear the overlay background 354 340 let outer = self.overlay_region; 355 341 if outer.intersects(strip.logical_window()) { 356 342 outer ··· 368 354 let label_region = self.item_label_region(i); 369 355 let value_region = self.item_value_region(i); 370 356 371 - // selected row: inverted (white text on black) 372 357 if selected { 373 358 let row_region = Region::new(OVERLAY_X, self.item_y(i), OVERLAY_W, ITEM_H); 374 359 if row_region.intersects(strip.logical_window()) { ··· 386 371 BinaryColor::On 387 372 }; 388 373 389 - // draw label text 390 374 if label_region.intersects(strip.logical_window()) { 391 375 font.draw_aligned( 392 376 strip, ··· 397 381 ); 398 382 } 399 383 400 - // draw value text 401 384 if value_region.intersects(strip.logical_window()) { 402 385 self.format_value(i, &mut val_buf); 403 386 font.draw_aligned(strip, value_region, val_buf.as_str(), Alignment::Center, fg); 404 387 } 405 388 } 406 389 407 - // help text at the bottom 408 390 let help = match &self.items[self.selected].kind { 409 391 MenuItemKind::AppCycle { .. } => "Up/Down: move Jump: adjust Sel: cycle Menu: close", 410 392 _ => "Up/Down: move Sel: activate Menu: close",
+1 -4
src/ui/stack_fmt.rs
··· 1 - // No-alloc fmt::Write buffers. 2 - // StackFmt<N> owns a [u8; N]; BorrowedFmt wraps &mut [u8]. 3 - // Both silently truncate on overflow. 1 + // No-alloc fmt::Write buffers; silently truncate on overflow. 4 2 5 3 pub struct StackFmt<const N: usize> { 6 4 buf: [u8; N], ··· 85 83 } 86 84 } 87 85 88 - // format into a borrowed slice via closure; returns bytes written 89 86 #[inline] 90 87 pub fn stack_fmt(buf: &mut [u8], f: impl FnOnce(&mut BorrowedFmt<'_>)) -> usize { 91 88 let mut w = BorrowedFmt::new(buf);
+1 -10
src/ui/statusbar.rs
··· 1 - // Debug status bar at top of screen (debug builds only). 2 - // Shows battery, uptime, heap (current/peak/total), stack, SD state. 3 - // In release builds BAR_HEIGHT is 0 and draw/update are no-ops, 4 - // so apps reclaim the full screen without any code changes. 1 + // Debug status bar; zero height in release builds. 5 2 6 3 #[cfg(debug_assertions)] 7 4 use core::fmt::Write; ··· 146 143 } 147 144 } 148 145 149 - // distance from current SP down to _stack_end_cpu0 (bottom of stack) 150 146 pub fn free_stack_bytes() -> usize { 151 147 let sp: usize; 152 148 #[cfg(target_arch = "riscv32")] ··· 158 154 sp = 0; 159 155 } 160 156 161 - // lowest address the stack may reach 162 157 #[cfg(target_arch = "riscv32")] 163 158 { 164 159 unsafe extern "C" { ··· 174 169 } 175 170 } 176 171 177 - // stack painting: paint_stack() fills unused stack with 0xDEAD_BEEF at boot; 178 - // stack_high_water_mark() scans upward to find peak usage. 179 172 const STACK_PAINT_WORD: u32 = 0xDEAD_BEEF; 180 173 181 - // fill unused stack with sentinel; call once early in main before task spawn 182 174 pub fn paint_stack() { 183 175 #[cfg(target_arch = "riscv32")] 184 176 { ··· 213 205 } 214 206 } 215 207 216 - // scan for first non-sentinel word from stack bottom; return peak usage in bytes 217 208 pub fn stack_high_water_mark() -> usize { 218 209 #[cfg(target_arch = "riscv32")] 219 210 {
+1 -7
src/ui/widget.rs
··· 1 - // Region geometry and alignment helpers 2 - // All coordinates are logical (rotation aware). x/w should be 8-aligned 3 - // for partial refresh to avoid byte boundary fixups on the controller. 1 + // Region geometry and alignment helpers; x/w should be 8-aligned for partial refresh. 4 2 5 3 use embedded_graphics::{prelude::*, primitives::Rectangle}; 6 4 ··· 97 95 } 98 96 } 99 97 100 - // wrap-around list navigation helpers 101 - 102 - // advance index by one, wrapping past count-1 back to 0 103 98 #[inline] 104 99 pub fn wrap_next(current: usize, count: usize) -> usize { 105 100 if count == 0 { ··· 108 103 if current + 1 >= count { 0 } else { current + 1 } 109 104 } 110 105 111 - // retreat index by one, wrapping past 0 to count-1 112 106 #[inline] 113 107 pub fn wrap_prev(current: usize, count: usize) -> usize { 114 108 if count == 0 {