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.

Merge pull request #2 from hansmrtn/async-bois

Move pulp-kernel crate into workspace

authored by

hans and committed by
GitHub
9cbc8a99 12a30766

+8446 -5823
+1 -1
.gitignore
··· 10 10 *.pdb 11 11 **/.DS_Store 12 12 13 - wiki.md 13 + *.md
+68 -9
Cargo.lock
··· 27 27 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 28 28 29 29 [[package]] 30 + name = "arbitrary-int" 31 + version = "2.1.1" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" 34 + 35 + [[package]] 30 36 name = "autocfg" 31 37 version = "1.5.0" 32 38 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 43 49 version = "0.13.1" 44 50 source = "registry+https://github.com/rust-lang/crates.io-index" 45 51 checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 52 + 53 + [[package]] 54 + name = "bitbybit" 55 + version = "2.0.0" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "71d2a3353d70ac1091a33cbf31fc7e77b19091538a7e306e3740712af19807ca" 58 + dependencies = [ 59 + "proc-macro2", 60 + "quote", 61 + "syn 2.0.117", 62 + ] 46 63 47 64 [[package]] 48 65 name = "bitfield" ··· 515 532 [[package]] 516 533 name = "embedded-sdmmc" 517 534 version = "0.9.0" 518 - source = "registry+https://github.com/rust-lang/crates.io-index" 519 - checksum = "ce3c7f9ea039eeafc4a49597b7bd5ae3a1c8e51b2803a381cb0f29ce90fe1ec6" 535 + source = "git+https://github.com/hansmrtn/embedded-sdmmc-rs?branch=async#0bf12548d1144b0e2b06b290acde3e4bb46cd91b" 520 536 dependencies = [ 537 + "arbitrary-int", 538 + "bitbybit", 539 + "bitflags 2.11.0", 521 540 "byteorder", 522 541 "embedded-hal 1.0.0", 523 - "embedded-io 0.6.1", 524 - "heapless 0.8.0", 542 + "embedded-hal-async", 543 + "embedded-io 0.7.1", 544 + "embedded-io-async 0.7.0", 545 + "heapless 0.9.2", 525 546 "log", 547 + "thiserror", 526 548 ] 527 549 528 550 [[package]] ··· 1265 1287 ] 1266 1288 1267 1289 [[package]] 1268 - name = "pulp-os" 1290 + name = "pulp-kernel" 1269 1291 version = "0.1.0" 1270 1292 dependencies = [ 1271 1293 "critical-section", 1272 1294 "embassy-executor", 1273 1295 "embassy-futures", 1274 - "embassy-net", 1275 1296 "embassy-sync 0.7.2", 1276 1297 "embassy-time", 1277 1298 "embedded-graphics", ··· 1279 1300 "embedded-hal 1.0.0", 1280 1301 "embedded-hal-async", 1281 1302 "embedded-hal-bus", 1303 + "embedded-sdmmc", 1304 + "esp-alloc", 1305 + "esp-hal", 1306 + "log", 1307 + "nb 1.1.0", 1308 + "smol-epub", 1309 + "static_cell", 1310 + ] 1311 + 1312 + [[package]] 1313 + name = "pulp-os" 1314 + version = "0.1.0" 1315 + dependencies = [ 1316 + "embassy-executor", 1317 + "embassy-futures", 1318 + "embassy-net", 1319 + "embassy-time", 1320 + "embedded-graphics", 1321 + "embedded-graphics-core", 1282 1322 "embedded-io-async 0.7.0", 1283 - "embedded-sdmmc", 1284 1323 "esp-alloc", 1285 1324 "esp-backtrace", 1286 1325 "esp-bootloader-esp-idf", ··· 1290 1329 "esp-rtos", 1291 1330 "fontdue", 1292 1331 "log", 1293 - "nb 1.1.0", 1332 + "pulp-kernel", 1294 1333 "smol-epub", 1295 1334 "static_cell", 1296 1335 ] ··· 1446 1485 [[package]] 1447 1486 name = "smol-epub" 1448 1487 version = "0.1.0" 1449 - source = "git+https://github.com/hansmrtn/smol-epub#ebabefc80ebfde53454eb1234d2efbf7e6336bfa" 1488 + source = "git+https://github.com/hansmrtn/smol-epub#813cc03043ce379464dc2c838e3bd03dba951029" 1450 1489 dependencies = [ 1451 1490 "log", 1452 1491 "miniz_oxide", ··· 1564 1603 checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 1565 1604 dependencies = [ 1566 1605 "winapi-util", 1606 + ] 1607 + 1608 + [[package]] 1609 + name = "thiserror" 1610 + version = "2.0.18" 1611 + source = "registry+https://github.com/rust-lang/crates.io-index" 1612 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1613 + dependencies = [ 1614 + "thiserror-impl", 1615 + ] 1616 + 1617 + [[package]] 1618 + name = "thiserror-impl" 1619 + version = "2.0.18" 1620 + source = "registry+https://github.com/rust-lang/crates.io-index" 1621 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1622 + dependencies = [ 1623 + "proc-macro2", 1624 + "quote", 1625 + "syn 2.0.117", 1567 1626 ] 1568 1627 1569 1628 [[package]]
+37 -25
Cargo.toml
··· 1 1 [workspace] 2 - members = ["."] 2 + members = [".", "kernel"] 3 + 4 + [workspace.dependencies] 5 + esp-hal = { version = "1.0.0", features = ["esp32c3", "log-04", "unstable"] } 6 + embassy-executor = "0.9" 7 + embassy-time = "0.5" 8 + embassy-futures = "0.1" 9 + embassy-sync = "0.7" 10 + embedded-hal = "1.0.0" 11 + embedded-hal-async = "1.0.0" 12 + embedded-hal-bus = "0.3.0" 13 + embedded-graphics-core = "0.4.1" 14 + embedded-graphics = "0.8.2" 15 + embedded-sdmmc = { git = "https://github.com/hansmrtn/embedded-sdmmc-rs", branch = "async", features = ["async"] } 16 + smol-epub = { git = "https://github.com/hansmrtn/smol-epub", features = ["async"] } 17 + critical-section = "1.2.0" 18 + static_cell = "2.1.1" 19 + nb = "1.1.0" 20 + log = "0.4.27" 21 + esp-alloc = { version = "0.9.0", features = ["internal-heap-stats"] } 3 22 4 23 [package] 5 24 edition = "2024" 6 25 name = "pulp-os" 7 26 rust-version = "1.88" 8 27 version = "0.1.0" 28 + authors = ["Hans Martin <https://github.com/hansmrtn>"] 29 + repository = "https://github.com/hansmrtn/pulp-os" 30 + license = "MIT" 9 31 10 32 [[bin]] 11 33 name = "pulp-os" 12 34 path = "./src/bin/main.rs" 13 35 14 36 [dependencies] 15 - esp-hal = { version = "1.0.0", features = ["esp32c3", "log-04", "unstable"] } 16 - esp-rtos = { version = "0.2.0", features = ["embassy", "esp32c3", "esp-radio", "log-04"] } 37 + pulp-kernel = { path = "kernel" } 17 38 18 - # esp-rtos pulls in embassy-executor and the time-driver backend; 19 - # we still need these for user-facing types (Spawner, Timer, Ticker, select). 20 - # Versions MUST match what esp-rtos 0.2.0 resolves internally. 21 - embassy-executor = "0.9" 22 - embassy-time = "0.5" 23 - embassy-futures = "0.1" 24 - embassy-sync = "0.7" 39 + esp-hal.workspace = true 40 + embassy-executor.workspace = true 41 + embassy-time.workspace = true 42 + embassy-futures.workspace = true 43 + embedded-graphics-core.workspace = true 44 + embedded-graphics.workspace = true 45 + static_cell.workspace = true 46 + log.workspace = true 47 + esp-alloc.workspace = true 48 + smol-epub.workspace = true 25 49 26 - # ── WiFi / networking (upload binary) ──────────────────────────────── 50 + esp-rtos = { version = "0.2.0", features = ["embassy", "esp32c3", "esp-radio", "log-04"] } 51 + 52 + # wifi / networking (used by apps/upload.rs) 27 53 esp-radio = { version = "0.17", default-features = false, features = [ 28 54 "esp32c3", 29 55 "wifi", ··· 34 60 embedded-io-async = "0.7" 35 61 36 62 esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3", "log-04"] } 37 - log = "0.4.27" 38 63 39 - esp-alloc = { version = "0.9.0", features = ["internal-heap-stats"] } 40 64 esp-backtrace = { version = "0.18.1", features = [ 41 65 "esp32c3", 42 66 "panic-handler", 43 67 "println", 44 68 ] } 45 69 esp-println = { version = "0.16.1", features = ["esp32c3", "log-04"] } 46 - 47 - critical-section = "1.2.0" 48 - static_cell = "2.1.1" 49 - embedded-hal = "1.0.0" 50 - embedded-hal-async = "1.0.0" 51 - embedded-hal-bus = "0.3.0" 52 - nb = "1.1.0" 53 - embedded-graphics-core = "0.4.1" 54 - embedded-graphics = "0.8.2" 55 - embedded-sdmmc = "0.9.0" 56 - smol-epub = { git = "https://github.com/hansmrtn/smol-epub" } 57 - 58 70 59 71 [build-dependencies] 60 72 fontdue = "0.9"
+194 -57
README.txt
··· 1 - ABOUT 2 - pulp-os is a bare-metal e-reader firmware for the XTEink X4 1 + pulp-os -- e-reader firmware for the XTEink X4 3 2 4 - Embedded e-reader operating system targeting the XTEink X4 board 5 - (ESP32-C3 + SSD1677 e-paper). Written in Rust. No std no framebuffer. 6 - Async runtime provided by Embassy via esp-rtos. 3 + bare-metal e-reader operating system for the XTEink X4 board 4 + (ESP32-C3 + SSD1677 e-paper). written in Rust. no std, no 5 + framebuffer, no dyn dispatch. async runtime via Embassy on 6 + esp-rtos. 7 7 8 - HARDWARE 9 - MCU ESP32-C3, single-core RISC-V RV32IMC, 160 MHz 10 - RAM 400 KB DRAM; 140 KB heap, rest for stack + radio 11 - Display 800x480 SSD1677 mono e-paper, DMA-backed SPI, portrait 12 - Storage MicroSD over shared SPI bus (400 kHz probe, 20 MHz run) 13 - Input 2 ADC ladders (GPIO1, GPIO2) + power button (GPIO3 IRQ) 14 - Battery Li-ion via ADC, 100K/100K divider on GPIO0 8 + hardware 9 + mcu ESP32-C3, single-core RISC-V RV32IMC, 160 MHz 10 + ram 400 KB DRAM; ~172 KB heap (108 KB main + 64 KB reclaimed) 11 + display 800x480 SSD1677 mono e-paper, DMA-backed SPI, portrait 12 + storage microSD over shared SPI bus (400 kHz probe, 20 MHz run) 13 + input 2 ADC ladders (GPIO1, GPIO2) + power button (GPIO3 IRQ) 14 + battery li-ion via ADC, 100K/100K divider on GPIO0 15 15 16 - Pin map: 16 + pin map: 17 17 GPIO0 battery ADC GPIO6 EPD BUSY 18 18 GPIO1 button row 1 ADC GPIO7 SPI MISO 19 19 GPIO2 button row 2 ADC GPIO8 SPI SCK ··· 21 21 GPIO4 EPD DC GPIO12 SD CS (raw register GPIO) 22 22 GPIO5 EPD RST GPIO21 EPD CS 23 23 24 - BUILDING 25 - Requires stable Rust >= 1.88 and the riscv32imc-unknown-none-elf 24 + EPD and SD share SPI2, arbitrated by CriticalSectionDevice. 25 + 26 + building 27 + requires stable Rust >= 1.88 and the riscv32imc-unknown-none-elf 26 28 target. rust-toolchain.toml handles both automatically. 27 29 28 30 cargo build --release 29 - espflash flash --monitor --chip esp32c3 /path/to/target/image 31 + espflash flash --monitor --chip esp32c3 target/... 30 32 31 - or 33 + or: 32 34 33 35 cargo run --release 34 36 35 - FEATURES 36 - txt reader lazy page-indexed, read-ahead prefetch 37 - epub reader ZIP/OPF/HTML-strip, chapter cache on SD, 38 - proportional fonts, inline PNG/JPEG (dithered 1-bit) 39 - bookmarks 16-slot LRU in RAM, flushed to SD every 30s 40 - wifi upload HTTP file upload + mDNS (pulp.local) 37 + local path dependencies (sibling dirs): 38 + embedded-sdmmc async FAT filesystem over SD/SPI (local fork) 39 + smol-epub no_std epub/zip/html/image processing 40 + 41 + features 42 + txt reader lazy page-indexed, read-ahead prefetch, 43 + proportional font wrapping 44 + epub reader ZIP/OPF/HTML-strip pipeline, chapter cache on SD, 45 + proportional fonts with bold/italic/heading styles, 46 + inline PNG/JPEG (1-bit Floyd-Steinberg dithered), 47 + TOC browser (NCX or inline), chapter navigation 48 + file browser paginated SD listing, background EPUB title 49 + scanner (resolves titles from OPF metadata) 50 + bookmarks 16-slot LRU in RAM, flushed to SD every 30 s; 51 + home screen bookmarks browser sorted by recency 52 + wifi upload HTTP file upload + mDNS (pulp.local); 53 + drag-and-drop web UI with delete support 41 54 fonts regular/bold/italic TTFs rasterised at build time 42 - via fontdue; three sizes (small/medium/large) 43 - display partial DU refresh (~400 ms page turn), 44 - periodic full GC refresh (configurable interval) 45 - quick menu per-app actions + screen refresh + go home 46 - status bar battery, uptime, heap, stack (debug builds only) 55 + via fontdue; five sizes, book and UI independently 56 + configurable 57 + display partial DU refresh (~400 ms page turn), periodic 58 + full GC refresh (configurable interval) 59 + quick menu per-app actions + screen refresh + go home, 60 + triggered by power button 47 61 settings sleep timeout, ghost clear interval, 48 62 book font size, UI font size, wifi credentials 49 63 sleep idle timeout + power long-press; EPD deep sleep 50 64 (~3 uA) + ESP32-C3 deep sleep (~5 uA); GPIO3 wake 51 65 52 - CONTROLS 66 + controls 53 67 Prev / Next scroll or turn page 54 68 PrevJump / NextJump page skip (files: full page; reader: chapter) 55 69 Select open item ··· 57 71 Power (short) open quick-action menu 58 72 Power (long) deep sleep 59 73 60 - RUNTIME ARCHITECTURE 61 - Embassy async executor on esp-rtos. Four concurrent tasks: 74 + runtime 75 + embassy async executor on esp-rtos. five concurrent tasks: 62 76 63 77 main event loop: input dispatch, app work, rendering 64 78 input_task 10 ms ADC poll, debounce, battery read (30 s) 65 79 housekeeping status bar (5 s), SD check (30 s), bookmark flush (30 s) 66 80 idle_timeout configurable idle timer, signals deep sleep 81 + worker_task background CPU-heavy work (HTML strip, image decode) 67 82 68 83 CPU sleeps (WFI) whenever all tasks are waiting. 69 84 70 - NOTES 71 - No dyn dispatch. with_app!() macro matches AppId, expands to 72 - concrete calls per app struct. All monomorphised; no vtable. 85 + directory layout 86 + kernel/ pulp-kernel workspace crate (zero app imports) 87 + src/ 88 + lib.rs crate root, re-exports 89 + kernel/ 90 + mod.rs Kernel struct, resource ownership 91 + app.rs App trait, AppLayer trait, AppIdType, 92 + Transition, Redraw, AppContext, Launcher, 93 + QuickAction protocol types 94 + console.rs boot console (FONT_6X13, no fontdue) 95 + scheduler.rs main loop, render pipeline, sleep 96 + handle.rs KernelHandle (app I/O API) 97 + tasks.rs spawned embassy tasks 98 + work_queue.rs background work with generation cancellation 99 + bookmarks.rs LRU bookmark cache 100 + config.rs settings parser/writer 101 + dir_cache.rs sorted directory cache with title resolution 102 + wake.rs uptime helper (embassy monotonic clock) 103 + board/ board support (pin map, SPI wiring, button layout) 104 + mod.rs Board::init, peripheral splitting 105 + action.rs ActionEvent (semantic button actions) 106 + battery.rs voltage-to-percentage mapping 107 + button.rs physical button enum, ButtonMapper 108 + layout.rs button-to-action table 109 + raw_gpio.rs register-level GPIO for SD CS 110 + drivers/ hardware drivers 111 + mod.rs driver re-exports 112 + ssd1677.rs EPD display driver, 3-phase partial refresh 113 + strip.rs 4 KB strip buffer, rotation, glyph blitting 114 + sdcard.rs SD card init and SPI wiring 115 + storage.rs FAT filesystem ops, poll_once, with_fs! macros 116 + input.rs ADC button polling, debounce, repeat 117 + battery.rs ADC battery voltage sampling 118 + ui/ font-independent primitives 119 + mod.rs Region, Alignment, stack measurement 120 + stack_fmt.rs no_alloc formatting (StackFmt) 121 + statusbar.rs status bar rendering 122 + widget.rs widget trait and helpers 123 + 124 + src/ distro / app layer 125 + bin/main.rs entry point, hardware init, boot 126 + lib.rs crate root 127 + ui/ 128 + mod.rs app-side UI helpers 129 + fonts/ 130 + mod.rs font size tiers, FontSet lookups 131 + bitmap.rs build-time bitmap font data 132 + apps/ 133 + mod.rs AppId enum, type aliases binding kernel generics 134 + manager.rs AppLayer impl, with_app! dispatch, lifecycle 135 + home.rs launcher menu + bookmarks browser 136 + files.rs SD file browser + background title scanner 137 + settings.rs settings UI 138 + upload.rs wifi upload server 139 + reader/ 140 + mod.rs state machine, lifecycle, draw, quick actions 141 + paging.rs text wrapping, page navigation, load/prefetch 142 + epub_pipeline.rs ZIP/OPF parsing, chapter caching, background strip 143 + images.rs image detection, decode dispatch, dithering 144 + widgets/ 145 + mod.rs widget re-exports 146 + bitmap_label.rs proportional text label (uses fonts/) 147 + quick_menu.rs power-button overlay menu 148 + button_feedback.rs button press visual feedback 149 + 150 + build.rs fontdue TTF rasterisation at compile time 151 + assets/fonts/ TTF files (regular, bold, italic) 152 + assets/upload.html web UI for wifi upload mode 153 + 154 + design notes 155 + kernel / app split. the kernel crate (kernel/) has zero imports 156 + from apps/ or fonts/. the scheduler is generic over AppLayer; 157 + it never names a concrete app. AppId is defined by the distro, 158 + not the kernel -- the kernel only knows AppIdType::HOME. 159 + 160 + no dyn dispatch. with_app!() macro matches AppId, expands to 161 + concrete calls per app struct. all monomorphised; no vtable, 162 + no Box. 163 + 164 + strip rendering. 12 x 40-row strips (4 KB each) instead of a 165 + 48 KB framebuffer. draw callback fires per strip during SPI 166 + transfer. blit_1bpp_270 fast path walks physical memory linearly 167 + for the portrait rotation. windowed mode for partial refresh. 168 + 169 + 3-phase partial refresh. write BW RAM, kick DU waveform, collect 170 + input during ~400 ms refresh, then sync RED RAM. phase3 skipped 171 + during rapid navigation (RED marked stale; next partial uses 172 + inv_red recovery). full GC promoted after configurable number 173 + of partials to clear ghosting. 174 + 175 + SPI bus sharing. EPD and SD share one SPI2 bus. all SD I/O 176 + completes before any EPD render pass. busy_wait_with_input() 177 + collects only input events, no background work. violating the 178 + ordering panics (RefCell double-borrow), never corrupts. 179 + 180 + poll_once. embedded-sdmmc's async API wraps blocking SPI+DMA 181 + that never pends. poll_once drives every future to completion 182 + in a single poll, avoiding task spawn overhead. 183 + 184 + KernelHandle. apps never touch hardware. KernelHandle borrows 185 + the Kernel for one lifecycle method and exposes file I/O, dir 186 + cache, bookmarks. every async method does sync work then 187 + yield_now() for executor fairness. 188 + 189 + smol-epub sync bridge. smol-epub I/O uses closures, not async. 190 + with_sync_reader() provides a scoped closure that completes 191 + all storage access before returning -- no borrows across await. 192 + 193 + heavy statics. large structs (ReaderApp ~28 KB, DirCache ~10 KB, 194 + StripBuffer ~4 KB) live in ConstStaticCell / StaticCell so the 195 + async future stays ~200 B. 196 + 197 + nav stack. Launcher<Id> holds a 4-deep stack. transitions 198 + (Push/Pop/Replace/Home) drive on_suspend / on_enter / on_resume 199 + lifecycle. Push degrades to Replace when stack is full. 73 200 74 - Apps never touch hardware. Services mediates all I/O (SD, dir 75 - cache, bookmarks) and is only passed in via on_work(). 201 + dirty-region tracking. apps call ctx.mark_dirty(region); regions 202 + are unioned per frame. partial DU or full GC issued accordingly. 76 203 77 - Dirty-region tracking. Apps call ctx.mark_dirty(region); regions 78 - are unioned per frame. Partial DU or full GC issued accordingly. 204 + work queue. dedicated embassy task for CPU-heavy work (HTML strip, 205 + image decode). generation-based cancellation: bump a counter and 206 + drain channels; worker checks generation before and after 207 + processing. channel capacity 1 for back-pressure. 79 208 80 - Strip rendering. 12 x 40-row strips (4 KB each) instead of a 81 - 48 KB framebuffer. Draw callback fires per strip during DMA. 82 - Windowed mode for partial refresh; widgets use logical coords. 209 + input. ADC ladders at 100 Hz, 4-sample oversampling, 15 ms 210 + debounce, 1 s long-press, 150 ms repeat. ButtonMapper translates 211 + physical buttons to semantic actions. apps never see hardware. 83 212 84 - Heavy statics. Large structs live in ConstStaticCell / StaticCell 85 - so the async future stays ~200 B. Taken once, passed as &'static mut. 213 + fonts. build.rs rasterises TTFs via fontdue into 1-bit bitmaps 214 + at five sizes (xsmall through xlarge), three styles (regular, 215 + bold, italic). ASCII direct-indexed, extended unicode binary- 216 + searched. book and UI sizes independently hot-swappable. 86 217 87 - Nav stack. Launcher holds a 4-deep AppId stack. Transitions 88 - (Push/Pop/Replace/Home) drive on_suspend / on_enter lifecycle. 218 + boot console. kernel renders text during hardware init using 219 + built-in FONT_6X13 mono font. works with zero fontdue, zero 220 + TTFs. if the SD card is missing, user still sees boot progress. 89 221 90 - Quick menu. Power button opens a per-app overlay; drawn inline 91 - during the strip pass. Refresh and go-home always available. 222 + bookmarks. 16-slot LRU, RAM-resident, binary format on SD. 223 + flushed every 30 s if dirty, plus on sleep. lookup by fnv1a 224 + hash + case-insensitive name comparison. 92 225 93 - Heap budget. 140 KB heap; used only for epub chapter text and 94 - image decode (alloc::vec). Peak ~79 KB. Rest is stack/static. 226 + settings. key=value text in _PULP/SETTINGS.TXT. parsed at boot, 227 + saved on change. font size changes propagate to all apps. 95 228 96 - smol-epub. Companion no_std crate: ZIP/DEFLATE, OPF spine, 97 - streaming HTML strip, 1-bit Floyd-Steinberg PNG/JPEG decoders. 98 - All I/O via generic read closure; storage-agnostic. 229 + wifi upload. bypasses normal dispatch. HTTP server on port 80, 230 + mDNS on 5353 (pulp.local). multipart upload with 8.3 filename 231 + sanitisation. radio torn down before returning to app loop. 99 232 100 - Input. ADC ladders sampled at 100 Hz, debounced, long-press and 101 - repeat detected in driver. ButtonMapper maps to semantic actions. 233 + memory budget. ~172 KB heap for epub text and image decode 234 + (alloc::vec). everything else is static or stack. ~56 KB stack, 235 + painted 0xDEAD_BEEF at boot, high-water mark logged every 5 s. 102 236 103 - Fonts. build.rs rasterises TTFs via fontdue into 1-bit bitmaps 104 - at three sizes. Book and UI sizes independently hot-swappable. 237 + forkable kernel. designed to be extracted as a standalone crate. 238 + a fork defines its own AppId, implements AppLayer, brings its 239 + own fonts and apps, writes a main.rs. the kernel provides 240 + drivers, scheduling, storage, bookmarks, config, and a working 241 + EPD with mono boot console. 105 242 106 - LICENSE 243 + license 107 244 MIT
+386 -151
build.rs
··· 16 16 what if what.starts_with("_defmt_") => { 17 17 eprintln!(); 18 18 eprintln!( 19 - "💡 `defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`" 19 + "hint: `defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`" 20 20 ); 21 21 eprintln!(); 22 22 } 23 23 "_stack_start" => { 24 24 eprintln!(); 25 - eprintln!("💡 Is the linker script `linkall.x` missing?"); 25 + eprintln!("hint: is the linker script `linkall.x` missing?"); 26 26 eprintln!(); 27 27 } 28 28 what if what.starts_with("esp_rtos_") => { 29 29 eprintln!(); 30 30 eprintln!( 31 - "💡 `esp-radio` has no scheduler enabled. Make sure you have initialized `esp-rtos` or provided an external scheduler." 31 + "hint: `esp-radio` has no scheduler enabled. make sure you have initialized `esp-rtos` or provided an external scheduler." 32 32 ); 33 33 eprintln!(); 34 34 } 35 35 "embedded_test_linker_file_not_added_to_rustflags" => { 36 36 eprintln!(); 37 37 eprintln!( 38 - "💡 `embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests" 38 + "hint: `embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests" 39 39 ); 40 40 eprintln!(); 41 41 } ··· 49 49 | "free_internal" => { 50 50 eprintln!(); 51 51 eprintln!( 52 - "💡 Did you forget the `esp-alloc` dependency or didn't enable the `compat` feature on it?" 52 + "hint: did you forget the `esp-alloc` dependency or didn't enable the `compat` feature on it?" 53 53 ); 54 54 eprintln!(); 55 55 } ··· 69 69 ); 70 70 } 71 71 72 - // build-time font rasterisation: scan assets/fonts/ for TTFs, classify by 73 - // weight/style, rasterise ASCII 0x20-0x7E to 1-bit bitmaps, emit font_data.rs. 72 + // build-time font rasterisation: scan assets/fonts/ for TTFs, classify 73 + // by weight/style, rasterise to 1-bit bitmaps, emit font_data.rs. 74 + // 75 + // five size tiers: XSmall / Small / Medium / Large / XLarge 76 + // two glyph sets per font: 77 + // ascii 0x20-0x7E (contiguous direct-indexed table) 78 + // extended unicode (sorted codepoint table, binary-searched at runtime) 79 + // 80 + // extended set covers latin-1 supplement, common punctuation (smart 81 + // quotes, dashes, ellipsis, bullet), and a handful of currency/math 82 + // symbols, enough for the vast majority of european-language epubs. 74 83 75 84 use std::fs; 76 85 use std::io::Write; 77 86 use std::path::{Path, PathBuf}; 78 87 79 - // body sizes: 0 = Small, 1 = Medium, 2 = Large 80 - const BODY_PX_SMALL: f32 = 16.0; 81 - const BODY_PX_MEDIUM: f32 = 24.0; 82 - const BODY_PX_LARGE: f32 = 30.0; 83 - // heading sizes scale with body index: Small=22, Medium=32, Large=40 84 - const HEADING_PX_SMALL: f32 = 22.0; 85 - const HEADING_PX_MEDIUM: f32 = 32.0; 86 - const HEADING_PX_LARGE: f32 = 40.0; 87 - 88 - const BODY_SIZES: [(f32, &str); 3] = [ 89 - (BODY_PX_SMALL, "SMALL"), 90 - (BODY_PX_MEDIUM, "MEDIUM"), 91 - (BODY_PX_LARGE, "LARGE"), 88 + // body sizes (px): 0=XSmall 1=Small 2=Medium 3=Large 4=XLarge 89 + const BODY_PX: [(f32, &str); 5] = [ 90 + (14.0, "XSMALL"), 91 + (17.0, "SMALL"), 92 + (21.0, "MEDIUM"), 93 + (26.0, "LARGE"), 94 + (32.0, "XLARGE"), 92 95 ]; 93 96 94 - const HEADING_SIZES: [(f32, &str); 3] = [ 95 - (HEADING_PX_SMALL, "SMALL"), 96 - (HEADING_PX_MEDIUM, "MEDIUM"), 97 - (HEADING_PX_LARGE, "LARGE"), 97 + // Heading sizes scale proportionally 98 + const HEADING_PX: [(f32, &str); 5] = [ 99 + (19.0, "XSMALL"), 100 + (23.0, "SMALL"), 101 + (28.0, "MEDIUM"), 102 + (34.0, "LARGE"), 103 + (42.0, "XLARGE"), 98 104 ]; 99 105 100 106 // fontdue coverage threshold; values >= this become black 101 107 const THRESHOLD: u8 = 100; 102 108 109 + // ASCII range (direct-indexed) 103 110 const FIRST_CHAR: u8 = 0x20; 104 111 const LAST_CHAR: u8 = 0x7E; 105 112 const GLYPH_COUNT: usize = (LAST_CHAR - FIRST_CHAR + 1) as usize; 106 113 114 + // build the sorted list of extended unicode codepoints to rasterise 115 + fn extended_codepoints() -> Vec<u32> { 116 + let mut cps: Vec<u32> = Vec::new(); 117 + 118 + // latin-1 supplement (0x00A0-0x00FF): accented letters, symbols 119 + // skip 0x00A0 (NBSP) and 0x00AD (soft hyphen), they are whitespace 120 + for cp in 0x00A1..=0x00FFu32 { 121 + if cp == 0x00AD { 122 + continue; // soft hyphen, handled as whitespace 123 + } 124 + cps.push(cp); 125 + } 126 + 127 + // Latin Extended-A: most common characters in European languages 128 + // Czech, Polish, Hungarian, Turkish, Romanian, etc. 129 + let latin_ext_a: &[u32] = &[ 130 + 0x0100, 0x0101, // Āā 131 + 0x0102, 0x0103, // Ăă 132 + 0x0104, 0x0105, // Ąą 133 + 0x0106, 0x0107, // Ćć 134 + 0x010C, 0x010D, // Čč 135 + 0x010E, 0x010F, // Ďď 136 + 0x0110, 0x0111, // Đđ 137 + 0x0118, 0x0119, // Ęę 138 + 0x011A, 0x011B, // Ěě 139 + 0x011E, 0x011F, // Ğğ 140 + 0x0130, 0x0131, // İı 141 + 0x0141, 0x0142, // Łł 142 + 0x0143, 0x0144, // Ńń 143 + 0x0147, 0x0148, // Ňň 144 + 0x0150, 0x0151, // Őő 145 + 0x0152, 0x0153, // Œœ 146 + 0x0158, 0x0159, // Řř 147 + 0x015A, 0x015B, // Śś 148 + 0x015E, 0x015F, // Şş 149 + 0x0160, 0x0161, // Šš 150 + 0x0162, 0x0163, // Ţţ 151 + 0x0164, 0x0165, // Ťť 152 + 0x016E, 0x016F, // Ůů 153 + 0x0170, 0x0171, // Űű 154 + 0x0178, // Ÿ 155 + 0x0179, 0x017A, // Źź 156 + 0x017B, 0x017C, // Żż 157 + 0x017D, 0x017E, // Žž 158 + ]; 159 + cps.extend_from_slice(latin_ext_a); 160 + 161 + // General Punctuation: hyphens, dashes, quotes, ellipsis, bullet 162 + let punctuation: &[u32] = &[ 163 + 0x2010, // ‐ hyphen 164 + 0x2011, // ‑ non-breaking hyphen 165 + 0x2012, // ‒ figure dash 166 + 0x2013, // – en dash 167 + 0x2014, // — em dash 168 + 0x2015, // ― horizontal bar 169 + 0x2018, // ' left single quotation mark 170 + 0x2019, // ' right single quotation mark 171 + 0x201A, // ‚ single low-9 quotation mark 172 + 0x201B, // ‛ single high-reversed-9 173 + 0x201C, // " left double quotation mark 174 + 0x201D, // " right double quotation mark 175 + 0x201E, // „ double low-9 quotation mark 176 + 0x201F, // ‟ double high-reversed-9 177 + 0x2022, // • bullet 178 + 0x2026, // … horizontal ellipsis 179 + 0x2032, // ′ prime 180 + 0x2033, // ″ double prime 181 + 0x2039, // ‹ single left-pointing angle quotation 182 + 0x203A, // › single right-pointing angle quotation 183 + ]; 184 + cps.extend_from_slice(punctuation); 185 + 186 + // Currency and math 187 + let symbols: &[u32] = &[ 188 + 0x20AC, // € euro sign 189 + 0x2122, // ™ trade mark sign 190 + 0x2212, // − minus sign 191 + ]; 192 + cps.extend_from_slice(symbols); 193 + 194 + cps.sort(); 195 + cps.dedup(); 196 + cps 197 + } 198 + 199 + // find first .ttf in dir whose name contains all keywords (case-insensitive); 200 + // excludes BoldItalic unless explicitly requested 201 + fn find_ttf(dir: &Path, keywords: &[&str]) -> Option<PathBuf> { 202 + let Ok(entries) = fs::read_dir(dir) else { 203 + return None; 204 + }; 205 + let mut candidates: Vec<PathBuf> = entries 206 + .filter_map(|e| e.ok()) 207 + .map(|e| e.path()) 208 + .filter(|p| { 209 + p.extension() 210 + .map(|e| e.eq_ignore_ascii_case("ttf")) 211 + .unwrap_or(false) 212 + }) 213 + .collect(); 214 + candidates.sort(); 215 + 216 + for path in &candidates { 217 + let stem = path 218 + .file_stem() 219 + .unwrap_or_default() 220 + .to_string_lossy() 221 + .to_lowercase(); 222 + 223 + let all_match = keywords.iter().all(|kw| stem.contains(&kw.to_lowercase())); 224 + if !all_match { 225 + continue; 226 + } 227 + 228 + // reject BoldItalic when looking for just Bold or just Italic 229 + if keywords.len() == 1 { 230 + if keywords[0].eq_ignore_ascii_case("Bold") && stem.contains("italic") { 231 + continue; 232 + } 233 + if keywords[0].eq_ignore_ascii_case("Italic") && stem.contains("bold") { 234 + continue; 235 + } 236 + } 237 + 238 + return Some(path.clone()); 239 + } 240 + None 241 + } 242 + 107 243 fn generate_bitmap_fonts() { 108 244 let out_dir = std::env::var("OUT_DIR").unwrap(); 109 245 let dest = Path::new(&out_dir).join("font_data.rs"); ··· 128 264 } 129 265 println!("cargo:rerun-if-changed=assets/fonts"); 130 266 131 - // header (clippy suppression lives on the module decl in fonts/mod.rs) 267 + let ext_codepoints = extended_codepoints(); 268 + 269 + // header 132 270 writeln!(out, "// AUTO-GENERATED by build.rs - do not edit").unwrap(); 133 271 writeln!( 134 272 out, ··· 139 277 140 278 let has_regular = regular.is_some(); 141 279 writeln!(out, "pub const HAS_REGULAR: bool = {};", has_regular).unwrap(); 280 + 281 + // emit the total number of extended codepoints (used by bitmap.rs) 282 + writeln!( 283 + out, 284 + "pub const EXT_GLYPH_COUNT: usize = {};", 285 + ext_codepoints.len() 286 + ) 287 + .unwrap(); 142 288 writeln!(out).unwrap(); 143 289 144 290 // regular ··· 147 293 let font = fontdue::Font::from_bytes(data.as_slice(), fontdue::FontSettings::default()) 148 294 .expect("failed to parse regular TTF"); 149 295 eprintln!( 150 - "cargo:warning=font: rasterising {} ({} glyphs) at {}/{}/{} px body, {}/{}/{} px heading", 296 + "cargo:warning=font: rasterising {} ({} glyphs, {} ext codepoints) body {:.0}/{:.0}/{:.0}/{:.0}/{:.0} px heading {:.0}/{:.0}/{:.0}/{:.0}/{:.0} px", 151 297 path.file_name().unwrap().to_string_lossy(), 152 298 font.glyph_count(), 153 - BODY_PX_SMALL, 154 - BODY_PX_MEDIUM, 155 - BODY_PX_LARGE, 156 - HEADING_PX_SMALL, 157 - HEADING_PX_MEDIUM, 158 - HEADING_PX_LARGE, 299 + ext_codepoints.len(), 300 + BODY_PX[0].0, 301 + BODY_PX[1].0, 302 + BODY_PX[2].0, 303 + BODY_PX[3].0, 304 + BODY_PX[4].0, 305 + HEADING_PX[0].0, 306 + HEADING_PX[1].0, 307 + HEADING_PX[2].0, 308 + HEADING_PX[3].0, 309 + HEADING_PX[4].0, 159 310 ); 160 - for (px, suffix) in &BODY_SIZES { 161 - emit_font(&mut out, &font, &format!("REGULAR_BODY_{suffix}"), *px); 311 + for (px, suffix) in &BODY_PX { 312 + emit_font( 313 + &mut out, 314 + &font, 315 + &format!("REGULAR_BODY_{suffix}"), 316 + *px, 317 + &ext_codepoints, 318 + ); 162 319 } 163 - for (px, suffix) in &HEADING_SIZES { 164 - emit_font(&mut out, &font, &format!("REGULAR_HEADING_{suffix}"), *px); 320 + for (px, suffix) in &HEADING_PX { 321 + emit_font( 322 + &mut out, 323 + &font, 324 + &format!("REGULAR_HEADING_{suffix}"), 325 + *px, 326 + &ext_codepoints, 327 + ); 165 328 } 166 329 } else { 167 - for (_px, suffix) in &BODY_SIZES { 330 + for (_px, suffix) in &BODY_PX { 168 331 emit_stub(&mut out, &format!("REGULAR_BODY_{suffix}")); 169 332 } 170 - for (_px, suffix) in &HEADING_SIZES { 333 + for (_px, suffix) in &HEADING_PX { 171 334 emit_stub(&mut out, &format!("REGULAR_HEADING_{suffix}")); 172 335 } 173 336 } ··· 178 341 let font = fontdue::Font::from_bytes(data.as_slice(), fontdue::FontSettings::default()) 179 342 .expect("failed to parse bold TTF"); 180 343 eprintln!( 181 - "cargo:warning=font: rasterising {} at {}/{}/{} px body", 344 + "cargo:warning=font: rasterising {} body {:.0}/{:.0}/{:.0}/{:.0}/{:.0} px", 182 345 path.file_name().unwrap().to_string_lossy(), 183 - BODY_PX_SMALL, 184 - BODY_PX_MEDIUM, 185 - BODY_PX_LARGE, 346 + BODY_PX[0].0, 347 + BODY_PX[1].0, 348 + BODY_PX[2].0, 349 + BODY_PX[3].0, 350 + BODY_PX[4].0, 186 351 ); 187 - for (px, suffix) in &BODY_SIZES { 188 - emit_font(&mut out, &font, &format!("BOLD_BODY_{suffix}"), *px); 352 + for (px, suffix) in &BODY_PX { 353 + emit_font( 354 + &mut out, 355 + &font, 356 + &format!("BOLD_BODY_{suffix}"), 357 + *px, 358 + &ext_codepoints, 359 + ); 189 360 } 190 361 } else { 191 - for (_px, suffix) in &BODY_SIZES { 362 + for (_px, suffix) in &BODY_PX { 192 363 emit_stub(&mut out, &format!("BOLD_BODY_{suffix}")); 193 364 } 194 365 } ··· 199 370 let font = fontdue::Font::from_bytes(data.as_slice(), fontdue::FontSettings::default()) 200 371 .expect("failed to parse italic TTF"); 201 372 eprintln!( 202 - "cargo:warning=font: rasterising {} at {}/{}/{} px body", 373 + "cargo:warning=font: rasterising {} body {:.0}/{:.0}/{:.0}/{:.0}/{:.0} px", 203 374 path.file_name().unwrap().to_string_lossy(), 204 - BODY_PX_SMALL, 205 - BODY_PX_MEDIUM, 206 - BODY_PX_LARGE, 375 + BODY_PX[0].0, 376 + BODY_PX[1].0, 377 + BODY_PX[2].0, 378 + BODY_PX[3].0, 379 + BODY_PX[4].0, 207 380 ); 208 - for (px, suffix) in &BODY_SIZES { 209 - emit_font(&mut out, &font, &format!("ITALIC_BODY_{suffix}"), *px); 381 + for (px, suffix) in &BODY_PX { 382 + emit_font( 383 + &mut out, 384 + &font, 385 + &format!("ITALIC_BODY_{suffix}"), 386 + *px, 387 + &ext_codepoints, 388 + ); 210 389 } 211 390 } else { 212 - for (_px, suffix) in &BODY_SIZES { 391 + for (_px, suffix) in &BODY_PX { 213 392 emit_stub(&mut out, &format!("ITALIC_BODY_{suffix}")); 214 393 } 215 394 } 216 395 } 217 396 218 - // find first .ttf in dir whose name contains all keywords (case-insensitive); 219 - // excludes BoldItalic unless explicitly requested 220 - fn find_ttf(dir: &Path, keywords: &[&str]) -> Option<PathBuf> { 221 - let Ok(entries) = fs::read_dir(dir) else { 222 - return None; 223 - }; 224 - let mut candidates: Vec<PathBuf> = entries 225 - .filter_map(|e| e.ok()) 226 - .map(|e| e.path()) 227 - .filter(|p| { 228 - p.extension() 229 - .map(|e| e.eq_ignore_ascii_case("ttf")) 230 - .unwrap_or(false) 231 - }) 232 - .collect(); 233 - candidates.sort(); 397 + struct RasterGlyph { 398 + advance: u8, 399 + offset_x: i8, 400 + offset_y: i8, 401 + width: u8, 402 + height: u8, 403 + bits: Vec<u8>, 404 + } 234 405 235 - for path in &candidates { 236 - let stem = path 237 - .file_stem() 238 - .unwrap_or_default() 239 - .to_string_lossy() 240 - .to_lowercase(); 406 + fn rasterize_char(font: &fontdue::Font, ch: char, px: f32) -> RasterGlyph { 407 + let (metrics, coverage) = font.rasterize(ch, px); 408 + let w = metrics.width; 409 + let h = metrics.height; 410 + let row_bytes = w.div_ceil(8); 241 411 242 - let all_match = keywords.iter().all(|kw| stem.contains(&kw.to_lowercase())); 243 - if !all_match { 244 - continue; 245 - } 246 - 247 - // reject BoldItalic when looking for just Bold or just Italic 248 - if keywords.len() == 1 { 249 - if keywords[0].eq_ignore_ascii_case("Bold") && stem.contains("italic") { 250 - continue; 251 - } 252 - if keywords[0].eq_ignore_ascii_case("Italic") && stem.contains("bold") { 253 - continue; 412 + // pack coverage to 1-bit MSB-first 413 + let mut bits = Vec::with_capacity(row_bytes * h); 414 + for y in 0..h { 415 + for bx in 0..row_bytes { 416 + let mut byte = 0u8; 417 + for bit in 0..8usize { 418 + let x = bx * 8 + bit; 419 + if x < w && coverage[y * w + x] >= THRESHOLD { 420 + byte |= 1 << (7 - bit); 421 + } 254 422 } 423 + bits.push(byte); 255 424 } 425 + } 256 426 257 - return Some(path.clone()); 427 + // offset_y: baseline to top row (y-down screen space). 428 + // fontdue ymin = baseline to bottom edge; top = ymin+h above baseline; 429 + // negate for screen coords. 430 + let offset_y = -metrics.ymin - (h as i32); 431 + 432 + RasterGlyph { 433 + advance: (metrics.advance_width + 0.5) as u8, 434 + offset_x: (metrics.xmin).clamp(-128, 127) as i8, 435 + offset_y: offset_y.clamp(-128, 127) as i8, 436 + width: w.min(255) as u8, 437 + height: h.min(255) as u8, 438 + bits, 258 439 } 259 - None 260 440 } 261 441 262 - fn emit_font(out: &mut fs::File, font: &fontdue::Font, name: &str, px: f32) { 442 + fn emit_font( 443 + out: &mut fs::File, 444 + font: &fontdue::Font, 445 + name: &str, 446 + px: f32, 447 + ext_codepoints: &[u32], 448 + ) { 263 449 // line metrics 264 450 let lm = font 265 451 .horizontal_line_metrics(px) ··· 267 453 let line_height = lm.new_line_size.ceil() as u16; 268 454 let ascent = lm.ascent.ceil() as u16; 269 455 270 - // rasterise each glyph 271 - struct Glyph { 272 - advance: u8, 273 - offset_x: i8, 274 - offset_y: i8, 275 - width: u8, 276 - height: u8, 277 - bits: Vec<u8>, 278 - } 456 + // ascii glyphs (direct-indexed 0x20-0x7E) 279 457 280 - let mut glyphs: Vec<Glyph> = Vec::with_capacity(GLYPH_COUNT); 281 - let mut total_bits: usize = 0; 458 + let mut ascii_glyphs: Vec<RasterGlyph> = Vec::with_capacity(GLYPH_COUNT); 459 + let mut ascii_bits_total: usize = 0; 282 460 283 461 for code in FIRST_CHAR..=LAST_CHAR { 284 - let ch = code as char; 285 - let (metrics, coverage) = font.rasterize(ch, px); 286 - let w = metrics.width; 287 - let h = metrics.height; 288 - let row_bytes = w.div_ceil(8); 289 - 290 - // pack coverage to 1-bit MSB-first 291 - let mut bits = Vec::with_capacity(row_bytes * h); 292 - for y in 0..h { 293 - for bx in 0..row_bytes { 294 - let mut byte = 0u8; 295 - for bit in 0..8usize { 296 - let x = bx * 8 + bit; 297 - if x < w && coverage[y * w + x] >= THRESHOLD { 298 - byte |= 1 << (7 - bit); 299 - } 300 - } 301 - bits.push(byte); 302 - } 303 - } 304 - 305 - // offset_y: baseline to top row (y-down screen space). 306 - // fontdue ymin = baseline to bottom edge; top = ymin+h above baseline; 307 - // negate for screen coords. 308 - let offset_y = -metrics.ymin - (h as i32); 309 - 310 - total_bits += bits.len(); 311 - glyphs.push(Glyph { 312 - advance: (metrics.advance_width + 0.5) as u8, 313 - offset_x: (metrics.xmin).clamp(-128, 127) as i8, 314 - offset_y: offset_y.clamp(-128, 127) as i8, 315 - width: w.min(255) as u8, 316 - height: h.min(255) as u8, 317 - bits, 318 - }); 462 + let g = rasterize_char(font, code as char, px); 463 + ascii_bits_total += g.bits.len(); 464 + ascii_glyphs.push(g); 319 465 } 320 466 321 - // glyph table 467 + // emit ASCII glyph table 322 468 writeln!(out, "static {name}_GLYPHS: [BitmapGlyph; GLYPH_COUNT] = [").unwrap(); 323 469 let mut offset: u16 = 0; 324 - for (i, g) in glyphs.iter().enumerate() { 470 + for (i, g) in ascii_glyphs.iter().enumerate() { 325 471 let ch = (FIRST_CHAR + i as u8) as char; 326 472 writeln!( 327 473 out, ··· 333 479 writeln!(out, "];").unwrap(); 334 480 writeln!(out).unwrap(); 335 481 336 - // bitmap data 337 - writeln!(out, "static {name}_BITMAPS: [u8; {total_bits}] = [").unwrap(); 482 + // emit ASCII bitmap data 483 + writeln!(out, "static {name}_BITMAPS: [u8; {ascii_bits_total}] = [").unwrap(); 484 + emit_bitmap_bytes(out, &ascii_glyphs); 485 + writeln!(out, "];").unwrap(); 486 + writeln!(out).unwrap(); 487 + 488 + // extended unicode glyphs (sorted by codepoint) 489 + 490 + let mut ext_glyphs: Vec<RasterGlyph> = Vec::with_capacity(ext_codepoints.len()); 491 + let mut ext_bits_total: usize = 0; 492 + 493 + for &cp in ext_codepoints { 494 + if let Some(ch) = char::from_u32(cp) { 495 + let g = rasterize_char(font, ch, px); 496 + ext_bits_total += g.bits.len(); 497 + ext_glyphs.push(g); 498 + } else { 499 + // invalid codepoint, push a zero-width space placeholder 500 + ext_glyphs.push(RasterGlyph { 501 + advance: 0, 502 + offset_x: 0, 503 + offset_y: 0, 504 + width: 0, 505 + height: 0, 506 + bits: Vec::new(), 507 + }); 508 + } 509 + } 510 + 511 + let ext_count = ext_codepoints.len(); 512 + 513 + // emit extended codepoint lookup array 514 + writeln!(out, "static {name}_EXT_CP: [u32; {ext_count}] = [").unwrap(); 338 515 let mut col = 0; 339 - for g in &glyphs { 340 - for &b in &g.bits { 341 - if col == 0 { 342 - write!(out, " ").unwrap(); 343 - } 344 - write!(out, "0x{b:02X},").unwrap(); 345 - col += 1; 346 - if col >= 16 { 347 - writeln!(out).unwrap(); 348 - col = 0; 349 - } 516 + for &cp in ext_codepoints { 517 + if col == 0 { 518 + write!(out, " ").unwrap(); 519 + } 520 + write!(out, "0x{cp:04X},").unwrap(); 521 + col += 1; 522 + if col >= 10 { 523 + writeln!(out).unwrap(); 524 + col = 0; 350 525 } 351 526 } 352 527 if col > 0 { ··· 355 530 writeln!(out, "];").unwrap(); 356 531 writeln!(out).unwrap(); 357 532 533 + // emit extended glyph table 534 + writeln!( 535 + out, 536 + "static {name}_EXT_GLYPHS: [BitmapGlyph; {ext_count}] = [" 537 + ) 538 + .unwrap(); 539 + let mut offset: u16 = 0; 540 + for (i, g) in ext_glyphs.iter().enumerate() { 541 + let cp = ext_codepoints[i]; 542 + let ch_display = char::from_u32(cp) 543 + .map(|c| format!("{:?}", c)) 544 + .unwrap_or_else(|| format!("U+{cp:04X}")); 545 + writeln!( 546 + out, 547 + " BitmapGlyph {{ advance: {:>2}, offset_x: {:>3}, offset_y: {:>4}, width: {:>2}, height: {:>2}, bitmap_offset: {:>5} }}, // {}", 548 + g.advance, g.offset_x, g.offset_y, g.width, g.height, offset, ch_display 549 + ).unwrap(); 550 + offset += g.bits.len() as u16; 551 + } 552 + writeln!(out, "];").unwrap(); 553 + writeln!(out).unwrap(); 554 + 555 + // emit extended bitmap data 556 + writeln!(out, "static {name}_EXT_BITMAPS: [u8; {ext_bits_total}] = [").unwrap(); 557 + emit_bitmap_bytes(out, &ext_glyphs); 558 + writeln!(out, "];").unwrap(); 559 + writeln!(out).unwrap(); 560 + 358 561 // BitmapFont struct 562 + 359 563 writeln!(out, "pub static {name}: BitmapFont = BitmapFont {{").unwrap(); 360 564 writeln!(out, " glyphs: &{name}_GLYPHS,").unwrap(); 361 565 writeln!(out, " bitmaps: &{name}_BITMAPS,").unwrap(); 566 + writeln!(out, " ext_codepoints: &{name}_EXT_CP,").unwrap(); 567 + writeln!(out, " ext_glyphs: &{name}_EXT_GLYPHS,").unwrap(); 568 + writeln!(out, " ext_bitmaps: &{name}_EXT_BITMAPS,").unwrap(); 362 569 writeln!(out, " line_height: {line_height},").unwrap(); 363 570 writeln!(out, " ascent: {ascent},").unwrap(); 364 571 writeln!(out, "}};").unwrap(); ··· 368 575 fn emit_stub(out: &mut fs::File, name: &str) { 369 576 writeln!( 370 577 out, 371 - "pub static {name}: BitmapFont = BitmapFont {{ glyphs: &[BitmapGlyph {{ advance: 0, offset_x: 0, offset_y: 0, width: 0, height: 0, bitmap_offset: 0 }}; GLYPH_COUNT], bitmaps: &[], line_height: 13, ascent: 13 }};" 578 + "pub static {name}: BitmapFont = BitmapFont {{\ 579 + glyphs: &[BitmapGlyph {{ advance: 0, offset_x: 0, offset_y: 0, width: 0, height: 0, bitmap_offset: 0 }}; GLYPH_COUNT], \ 580 + bitmaps: &[], \ 581 + ext_codepoints: &[], \ 582 + ext_glyphs: &[], \ 583 + ext_bitmaps: &[], \ 584 + line_height: 13, \ 585 + ascent: 13 \ 586 + }};" 372 587 ) 373 588 .unwrap(); 374 589 writeln!(out).unwrap(); 375 590 } 591 + 592 + fn emit_bitmap_bytes(out: &mut fs::File, glyphs: &[RasterGlyph]) { 593 + let mut col = 0; 594 + for g in glyphs { 595 + for &b in &g.bits { 596 + if col == 0 { 597 + write!(out, " ").unwrap(); 598 + } 599 + write!(out, "0x{b:02X},").unwrap(); 600 + col += 1; 601 + if col >= 16 { 602 + writeln!(out).unwrap(); 603 + col = 0; 604 + } 605 + } 606 + } 607 + if col > 0 { 608 + writeln!(out).unwrap(); 609 + } 610 + }
+28
kernel/Cargo.toml
··· 1 + [package] 2 + name = "pulp-kernel" 3 + edition = "2024" 4 + rust-version = "1.88" 5 + version = "0.1.0" 6 + authors = ["Hans Martin <hansmrtn@users.noreply.github.com>"] 7 + description = "no_std async kernel for pulp-os (scheduling, drivers, storage, EPD rendering)" 8 + license = "MIT" 9 + repository = "https://github.com/hansmrtn/pulp-os" 10 + 11 + [dependencies] 12 + esp-hal.workspace = true 13 + embassy-executor.workspace = true 14 + embassy-time.workspace = true 15 + embassy-futures.workspace = true 16 + embassy-sync.workspace = true 17 + embedded-hal.workspace = true 18 + embedded-hal-async.workspace = true 19 + embedded-hal-bus.workspace = true 20 + embedded-graphics-core.workspace = true 21 + embedded-graphics.workspace = true 22 + embedded-sdmmc.workspace = true 23 + critical-section.workspace = true 24 + static_cell.workspace = true 25 + nb.workspace = true 26 + log.workspace = true 27 + smol-epub.workspace = true 28 + esp-alloc = { version = "0.9.0", features = ["internal-heap-stats"] }
+22
kernel/src/board/battery.rs
··· 1 + // battery calibration for the XTEink X4. 2 + // GPIO0 reads through 100K/100K divider (2:1); ADC 11dB attenuation 3 + // gives 0..2500 mV --- multiply by 2 for actual cell voltage. 4 + 5 + // voltage divider multiplier (100K/100K resistive divider). 6 + pub const DIVIDER_MULT: u32 = 2; 7 + 8 + // piecewise-linear li-ion discharge curve. sorted descending by mV. 9 + pub const DISCHARGE_CURVE: &[(u32, u8)] = &[ 10 + (4200, 100), 11 + (4060, 90), 12 + (3980, 80), 13 + (3920, 70), 14 + (3870, 60), 15 + (3830, 50), 16 + (3790, 40), 17 + (3750, 30), 18 + (3700, 20), 19 + (3600, 10), 20 + (3400, 5), 21 + (3000, 0), 22 + ];
+12
kernel/src/board/layout.rs
··· 1 + // physical button positions on the XTEink X4 bezel. 2 + // used by button_feedback to render labels at the correct screen edge. 3 + 4 + // center-x of bottom-edge buttons. 5 + pub const CX_BACK: u16 = 84; 6 + pub const CX_CONFIRM: u16 = 194; 7 + pub const CX_LEFT: u16 = 286; 8 + pub const CX_RIGHT: u16 = 396; 9 + 10 + // center-y of right-edge buttons. 11 + pub const CY_VOL_UP: u16 = 364; 12 + pub const CY_VOL_DOWN: u16 = 484;
+231
kernel/src/board/mod.rs
··· 1 + // board support for the XTEink X4 (ESP32-C3, SSD1677 800x480, SD over SPI2) 2 + // DMA-backed SPI (GDMA CH0); CriticalSectionDevice arbitrates bus 3 + 4 + pub mod action; 5 + pub mod battery; 6 + pub mod button; 7 + pub mod layout; 8 + pub mod raw_gpio; 9 + 10 + pub use crate::drivers::sdcard::{SdStorage, SyncSdCard}; 11 + pub use crate::drivers::ssd1677::{DisplayDriver, HEIGHT, SPI_FREQ_MHZ, WIDTH}; 12 + pub use crate::drivers::strip::StripBuffer; 13 + pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 14 + 15 + pub const SCREEN_W: u16 = HEIGHT; // 480 16 + pub const SCREEN_H: u16 = WIDTH; // 800 17 + 18 + use core::cell::RefCell; 19 + 20 + use critical_section::Mutex; 21 + use embedded_hal_bus::spi::CriticalSectionDevice; 22 + use esp_hal::{ 23 + Blocking, 24 + analog::adc::{Adc, AdcCalCurve, AdcConfig, AdcPin, Attenuation}, 25 + delay::Delay, 26 + dma::{DmaRxBuf, DmaTxBuf}, 27 + gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull}, 28 + peripherals::{ADC1, GPIO0, GPIO1, GPIO2, Peripherals}, 29 + spi, 30 + time::Rate, 31 + }; 32 + use log::info; 33 + use static_cell::StaticCell; 34 + 35 + pub type SpiBus = spi::master::SpiDmaBus<'static, Blocking>; 36 + pub type SharedSpiDevice = CriticalSectionDevice<'static, SpiBus, Output<'static>, Delay>; 37 + pub type SdSpiDevice = CriticalSectionDevice<'static, SpiBus, raw_gpio::RawOutputPin, Delay>; 38 + pub type Epd = DisplayDriver<SharedSpiDevice, Output<'static>, Output<'static>, Input<'static>>; 39 + 40 + static SPI_BUS: StaticCell<Mutex<RefCell<SpiBus>>> = StaticCell::new(); 41 + 42 + // cached ref to the SPI bus mutex, set once in Board::init 43 + static SPI_BUS_REF: Mutex<core::cell::Cell<Option<&'static Mutex<RefCell<SpiBus>>>>> = 44 + Mutex::new(core::cell::Cell::new(None)); 45 + 46 + static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None)); 47 + 48 + #[esp_hal::handler] 49 + fn gpio_handler() { 50 + critical_section::with(|cs| { 51 + if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut() 52 + && btn.is_interrupt_set() 53 + { 54 + btn.clear_interrupt(); 55 + } 56 + }); 57 + } 58 + 59 + pub fn power_button_is_low() -> bool { 60 + critical_section::with(|cs| { 61 + POWER_BTN 62 + .borrow_ref_mut(cs) 63 + .as_mut() 64 + .map(|btn| btn.is_low()) 65 + .unwrap_or(false) 66 + }) 67 + } 68 + 69 + pub struct InputHw { 70 + pub adc: Adc<'static, ADC1<'static>, Blocking>, 71 + pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 72 + pub row2: AdcPin<GPIO2<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 73 + pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 74 + } 75 + 76 + pub struct DisplayHw { 77 + pub epd: Epd, 78 + } 79 + 80 + pub struct StorageHw { 81 + // sd card, initialised at 400 kHz before EPD touches the bus 82 + pub sd_card: Option<SyncSdCard>, 83 + } 84 + 85 + pub struct Board { 86 + pub input: InputHw, 87 + pub display: DisplayHw, 88 + pub storage: StorageHw, 89 + } 90 + 91 + impl Board { 92 + pub fn init(p: Peripherals) -> Self { 93 + let input = Self::init_input(&p); 94 + let (display, storage) = Self::init_spi_peripherals(p); 95 + Board { 96 + input, 97 + display, 98 + storage, 99 + } 100 + } 101 + 102 + // gpio / peripheral ownership: 103 + // 104 + // init_input (clone_unchecked) init_spi_peripherals (move/clone) 105 + // --- --- 106 + // GPIO0 battery ADC GPIO4 EPD DC 107 + // GPIO1 button row 1 ADC GPIO5 EPD RST 108 + // GPIO2 button row 2 ADC GPIO6 EPD BUSY 109 + // GPIO3 power button GPIO7 SPI MISO 110 + // ADC1 GPIO8 SPI SCK 111 + // IO_MUX GPIO10 SPI MOSI 112 + // GPIO12 SD CS (raw register) 113 + // GPIO21 EPD CS 114 + // SPI2, DMA_CH0 115 + 116 + // Safety for all clone_unchecked calls below: 117 + // 118 + // init_input borrows Peripherals immutably and clones the pins it 119 + // needs. init_spi_peripherals later takes ownership of the full 120 + // Peripherals struct but only touches a disjoint set of GPIOs 121 + // (GPIO4-8, GPIO10, GPIO21, SPI2, DMA_CH0). See the ownership 122 + // table above for the complete split. Each peripheral listed here 123 + // is used exclusively by InputHw and never touched again. 124 + fn init_input(p: &Peripherals) -> InputHw { 125 + let mut adc_cfg = AdcConfig::new(); 126 + 127 + // Safety: GPIO1 is used only here (button row 1 ADC). 128 + let row1 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 129 + unsafe { p.GPIO1.clone_unchecked() }, 130 + Attenuation::_11dB, 131 + ); 132 + 133 + // Safety: GPIO2 is used only here (button row 2 ADC). 134 + let row2 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 135 + unsafe { p.GPIO2.clone_unchecked() }, 136 + Attenuation::_11dB, 137 + ); 138 + 139 + // Safety: GPIO0 is used only here (battery voltage ADC). 140 + let battery = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 141 + unsafe { p.GPIO0.clone_unchecked() }, 142 + Attenuation::_11dB, 143 + ); 144 + 145 + // Safety: ADC1 is used only here; init_spi_peripherals does not use ADC. 146 + let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg); 147 + 148 + // Safety: IO_MUX is used only here for the GPIO interrupt handler. 149 + let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() }); 150 + io.set_interrupt_handler(gpio_handler); 151 + 152 + // Safety: GPIO3 is used only here (power button input with IRQ). 153 + let mut power = Input::new( 154 + unsafe { p.GPIO3.clone_unchecked() }, 155 + InputConfig::default().with_pull(Pull::Up), 156 + ); 157 + power.listen(Event::FallingEdge); 158 + 159 + critical_section::with(|cs| { 160 + POWER_BTN.borrow_ref_mut(cs).replace(power); 161 + }); 162 + info!("power button: GPIO3 interrupt armed (FallingEdge)"); 163 + 164 + InputHw { 165 + adc, 166 + row1, 167 + row2, 168 + battery, 169 + } 170 + } 171 + 172 + // 400 kHz for SD probe, then 20 MHz; DMA-backed 173 + fn init_spi_peripherals(p: Peripherals) -> (DisplayHw, StorageHw) { 174 + let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default()); 175 + let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default()); 176 + let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default()); 177 + let busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None)); 178 + 179 + // GPIO12 free in DIO mode; no esp-hal type, use raw registers 180 + let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) }; 181 + 182 + let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400)); 183 + 184 + let mut spi_raw = spi::master::Spi::new(p.SPI2, slow_cfg) 185 + .unwrap() 186 + .with_sck(p.GPIO8) 187 + .with_mosi(p.GPIO10) 188 + .with_miso(p.GPIO7); 189 + 190 + // 80 clocks with CS high before DMA conversion (SD spec init) 191 + let _ = spi_raw.write(&[0xFF; 10]); 192 + 193 + // 4096B each direction: strip max ~4000B, SD sectors 512B 194 + let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = esp_hal::dma_buffers!(4096); 195 + let dma_rx_buf = DmaRxBuf::new(rx_descriptors, rx_buffer).unwrap(); 196 + let dma_tx_buf = DmaTxBuf::new(tx_descriptors, tx_buffer).unwrap(); 197 + 198 + let spi_dma_bus = spi_raw 199 + .with_dma(p.DMA_CH0) 200 + .with_buffers(dma_rx_buf, dma_tx_buf); 201 + 202 + let spi_ref: &'static Mutex<RefCell<SpiBus>> = 203 + SPI_BUS.init(Mutex::new(RefCell::new(spi_dma_bus))); 204 + info!("SPI bus: DMA enabled (CH0, 4096B TX+RX)"); 205 + 206 + critical_section::with(|cs| SPI_BUS_REF.borrow(cs).set(Some(spi_ref))); 207 + 208 + let sd_spi = CriticalSectionDevice::new(spi_ref, sd_cs, Delay::new()).unwrap(); 209 + 210 + // init SD card now, at 400 kHz on a pristine bus, before EPD 211 + // traffic -- SD spec requires CMD0 on a clean bus 212 + let sd_card = SdStorage::init_card(sd_spi); 213 + 214 + let epd_spi = CriticalSectionDevice::new(spi_ref, epd_cs, Delay::new()).unwrap(); 215 + let epd = DisplayDriver::new(epd_spi, dc, rst, busy); 216 + 217 + (DisplayHw { epd }, StorageHw { sd_card }) 218 + } 219 + } 220 + 221 + // switch SPI bus from 400 kHz to operational frequency (20 MHz) 222 + // call after Board::init and before first EPD render 223 + pub fn speed_up_spi() { 224 + let fast_cfg = spi::master::Config::default().with_frequency(Rate::from_mhz(SPI_FREQ_MHZ)); 225 + critical_section::with(|cs| { 226 + if let Some(bus) = SPI_BUS_REF.borrow(cs).get() { 227 + bus.borrow(cs).borrow_mut().apply_config(&fast_cfg).unwrap(); 228 + info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ); 229 + } 230 + }); 231 + }
+8
kernel/src/drivers/mod.rs
··· 1 + // hardware drivers: chip-level and protocol-level, board-independent 2 + 3 + pub mod battery; 4 + pub mod input; 5 + pub mod sdcard; 6 + pub mod ssd1677; 7 + pub mod storage; 8 + pub mod strip;
+192
kernel/src/drivers/sdcard.rs
··· 1 + // sd card over SPI: sync SdCard + async volume manager 2 + // 3 + // sync SdCard handles the SD protocol (CMD0, init, sector I/O) 4 + // using embedded_hal SpiDevice + DelayNs traits 5 + // 6 + // BlockDeviceAdapter bridges sync BlockDevice to AsyncBlockDevice 7 + // so AsyncVolumeManager can consume it 8 + // 9 + // poll_once drives file-I/O futures to completion in a single poll 10 + // (SPI bus is blocking, so every .await resolves immediately) 11 + 12 + use core::cell::RefCell; 13 + use core::future::Future; 14 + use core::pin::pin; 15 + use core::task::{Context, Poll, Waker}; 16 + use embedded_hal::delay::DelayNs; 17 + 18 + use embedded_sdmmc::{ 19 + AsyncBlockDevice, AsyncVolumeManager, Block, BlockCount, BlockDevice, BlockIdx, RawDirectory, 20 + RawVolume, SdCard, TimeSource, Timestamp, VolumeIdx, 21 + }; 22 + use log::info; 23 + 24 + use crate::board::SdSpiDevice; 25 + 26 + // sync BlockDevice -> AsyncBlockDevice adapter 27 + // 28 + // sync SdCard uses RefCell internally, takes &self for BlockDevice 29 + // methods; we delegate AsyncBlockDevice &mut self to the inner &self 30 + // methods -- all resolve immediately since SPI is DMA-blocking 31 + 32 + pub(crate) struct BlockDeviceAdapter<D: BlockDevice>(D); 33 + 34 + impl<D: BlockDevice> AsyncBlockDevice for BlockDeviceAdapter<D> { 35 + type Error = D::Error; 36 + 37 + async fn read( 38 + &mut self, 39 + blocks: &mut [Block], 40 + start_block_idx: BlockIdx, 41 + ) -> Result<(), Self::Error> { 42 + self.0.read(blocks, start_block_idx) 43 + } 44 + 45 + async fn write( 46 + &mut self, 47 + blocks: &[Block], 48 + start_block_idx: BlockIdx, 49 + ) -> Result<(), Self::Error> { 50 + self.0.write(blocks, start_block_idx) 51 + } 52 + 53 + async fn num_blocks(&mut self) -> Result<BlockCount, Self::Error> { 54 + self.0.num_blocks() 55 + } 56 + } 57 + 58 + // no RTC on this board 59 + 60 + pub(crate) struct NullTimeSource; 61 + 62 + impl TimeSource for NullTimeSource { 63 + fn get_timestamp(&self) -> Timestamp { 64 + Timestamp { 65 + year_since_1970: 0, 66 + zero_indexed_month: 0, 67 + zero_indexed_day: 0, 68 + hours: 0, 69 + minutes: 0, 70 + seconds: 0, 71 + } 72 + } 73 + } 74 + 75 + // type aliases 76 + 77 + pub type SyncSdCard = SdCard<SdSpiDevice, esp_hal::delay::Delay>; 78 + pub(crate) type SdBlockDev = BlockDeviceAdapter<SyncSdCard>; 79 + pub(crate) type VolMgr = AsyncVolumeManager<SdBlockDev, NullTimeSource, 4, 4, 1>; 80 + 81 + // persistent volume manager state, held behind RefCell for interior 82 + // mutability (AsyncVolumeManager requires &mut self) 83 + 84 + pub(crate) struct SdStorageInner { 85 + pub(crate) mgr: VolMgr, 86 + #[allow(dead_code)] 87 + pub(crate) vol: RawVolume, 88 + pub(crate) root: RawDirectory, 89 + } 90 + 91 + // holds a persistently-mounted AsyncVolumeManager with volume 0 and 92 + // root directory kept open for the device lifetime; RefCell provides 93 + // interior mutability so storage functions can take &SdStorage 94 + 95 + pub struct SdStorage { 96 + inner: Option<RefCell<SdStorageInner>>, 97 + } 98 + 99 + impl SdStorage { 100 + pub fn empty() -> Self { 101 + Self { inner: None } 102 + } 103 + 104 + // init SD card at 400 kHz (SD spec init frequency) 105 + // 106 + // sync SdCard auto-initialises on first method call; we call 107 + // num_bytes() to force init and verify the card responds 108 + // 109 + // pub so Board::init can run this before other SPI peripherals 110 + // touch the bus -- SD spec requires a clean 400 kHz bus for CMD0 111 + pub fn init_card(spi_device: SdSpiDevice) -> Option<SyncSdCard> { 112 + let sd = SdCard::new(spi_device, esp_hal::delay::Delay::new()); 113 + 114 + for attempt in 1..=5 { 115 + match sd.num_bytes() { 116 + Ok(size) => { 117 + info!("SD card: initialised (attempt {})", attempt); 118 + info!("SD card: {} bytes ({} MB)", size, size / 1024 / 1024); 119 + return Some(sd); 120 + } 121 + Err(e) => { 122 + info!("SD card: init attempt {} failed: {:?}", attempt, e); 123 + sd.mark_card_uninit(); 124 + esp_hal::delay::Delay::new().delay_ms(50); 125 + } 126 + } 127 + } 128 + 129 + info!("SD card: all init attempts failed"); 130 + None 131 + } 132 + 133 + // mount FAT filesystem on an already-initialised SD card 134 + // 135 + // opens volume 0 (first MBR partition) and keeps the root 136 + // directory open for the device lifetime 137 + pub async fn mount(sd: SyncSdCard) -> Self { 138 + let adapter = BlockDeviceAdapter(sd); 139 + let mut mgr = AsyncVolumeManager::new(adapter, NullTimeSource); 140 + 141 + let vol = match mgr.open_raw_volume(VolumeIdx(0)).await { 142 + Ok(v) => v, 143 + Err(e) => { 144 + info!("SD card: open volume failed: {}", e); 145 + return Self { inner: None }; 146 + } 147 + }; 148 + 149 + let root = match mgr.open_root_dir(vol) { 150 + Ok(d) => d, 151 + Err(e) => { 152 + info!("SD card: open root dir failed: {}", e); 153 + let _ = mgr.close_volume(vol).await; 154 + return Self { inner: None }; 155 + } 156 + }; 157 + 158 + info!("SD card: filesystem mounted"); 159 + Self { 160 + inner: Some(RefCell::new(SdStorageInner { mgr, vol, root })), 161 + } 162 + } 163 + 164 + #[inline] 165 + pub fn probe_ok(&self) -> bool { 166 + self.inner.is_some() 167 + } 168 + 169 + #[inline] 170 + pub(crate) fn borrow_inner(&self) -> Option<core::cell::RefMut<'_, SdStorageInner>> { 171 + self.inner.as_ref().map(|c| c.borrow_mut()) 172 + } 173 + } 174 + 175 + // drive a future to completion in exactly one poll 176 + // 177 + // correct because the SPI bus is blocking and the sync SdCard 178 + // completes every operation before returning -- no inner .await 179 + // ever returns Pending 180 + // 181 + // only use for file-level operations (open, read, write, seek, 182 + // iterate); mount runs inside the real Embassy executor 183 + 184 + pub fn poll_once<T>(fut: impl Future<Output = T>) -> T { 185 + let waker: &Waker = Waker::noop(); 186 + let mut cx = Context::from_waker(waker); 187 + let mut fut = pin!(fut); 188 + match fut.as_mut().poll(&mut cx) { 189 + Poll::Ready(v) => v, 190 + Poll::Pending => panic!("poll_once: future pended -- SPI must be in Blocking mode"), 191 + } 192 + }
+593
kernel/src/drivers/storage.rs
··· 1 + // sd card file operations 2 + // 3 + // all I/O through embedded-sdmmc AsyncVolumeManager; functions are 4 + // synchronous, wrapping async ops with poll_once (SPI bus is blocking 5 + // so every .await resolves immediately) 6 + 7 + use core::ops::ControlFlow; 8 + 9 + use embedded_sdmmc::Mode; 10 + 11 + use crate::drivers::sdcard::{SdStorage, SdStorageInner, poll_once}; 12 + 13 + pub const PULP_DIR: &str = "_PULP"; 14 + pub const TITLES_FILE: &str = "TITLES.BIN"; 15 + pub const TITLE_CAP: usize = 48; 16 + 17 + #[derive(Clone, Copy)] 18 + pub struct DirEntry { 19 + pub name: [u8; 13], 20 + pub name_len: u8, 21 + pub is_dir: bool, 22 + pub size: u32, 23 + pub title: [u8; TITLE_CAP], 24 + pub title_len: u8, 25 + } 26 + 27 + impl DirEntry { 28 + pub const EMPTY: Self = Self { 29 + name: [0u8; 13], 30 + name_len: 0, 31 + is_dir: false, 32 + size: 0, 33 + title: [0u8; TITLE_CAP], 34 + title_len: 0, 35 + }; 36 + 37 + pub fn name_str(&self) -> &str { 38 + core::str::from_utf8(&self.name[..self.name_len as usize]).unwrap_or("?") 39 + } 40 + 41 + pub fn display_name(&self) -> &str { 42 + if self.title_len > 0 { 43 + core::str::from_utf8(&self.title[..self.title_len as usize]).unwrap_or(self.name_str()) 44 + } else { 45 + self.name_str() 46 + } 47 + } 48 + 49 + pub fn set_title(&mut self, s: &[u8]) { 50 + let n = s.len().min(TITLE_CAP); 51 + self.title[..n].copy_from_slice(&s[..n]); 52 + self.title_len = n as u8; 53 + } 54 + } 55 + 56 + pub struct DirPage { 57 + pub total: usize, 58 + pub count: usize, 59 + } 60 + 61 + fn ext_eq(name: &[u8], target: &[u8]) -> bool { 62 + let dot = match name.iter().rposition(|&b| b == b'.') { 63 + Some(p) => p, 64 + None => return false, 65 + }; 66 + let ext = &name[dot + 1..]; 67 + ext.len() == target.len() && ext.eq_ignore_ascii_case(target) 68 + } 69 + 70 + fn has_supported_ext(name: &[u8]) -> bool { 71 + ext_eq(name, b"TXT") || ext_eq(name, b"EPUB") || ext_eq(name, b"EPU") || ext_eq(name, b"MD") 72 + } 73 + 74 + // build "NAME.EXT" bytes from a ShortFileName 75 + 76 + fn sfn_to_bytes(name: &embedded_sdmmc::ShortFileName, out: &mut [u8; 13]) -> u8 { 77 + let base = name.base_name(); 78 + let ext = name.extension(); 79 + let mut pos = 0usize; 80 + let blen = base.len().min(8); 81 + out[..blen].copy_from_slice(&base[..blen]); 82 + pos += blen; 83 + if !ext.is_empty() { 84 + out[pos] = b'.'; 85 + pos += 1; 86 + let elen = ext.len().min(3); 87 + out[pos..pos + elen].copy_from_slice(&ext[..elen]); 88 + pos += elen; 89 + } 90 + pos as u8 91 + } 92 + 93 + // file-operation macros 94 + // 95 + // each evaluates to Result<T, &'static str>; none use `?` internally 96 + // so caller cleanup (close_dir etc) is never bypassed 97 + 98 + macro_rules! op_file_size { 99 + ($inner:expr, $dir:expr, $name:expr) => { 100 + $inner 101 + .mgr 102 + .find_directory_entry($dir, $name) 103 + .await 104 + .map(|e| e.size) 105 + .map_err(|_| "open file failed") 106 + }; 107 + } 108 + 109 + macro_rules! op_read_chunk { 110 + ($inner:expr, $dir:expr, $name:expr, $offset:expr, $buf:expr) => { 111 + match $inner 112 + .mgr 113 + .open_file_in_dir($dir, $name, Mode::ReadOnly) 114 + .await 115 + { 116 + Err(_) => Err("open file failed"), 117 + Ok(file) => { 118 + let result = match $inner.mgr.file_seek_from_start(file, $offset) { 119 + Ok(()) => $inner.mgr.read(file, $buf).await.map_err(|_| "read failed"), 120 + Err(_) => Err("seek failed"), 121 + }; 122 + let _ = $inner.mgr.close_file(file).await; 123 + result 124 + } 125 + } 126 + }; 127 + } 128 + 129 + macro_rules! op_read_start { 130 + ($inner:expr, $dir:expr, $name:expr, $buf:expr) => { 131 + match $inner 132 + .mgr 133 + .open_file_in_dir($dir, $name, Mode::ReadOnly) 134 + .await 135 + { 136 + Err(_) => Err("open file failed"), 137 + Ok(file) => { 138 + let size = $inner.mgr.file_length(file).unwrap_or(0); 139 + let result = $inner.mgr.read(file, $buf).await.map_err(|_| "read failed"); 140 + let _ = $inner.mgr.close_file(file).await; 141 + result.map(|n| (size, n)) 142 + } 143 + } 144 + }; 145 + } 146 + 147 + macro_rules! op_write { 148 + ($inner:expr, $dir:expr, $name:expr, $data:expr) => { 149 + match $inner 150 + .mgr 151 + .open_file_in_dir($dir, $name, Mode::ReadWriteCreateOrTruncate) 152 + .await 153 + { 154 + Err(_) => Err("create file failed"), 155 + Ok(file) => { 156 + let result = if ($data).is_empty() { 157 + Ok(()) 158 + } else { 159 + $inner 160 + .mgr 161 + .write(file, $data) 162 + .await 163 + .map_err(|_| "write failed") 164 + }; 165 + let _ = $inner.mgr.close_file(file).await; 166 + result 167 + } 168 + } 169 + }; 170 + } 171 + 172 + macro_rules! op_append { 173 + ($inner:expr, $dir:expr, $name:expr, $data:expr) => { 174 + match $inner 175 + .mgr 176 + .open_file_in_dir($dir, $name, Mode::ReadWriteCreateOrAppend) 177 + .await 178 + { 179 + Err(_) => Err("create file failed"), 180 + Ok(file) => { 181 + let result = if ($data).is_empty() { 182 + Ok(()) 183 + } else { 184 + $inner 185 + .mgr 186 + .write(file, $data) 187 + .await 188 + .map_err(|_| "write failed") 189 + }; 190 + let _ = $inner.mgr.close_file(file).await; 191 + result 192 + } 193 + } 194 + }; 195 + } 196 + 197 + macro_rules! op_delete { 198 + ($inner:expr, $dir:expr, $name:expr) => {{ 199 + $inner 200 + .mgr 201 + .delete_entry_in_dir($dir, $name) 202 + .await 203 + .map_err(|_| "delete failed") 204 + }}; 205 + } 206 + 207 + // dir-scoping macros: open subdir, execute body, close handle 208 + 209 + macro_rules! in_dir { 210 + ($inner:expr, $dirname:expr, |$dir:ident| $body:expr) => { 211 + match $inner.mgr.open_dir($inner.root, $dirname).await { 212 + Err(_) => Err("open dir failed"), 213 + Ok($dir) => { 214 + let _r = $body; 215 + let _ = $inner.mgr.close_dir($dir); 216 + _r 217 + } 218 + } 219 + }; 220 + } 221 + 222 + macro_rules! in_subdir { 223 + ($inner:expr, $d1:expr, $d2:expr, |$dir:ident| $body:expr) => { 224 + match $inner.mgr.open_dir($inner.root, $d1).await { 225 + Err(_) => Err("open dir failed"), 226 + Ok(_mid) => match $inner.mgr.open_dir(_mid, $d2).await { 227 + Err(_) => { 228 + let _ = $inner.mgr.close_dir(_mid); 229 + Err("open dir failed") 230 + } 231 + Ok($dir) => { 232 + let _r = $body; 233 + let _ = $inner.mgr.close_dir($dir); 234 + let _ = $inner.mgr.close_dir(_mid); 235 + _r 236 + } 237 + }, 238 + } 239 + }; 240 + } 241 + 242 + // borrow helper 243 + 244 + fn borrow(sd: &SdStorage) -> Result<core::cell::RefMut<'_, SdStorageInner>, &'static str> { 245 + sd.borrow_inner().ok_or("SD not mounted") 246 + } 247 + 248 + // root file operations 249 + 250 + pub fn file_size(sd: &SdStorage, name: &str) -> Result<u32, &'static str> { 251 + poll_once(async { 252 + let mut guard = borrow(sd)?; 253 + let inner = &mut *guard; 254 + op_file_size!(inner, inner.root, name) 255 + }) 256 + } 257 + 258 + pub fn read_file_chunk( 259 + sd: &SdStorage, 260 + name: &str, 261 + offset: u32, 262 + buf: &mut [u8], 263 + ) -> Result<usize, &'static str> { 264 + poll_once(async { 265 + let mut guard = borrow(sd)?; 266 + let inner = &mut *guard; 267 + op_read_chunk!(inner, inner.root, name, offset, buf) 268 + }) 269 + } 270 + 271 + pub fn read_file_start( 272 + sd: &SdStorage, 273 + name: &str, 274 + buf: &mut [u8], 275 + ) -> Result<(u32, usize), &'static str> { 276 + poll_once(async { 277 + let mut guard = borrow(sd)?; 278 + let inner = &mut *guard; 279 + op_read_start!(inner, inner.root, name, buf) 280 + }) 281 + } 282 + 283 + pub fn write_file(sd: &SdStorage, name: &str, data: &[u8]) -> Result<(), &'static str> { 284 + poll_once(async { 285 + let mut guard = borrow(sd)?; 286 + let inner = &mut *guard; 287 + op_write!(inner, inner.root, name, data) 288 + }) 289 + } 290 + 291 + pub fn append_root_file(sd: &SdStorage, name: &str, data: &[u8]) -> Result<(), &'static str> { 292 + poll_once(async { 293 + let mut guard = borrow(sd)?; 294 + let inner = &mut *guard; 295 + op_append!(inner, inner.root, name, data) 296 + }) 297 + } 298 + 299 + pub fn delete_file(sd: &SdStorage, name: &str) -> Result<(), &'static str> { 300 + poll_once(async { 301 + let mut guard = borrow(sd)?; 302 + let inner = &mut *guard; 303 + op_delete!(inner, inner.root, name) 304 + }) 305 + } 306 + 307 + // directory listing 308 + 309 + pub fn list_root_files(sd: &SdStorage, buf: &mut [DirEntry]) -> Result<usize, &'static str> { 310 + poll_once(async { 311 + let mut guard = borrow(sd)?; 312 + let inner = &mut *guard; 313 + 314 + let mut count = 0usize; 315 + let mut total = 0usize; 316 + 317 + inner 318 + .mgr 319 + .iterate_dir(inner.root, |entry| { 320 + if entry.attributes.is_volume() || entry.attributes.is_directory() { 321 + return ControlFlow::Continue(()); 322 + } 323 + 324 + let mut name_buf = [0u8; 13]; 325 + let name_len = sfn_to_bytes(&entry.name, &mut name_buf); 326 + let sfn = &name_buf[..name_len as usize]; 327 + 328 + if sfn.is_empty() || sfn[0] == b'.' || sfn[0] == b'_' { 329 + return ControlFlow::Continue(()); 330 + } 331 + if !has_supported_ext(sfn) { 332 + return ControlFlow::Continue(()); 333 + } 334 + 335 + total += 1; 336 + 337 + if count < buf.len() { 338 + buf[count] = DirEntry { 339 + name: name_buf, 340 + name_len, 341 + is_dir: false, 342 + size: entry.size, 343 + title: [0u8; TITLE_CAP], 344 + title_len: 0, 345 + }; 346 + count += 1; 347 + } 348 + ControlFlow::Continue(()) 349 + }) 350 + .await 351 + .map_err(|_| "iterate dir failed")?; 352 + 353 + if total > count { 354 + log::warn!( 355 + "dir: {} supported files on SD, only {} fit in buffer (max {})", 356 + total, 357 + count, 358 + buf.len(), 359 + ); 360 + } 361 + Ok(count) 362 + }) 363 + } 364 + 365 + // directory management 366 + 367 + pub fn ensure_dir(sd: &SdStorage, name: &str) -> Result<(), &'static str> { 368 + // two poll_once calls so the large make_dir future never shares 369 + // a stack frame with open_dir, halving peak stack usage 370 + let exists = poll_once(async { 371 + let mut guard = borrow(sd)?; 372 + let inner = &mut *guard; 373 + match inner.mgr.open_dir(inner.root, name).await { 374 + Ok(dir) => { 375 + let _ = inner.mgr.close_dir(dir); 376 + Ok::<_, &'static str>(true) 377 + } 378 + Err(_) => Ok(false), 379 + } 380 + })?; 381 + 382 + if exists { 383 + return Ok(()); 384 + } 385 + 386 + poll_once(async { 387 + let mut guard = borrow(sd)?; 388 + let inner = &mut *guard; 389 + match inner.mgr.make_dir_in_dir(inner.root, name).await { 390 + Ok(()) => Ok(()), 391 + Err(embedded_sdmmc::Error::DirAlreadyExists) => Ok(()), 392 + Err(_) => Err("make dir failed"), 393 + } 394 + }) 395 + } 396 + 397 + // single-directory file operations 398 + 399 + pub fn write_file_in_dir( 400 + sd: &SdStorage, 401 + dir: &str, 402 + name: &str, 403 + data: &[u8], 404 + ) -> Result<(), &'static str> { 405 + poll_once(async { 406 + let mut guard = borrow(sd)?; 407 + let inner = &mut *guard; 408 + in_dir!(inner, dir, |dir_h| op_write!(inner, dir_h, name, data)) 409 + }) 410 + } 411 + 412 + pub fn append_file_in_dir( 413 + sd: &SdStorage, 414 + dir: &str, 415 + name: &str, 416 + data: &[u8], 417 + ) -> Result<(), &'static str> { 418 + poll_once(async { 419 + let mut guard = borrow(sd)?; 420 + let inner = &mut *guard; 421 + in_dir!(inner, dir, |dir_h| op_append!(inner, dir_h, name, data)) 422 + }) 423 + } 424 + 425 + pub fn read_file_chunk_in_dir( 426 + sd: &SdStorage, 427 + dir: &str, 428 + name: &str, 429 + offset: u32, 430 + buf: &mut [u8], 431 + ) -> Result<usize, &'static str> { 432 + poll_once(async { 433 + let mut guard = borrow(sd)?; 434 + let inner = &mut *guard; 435 + in_dir!(inner, dir, |dir_h| op_read_chunk!( 436 + inner, dir_h, name, offset, buf 437 + )) 438 + }) 439 + } 440 + 441 + pub fn read_file_start_in_dir( 442 + sd: &SdStorage, 443 + dir: &str, 444 + name: &str, 445 + buf: &mut [u8], 446 + ) -> Result<(u32, usize), &'static str> { 447 + poll_once(async { 448 + let mut guard = borrow(sd)?; 449 + let inner = &mut *guard; 450 + in_dir!(inner, dir, |dir_h| op_read_start!(inner, dir_h, name, buf)) 451 + }) 452 + } 453 + 454 + // async boot path (runs inside the real executor) 455 + 456 + pub async fn ensure_pulp_dir_async(sd: &SdStorage) -> Result<(), &'static str> { 457 + let mut guard = borrow(sd)?; 458 + let inner = &mut *guard; 459 + 460 + match inner.mgr.open_dir(inner.root, PULP_DIR).await { 461 + Ok(dir) => { 462 + let _ = inner.mgr.close_dir(dir); 463 + return Ok(()); 464 + } 465 + Err(_) => {} 466 + } 467 + match inner.mgr.make_dir_in_dir(inner.root, PULP_DIR).await { 468 + Ok(()) => Ok(()), 469 + Err(embedded_sdmmc::Error::DirAlreadyExists) => Ok(()), 470 + Err(_) => Err("make dir failed"), 471 + } 472 + } 473 + 474 + // _PULP subdirectory operations 475 + 476 + pub fn ensure_pulp_subdir(sd: &SdStorage, name: &str) -> Result<(), &'static str> { 477 + let exists = poll_once(async { 478 + let mut guard = borrow(sd)?; 479 + let inner = &mut *guard; 480 + in_dir!(inner, PULP_DIR, |pulp_h| { 481 + match inner.mgr.open_dir(pulp_h, name).await { 482 + Ok(sub) => { 483 + let _ = inner.mgr.close_dir(sub); 484 + Ok::<_, &'static str>(true) 485 + } 486 + Err(_) => Ok(false), 487 + } 488 + }) 489 + })?; 490 + 491 + if exists { 492 + return Ok(()); 493 + } 494 + 495 + poll_once(async { 496 + let mut guard = borrow(sd)?; 497 + let inner = &mut *guard; 498 + in_dir!(inner, PULP_DIR, |pulp_h| { 499 + match inner.mgr.make_dir_in_dir(pulp_h, name).await { 500 + Ok(()) => Ok::<_, &'static str>(()), 501 + Err(embedded_sdmmc::Error::DirAlreadyExists) => Ok(()), 502 + Err(_) => Err("make dir failed"), 503 + } 504 + }) 505 + }) 506 + } 507 + 508 + pub fn write_in_pulp_subdir( 509 + sd: &SdStorage, 510 + dir: &str, 511 + name: &str, 512 + data: &[u8], 513 + ) -> Result<(), &'static str> { 514 + poll_once(async { 515 + let mut guard = borrow(sd)?; 516 + let inner = &mut *guard; 517 + in_subdir!(inner, PULP_DIR, dir, |sub_h| op_write!( 518 + inner, sub_h, name, data 519 + )) 520 + }) 521 + } 522 + 523 + pub fn append_in_pulp_subdir( 524 + sd: &SdStorage, 525 + dir: &str, 526 + name: &str, 527 + data: &[u8], 528 + ) -> Result<(), &'static str> { 529 + poll_once(async { 530 + let mut guard = borrow(sd)?; 531 + let inner = &mut *guard; 532 + in_subdir!(inner, PULP_DIR, dir, |sub_h| op_append!( 533 + inner, sub_h, name, data 534 + )) 535 + }) 536 + } 537 + 538 + pub fn read_chunk_in_pulp_subdir( 539 + sd: &SdStorage, 540 + dir: &str, 541 + name: &str, 542 + offset: u32, 543 + buf: &mut [u8], 544 + ) -> Result<usize, &'static str> { 545 + poll_once(async { 546 + let mut guard = borrow(sd)?; 547 + let inner = &mut *guard; 548 + in_subdir!(inner, PULP_DIR, dir, |sub_h| op_read_chunk!( 549 + inner, sub_h, name, offset, buf 550 + )) 551 + }) 552 + } 553 + 554 + pub fn file_size_in_pulp_subdir( 555 + sd: &SdStorage, 556 + dir: &str, 557 + name: &str, 558 + ) -> Result<u32, &'static str> { 559 + poll_once(async { 560 + let mut guard = borrow(sd)?; 561 + let inner = &mut *guard; 562 + in_subdir!(inner, PULP_DIR, dir, |sub_h| op_file_size!( 563 + inner, sub_h, name 564 + )) 565 + }) 566 + } 567 + 568 + pub fn delete_in_pulp_subdir(sd: &SdStorage, dir: &str, name: &str) -> Result<(), &'static str> { 569 + poll_once(async { 570 + let mut guard = borrow(sd)?; 571 + let inner = &mut *guard; 572 + in_subdir!(inner, PULP_DIR, dir, |sub_h| op_delete!(inner, sub_h, name)) 573 + }) 574 + } 575 + 576 + // append a title mapping line to _PULP/TITLES.BIN 577 + pub fn save_title(sd: &SdStorage, filename: &str, title: &str) -> Result<(), &'static str> { 578 + let name_bytes = filename.as_bytes(); 579 + let title_bytes = title.as_bytes(); 580 + let title_len = title_bytes.len().min(TITLE_CAP); 581 + let line_len = name_bytes.len() + 1 + title_len + 1; // name + \t + title + \n 582 + if line_len > 128 { 583 + return Err("title line too long"); 584 + } 585 + let mut line = [0u8; 128]; 586 + line[..name_bytes.len()].copy_from_slice(name_bytes); 587 + line[name_bytes.len()] = b'\t'; 588 + line[name_bytes.len() + 1..name_bytes.len() + 1 + title_len] 589 + .copy_from_slice(&title_bytes[..title_len]); 590 + line[name_bytes.len() + 1 + title_len] = b'\n'; 591 + 592 + append_file_in_dir(sd, PULP_DIR, TITLES_FILE, &line[..line_len]) 593 + }
+369
kernel/src/kernel/app.rs
··· 1 + // app protocol: trait, context, transitions, and redraw types 2 + // 3 + // these types define the contract between the kernel scheduler and 4 + // the app layer. concrete apps implement the App trait; the kernel 5 + // drives lifecycle, input dispatch, and rendering through it. 6 + // 7 + // the kernel is generic over an app identity type (AppIdType). 8 + // distros define their own AppId enum and implement AppIdType for 9 + // it. the kernel never knows which specific apps exist. 10 + // 11 + // QuickAction types also live here -- they are pure data describing 12 + // what actions an app exposes. the renderer (QuickMenu widget) is 13 + // app-side, but the protocol is kernel-side. 14 + 15 + use esp_hal::delay::Delay; 16 + 17 + use crate::board::Epd; 18 + use crate::board::action::ActionEvent; 19 + use crate::drivers::input::Event; 20 + use crate::drivers::sdcard::SdStorage; 21 + #[allow(unused_imports)] 22 + use crate::drivers::strip::StripBuffer; 23 + use crate::ui::Region; 24 + 25 + use super::KernelHandle; 26 + use super::bookmarks::BookmarkCache; 27 + use super::config::{SystemSettings, WifiConfig}; 28 + 29 + pub const MAX_APP_ACTIONS: usize = 6; 30 + 31 + #[derive(Debug, Clone, Copy)] 32 + pub enum QuickActionKind { 33 + Cycle { 34 + value: u8, 35 + options: &'static [&'static str], 36 + }, 37 + Trigger { 38 + display: &'static str, 39 + }, 40 + } 41 + 42 + #[derive(Debug, Clone, Copy)] 43 + pub struct QuickAction { 44 + pub id: u8, 45 + pub label: &'static str, 46 + pub kind: QuickActionKind, 47 + } 48 + 49 + impl QuickAction { 50 + pub const fn cycle( 51 + id: u8, 52 + label: &'static str, 53 + value: u8, 54 + options: &'static [&'static str], 55 + ) -> Self { 56 + Self { 57 + id, 58 + label, 59 + kind: QuickActionKind::Cycle { value, options }, 60 + } 61 + } 62 + 63 + pub const fn trigger(id: u8, label: &'static str, display: &'static str) -> Self { 64 + Self { 65 + id, 66 + label, 67 + kind: QuickActionKind::Trigger { display }, 68 + } 69 + } 70 + } 71 + 72 + pub const RECENT_FILE: &str = "RECENT"; 73 + 74 + // distros define their own AppId enum and implement this trait. 75 + // the kernel uses HOME to initialise the nav stack and reset on 76 + // Transition::Home. nothing else about the concrete variants is 77 + // known to the kernel. 78 + 79 + pub trait AppIdType: Copy + Eq + core::fmt::Debug { 80 + const HOME: Self; 81 + } 82 + 83 + #[derive(Clone, Copy, Debug)] 84 + pub enum PendingSetting { 85 + BookFontSize(u8), 86 + } 87 + 88 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 89 + pub enum Transition<Id> { 90 + None, 91 + Push(Id), 92 + Pop, 93 + Replace(Id), 94 + Home, 95 + } 96 + 97 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 98 + pub enum Redraw { 99 + None, 100 + Partial(Region), 101 + Full, 102 + } 103 + 104 + const MSG_BUF_SIZE: usize = 64; 105 + 106 + pub struct AppContext { 107 + msg_buf: [u8; MSG_BUF_SIZE], 108 + msg_len: usize, 109 + redraw: Redraw, 110 + } 111 + 112 + impl Default for AppContext { 113 + fn default() -> Self { 114 + Self::new() 115 + } 116 + } 117 + 118 + impl AppContext { 119 + pub const fn new() -> Self { 120 + Self { 121 + msg_buf: [0u8; MSG_BUF_SIZE], 122 + msg_len: 0, 123 + redraw: Redraw::None, 124 + } 125 + } 126 + 127 + pub fn set_message(&mut self, data: &[u8]) { 128 + let len = data.len().min(MSG_BUF_SIZE); 129 + self.msg_buf[..len].copy_from_slice(&data[..len]); 130 + self.msg_len = len; 131 + } 132 + 133 + pub fn message(&self) -> &[u8] { 134 + &self.msg_buf[..self.msg_len] 135 + } 136 + 137 + pub fn message_str(&self) -> &str { 138 + core::str::from_utf8(self.message()).unwrap_or("") 139 + } 140 + 141 + pub fn clear_message(&mut self) { 142 + self.msg_len = 0; 143 + } 144 + 145 + pub fn request_full_redraw(&mut self) { 146 + self.redraw = Redraw::Full; 147 + } 148 + 149 + pub fn request_partial_redraw(&mut self, region: Region) { 150 + match self.redraw { 151 + Redraw::Full => {} 152 + Redraw::Partial(existing) => { 153 + self.redraw = Redraw::Partial(existing.union(region)); 154 + } 155 + Redraw::None => self.redraw = Redraw::Partial(region), 156 + } 157 + } 158 + 159 + #[inline] 160 + pub fn mark_dirty(&mut self, region: Region) { 161 + self.request_partial_redraw(region); 162 + } 163 + 164 + pub fn has_redraw(&self) -> bool { 165 + !matches!(self.redraw, Redraw::None) 166 + } 167 + 168 + pub fn take_redraw(&mut self) -> Redraw { 169 + let r = self.redraw; 170 + self.redraw = Redraw::None; 171 + r 172 + } 173 + } 174 + 175 + #[allow(async_fn_in_trait)] 176 + pub trait App<Id> { 177 + async fn on_enter(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>); 178 + 179 + fn on_exit(&mut self) {} 180 + 181 + fn on_suspend(&mut self) { 182 + self.on_exit(); 183 + } 184 + 185 + async fn on_resume(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) { 186 + self.on_enter(ctx, k).await; 187 + } 188 + 189 + fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition<Id>; 190 + 191 + fn quick_actions(&self) -> &[QuickAction] { 192 + &[] 193 + } 194 + 195 + fn on_quick_trigger(&mut self, _id: u8, _ctx: &mut AppContext) {} 196 + 197 + fn on_quick_cycle_update(&mut self, _id: u8, _value: u8, _ctx: &mut AppContext) {} 198 + 199 + fn draw(&self, strip: &mut StripBuffer); 200 + 201 + async fn background(&mut self, _ctx: &mut AppContext, _k: &mut KernelHandle<'_>) {} 202 + 203 + fn pending_setting(&self) -> Option<PendingSetting> { 204 + None 205 + } 206 + 207 + fn save_state(&self, _bm: &mut BookmarkCache) {} 208 + 209 + fn has_background_when_suspended(&self) -> bool { 210 + false 211 + } 212 + 213 + fn background_suspended(&mut self, _k: &mut KernelHandle<'_>) {} 214 + } 215 + 216 + const MAX_STACK_DEPTH: usize = 4; 217 + 218 + #[derive(Debug, Clone, Copy)] 219 + pub struct NavEvent<Id> { 220 + pub from: Id, 221 + pub to: Id, 222 + pub suspend: bool, 223 + pub resume: bool, 224 + } 225 + 226 + // 4-deep navigation stack with shared AppContext 227 + pub struct Launcher<Id: AppIdType> { 228 + stack: [Id; MAX_STACK_DEPTH], 229 + depth: usize, 230 + pub ctx: AppContext, 231 + } 232 + 233 + impl<Id: AppIdType> Default for Launcher<Id> { 234 + fn default() -> Self { 235 + Self::new() 236 + } 237 + } 238 + 239 + impl<Id: AppIdType> Launcher<Id> { 240 + pub const fn new() -> Self { 241 + Self { 242 + stack: [Id::HOME; MAX_STACK_DEPTH], 243 + depth: 1, 244 + ctx: AppContext::new(), 245 + } 246 + } 247 + 248 + pub fn active(&self) -> Id { 249 + self.stack[self.depth - 1] 250 + } 251 + 252 + pub fn apply(&mut self, transition: Transition<Id>) -> Option<NavEvent<Id>> { 253 + let old = self.active(); 254 + 255 + let (suspend, resume) = match transition { 256 + Transition::None => return None, 257 + 258 + Transition::Push(id) => { 259 + if self.depth >= MAX_STACK_DEPTH { 260 + log::warn!( 261 + "nav stack full (depth {}), Push({:?}) degraded to Replace", 262 + self.depth, 263 + id 264 + ); 265 + self.stack[self.depth - 1] = id; 266 + (false, false) 267 + } else { 268 + self.stack[self.depth] = id; 269 + self.depth += 1; 270 + (true, false) 271 + } 272 + } 273 + 274 + Transition::Pop => { 275 + if self.depth > 1 { 276 + self.depth -= 1; 277 + (false, true) 278 + } else { 279 + return None; 280 + } 281 + } 282 + 283 + Transition::Replace(id) => { 284 + self.stack[self.depth - 1] = id; 285 + (false, false) 286 + } 287 + 288 + Transition::Home => { 289 + self.depth = 1; 290 + self.stack[0] = Id::HOME; 291 + (false, true) 292 + } 293 + }; 294 + 295 + let new = self.active(); 296 + if new != old { 297 + Some(NavEvent { 298 + from: old, 299 + to: new, 300 + suspend, 301 + resume, 302 + }) 303 + } else { 304 + None 305 + } 306 + } 307 + } 308 + 309 + // aggregate interface the kernel scheduler calls on the app layer. 310 + // a distro implements this (typically via an AppManager struct that 311 + // holds concrete app types and a with_app! dispatch macro). the 312 + // scheduler is generic over AppLayer without importing any concrete 313 + // app types. 314 + 315 + #[allow(async_fn_in_trait)] 316 + pub trait AppLayer { 317 + type Id: AppIdType; 318 + 319 + // active app and event dispatch 320 + fn active(&self) -> Self::Id; 321 + fn dispatch_event(&mut self, event: Event, bm: &mut BookmarkCache) -> Transition<Self::Id>; 322 + async fn apply_transition(&mut self, t: Transition<Self::Id>, k: &mut KernelHandle<'_>); 323 + 324 + // background work (SD I/O, caching) -- runs before render 325 + async fn run_background(&mut self, k: &mut KernelHandle<'_>); 326 + 327 + // rendering 328 + fn draw(&self, strip: &mut StripBuffer); 329 + fn has_redraw(&self) -> bool; 330 + fn take_redraw(&mut self) -> Redraw; 331 + fn request_full_redraw(&mut self); 332 + fn ctx_mut(&mut self) -> &mut AppContext; 333 + 334 + // system configuration 335 + fn system_settings(&self) -> &SystemSettings; 336 + fn settings_loaded(&self) -> bool; 337 + fn ghost_clear_every(&self) -> u32; 338 + fn wifi_config(&self) -> &WifiConfig; 339 + 340 + // boot-time init: load settings, populate caches, enter first app 341 + fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>); 342 + fn load_initial_state(&mut self, k: &mut KernelHandle<'_>); 343 + async fn enter_initial(&mut self, k: &mut KernelHandle<'_>); 344 + 345 + // true when the active app wants to take over the main loop 346 + // (e.g. wifi upload mode bypasses the normal event dispatch) 347 + fn needs_special_mode(&self) -> bool { 348 + false 349 + } 350 + 351 + // run the special mode; scheduler calls this when 352 + // needs_special_mode() returns true. hardware resources are 353 + // passed from the kernel since special modes drive the EPD 354 + // and SD directly (e.g. wifi upload mode). 355 + async fn run_special_mode( 356 + &mut self, 357 + _epd: &mut Epd, 358 + _strip: &mut StripBuffer, 359 + _delay: &mut Delay, 360 + _sd: &SdStorage, 361 + ) { 362 + } 363 + 364 + // true when deferred input during EPD refresh should be 365 + // suppressed (e.g. quick menu overlay is open) 366 + fn suppress_deferred_input(&self) -> bool { 367 + false 368 + } 369 + }
+219
kernel/src/kernel/config.rs
··· 1 + // system configuration: key=value text in _PULP/SETTINGS.TXT 2 + // 3 + // SystemSettings and WifiConfig are kernel-owned configuration; 4 + // the SettingsApp in apps/ provides the UI for editing them 5 + 6 + pub const SETTINGS_FILE: &str = "SETTINGS.TXT"; 7 + 8 + #[derive(Clone, Copy)] 9 + pub struct SystemSettings { 10 + pub sleep_timeout: u16, // minutes idle before sleep; 0 = never 11 + pub ghost_clear_every: u8, // partial refreshes before forced full GC 12 + pub book_font_size_idx: u8, // 0 = Small, 1 = Medium, 2 = Large 13 + pub ui_font_size_idx: u8, // 0 = Small, 1 = Medium, 2 = Large 14 + } 15 + 16 + impl Default for SystemSettings { 17 + fn default() -> Self { 18 + Self::defaults() 19 + } 20 + } 21 + 22 + impl SystemSettings { 23 + pub const fn defaults() -> Self { 24 + Self { 25 + sleep_timeout: 10, 26 + ghost_clear_every: 10, 27 + book_font_size_idx: 2, 28 + ui_font_size_idx: 2, 29 + } 30 + } 31 + 32 + pub fn sanitize(&mut self) { 33 + self.sanitize_with_max_font(Self::DEFAULT_MAX_FONT_IDX); 34 + } 35 + 36 + pub fn sanitize_with_max_font(&mut self, max_font: u8) { 37 + self.sleep_timeout = self.sleep_timeout.min(120); 38 + self.ghost_clear_every = self.ghost_clear_every.clamp(1, 50); 39 + self.book_font_size_idx = self.book_font_size_idx.min(max_font); 40 + self.ui_font_size_idx = self.ui_font_size_idx.min(max_font); 41 + } 42 + 43 + // reasonable default; distros override via sanitize_with_max_font 44 + const DEFAULT_MAX_FONT_IDX: u8 = 4; 45 + } 46 + 47 + pub const WIFI_SSID_CAP: usize = 32; 48 + pub const WIFI_PASS_CAP: usize = 63; 49 + 50 + pub struct WifiConfig { 51 + ssid: [u8; WIFI_SSID_CAP], 52 + ssid_len: u8, 53 + pass: [u8; WIFI_PASS_CAP], 54 + pass_len: u8, 55 + } 56 + 57 + impl WifiConfig { 58 + pub const fn empty() -> Self { 59 + Self { 60 + ssid: [0u8; WIFI_SSID_CAP], 61 + ssid_len: 0, 62 + pass: [0u8; WIFI_PASS_CAP], 63 + pass_len: 0, 64 + } 65 + } 66 + 67 + pub fn ssid(&self) -> &str { 68 + core::str::from_utf8(&self.ssid[..self.ssid_len as usize]).unwrap_or("") 69 + } 70 + 71 + pub fn password(&self) -> &str { 72 + core::str::from_utf8(&self.pass[..self.pass_len as usize]).unwrap_or("") 73 + } 74 + 75 + pub fn has_credentials(&self) -> bool { 76 + self.ssid_len > 0 77 + } 78 + 79 + fn set_ssid(&mut self, val: &[u8]) { 80 + let n = val.len().min(WIFI_SSID_CAP); 81 + self.ssid[..n].copy_from_slice(&val[..n]); 82 + self.ssid_len = n as u8; 83 + } 84 + 85 + fn set_pass(&mut self, val: &[u8]) { 86 + let n = val.len().min(WIFI_PASS_CAP); 87 + self.pass[..n].copy_from_slice(&val[..n]); 88 + self.pass_len = n as u8; 89 + } 90 + } 91 + 92 + fn trim(s: &[u8]) -> &[u8] { 93 + let mut start = 0; 94 + let mut end = s.len(); 95 + while start < end && matches!(s[start], b' ' | b'\t' | b'\r') { 96 + start += 1; 97 + } 98 + while end > start && matches!(s[end - 1], b' ' | b'\t' | b'\r') { 99 + end -= 1; 100 + } 101 + &s[start..end] 102 + } 103 + 104 + fn parse_u16(s: &[u8]) -> Option<u16> { 105 + if s.is_empty() { 106 + return None; 107 + } 108 + let mut val: u16 = 0; 109 + for &b in s { 110 + if !b.is_ascii_digit() { 111 + return None; 112 + } 113 + val = val.checked_mul(10)?.checked_add((b - b'0') as u16)?; 114 + } 115 + Some(val) 116 + } 117 + 118 + fn apply_setting(key: &[u8], val: &[u8], s: &mut SystemSettings, w: &mut WifiConfig) { 119 + match key { 120 + b"sleep_timeout" => { 121 + if let Some(v) = parse_u16(val) { 122 + s.sleep_timeout = v; 123 + } 124 + } 125 + b"ghost_clear" => { 126 + if let Some(v) = parse_u16(val) { 127 + s.ghost_clear_every = v as u8; 128 + } 129 + } 130 + b"book_font" => { 131 + if let Some(v) = parse_u16(val) { 132 + s.book_font_size_idx = v as u8; 133 + } 134 + } 135 + b"ui_font" => { 136 + if let Some(v) = parse_u16(val) { 137 + s.ui_font_size_idx = v as u8; 138 + } 139 + } 140 + b"wifi_ssid" => w.set_ssid(val), 141 + b"wifi_pass" => w.set_pass(val), 142 + _ => {} 143 + } 144 + } 145 + 146 + pub fn parse_settings_txt(data: &[u8], settings: &mut SystemSettings, wifi: &mut WifiConfig) { 147 + for line in data.split(|&b| b == b'\n') { 148 + let line = trim(line); 149 + if line.is_empty() || line[0] == b'#' { 150 + continue; 151 + } 152 + if let Some(eq) = line.iter().position(|&b| b == b'=') { 153 + let key = trim(&line[..eq]); 154 + let val = trim(&line[eq + 1..]); 155 + apply_setting(key, val, settings, wifi); 156 + } 157 + } 158 + } 159 + 160 + struct TxtWriter<'a> { 161 + buf: &'a mut [u8], 162 + pos: usize, 163 + } 164 + 165 + impl<'a> TxtWriter<'a> { 166 + fn new(buf: &'a mut [u8]) -> Self { 167 + Self { buf, pos: 0 } 168 + } 169 + 170 + fn put(&mut self, data: &[u8]) { 171 + let n = data.len().min(self.buf.len() - self.pos); 172 + self.buf[self.pos..self.pos + n].copy_from_slice(&data[..n]); 173 + self.pos += n; 174 + } 175 + 176 + fn put_u16(&mut self, val: u16) { 177 + if val == 0 { 178 + self.put(b"0"); 179 + return; 180 + } 181 + let mut digits = [0u8; 5]; 182 + let mut i = 5; 183 + let mut v = val; 184 + while v > 0 { 185 + i -= 1; 186 + digits[i] = b'0' + (v % 10) as u8; 187 + v /= 10; 188 + } 189 + self.put(&digits[i..5]); 190 + } 191 + 192 + fn kv_num(&mut self, key: &[u8], val: u16) { 193 + self.put(key); 194 + self.put(b"="); 195 + self.put_u16(val); 196 + self.put(b"\n"); 197 + } 198 + 199 + fn kv_str(&mut self, key: &[u8], val: &[u8]) { 200 + self.put(key); 201 + self.put(b"="); 202 + self.put(val); 203 + self.put(b"\n"); 204 + } 205 + } 206 + 207 + pub fn write_settings_txt(s: &SystemSettings, w: &WifiConfig, buf: &mut [u8]) -> usize { 208 + let mut wr = TxtWriter::new(buf); 209 + wr.put(b"# pulp-os settings\n"); 210 + wr.put(b"# lines starting with # are ignored\n\n"); 211 + wr.kv_num(b"sleep_timeout", s.sleep_timeout); 212 + wr.kv_num(b"ghost_clear", s.ghost_clear_every as u16); 213 + wr.kv_num(b"book_font", s.book_font_size_idx as u16); 214 + wr.kv_num(b"ui_font", s.ui_font_size_idx as u16); 215 + wr.put(b"\n# wifi credentials for upload mode\n"); 216 + wr.kv_str(b"wifi_ssid", &w.ssid[..w.ssid_len as usize]); 217 + wr.kv_str(b"wifi_pass", &w.pass[..w.pass_len as usize]); 218 + wr.pos 219 + }
+63
kernel/src/kernel/console.rs
··· 1 + // boot console: accumulates text lines during hardware init, rendered 2 + // once to EPD before the app layer takes over 3 + // 4 + // uses the embedded-graphics built-in FONT_6X13 -- no TTF assets or 5 + // build.rs font pipeline needed. the kernel can show boot progress 6 + // on a bare display with nothing but this mono font. 7 + 8 + use embedded_graphics::mono_font::MonoTextStyle; 9 + use embedded_graphics::mono_font::ascii::FONT_6X13; 10 + use embedded_graphics::pixelcolor::BinaryColor; 11 + use embedded_graphics::prelude::*; 12 + use embedded_graphics::text::Text; 13 + 14 + use crate::drivers::strip::StripBuffer; 15 + 16 + const MAX_LINES: usize = 40; 17 + const MAX_LINE_LEN: usize = 76; 18 + const LEFT_MARGIN: i32 = 8; 19 + const TOP_MARGIN: i32 = 6; 20 + const LINE_H: i32 = 15; 21 + 22 + pub struct BootConsole { 23 + lines: [[u8; MAX_LINE_LEN]; MAX_LINES], 24 + lengths: [u8; MAX_LINES], 25 + count: usize, 26 + } 27 + 28 + impl Default for BootConsole { 29 + fn default() -> Self { 30 + Self::new() 31 + } 32 + } 33 + 34 + impl BootConsole { 35 + pub const fn new() -> Self { 36 + Self { 37 + lines: [[0u8; MAX_LINE_LEN]; MAX_LINES], 38 + lengths: [0u8; MAX_LINES], 39 + count: 0, 40 + } 41 + } 42 + 43 + pub fn push(&mut self, text: &str) { 44 + if self.count >= MAX_LINES { 45 + return; 46 + } 47 + let bytes = text.as_bytes(); 48 + let len = bytes.len().min(MAX_LINE_LEN); 49 + self.lines[self.count][..len].copy_from_slice(&bytes[..len]); 50 + self.lengths[self.count] = len as u8; 51 + self.count += 1; 52 + } 53 + 54 + pub fn draw(&self, strip: &mut StripBuffer) { 55 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 56 + for i in 0..self.count { 57 + let len = self.lengths[i] as usize; 58 + let text = core::str::from_utf8(&self.lines[i][..len]).unwrap_or(""); 59 + let y = TOP_MARGIN + (i as i32 + 1) * LINE_H; 60 + let _ = Text::new(text, Point::new(LEFT_MARGIN, y), style).draw(strip); 61 + } 62 + } 63 + }
+157
kernel/src/kernel/dir_cache.rs
··· 1 + // directory listing cache: sorted entries with title resolution 2 + // loaded lazily from SD, held in RAM, invalidated on demand 3 + 4 + use crate::drivers::sdcard::SdStorage; 5 + use crate::drivers::storage::{ 6 + DirEntry, DirPage, PULP_DIR, TITLES_FILE, list_root_files, read_file_start_in_dir, 7 + }; 8 + 9 + const MAX_DIR_ENTRIES: usize = 128; 10 + 11 + pub struct DirCache { 12 + entries: [DirEntry; MAX_DIR_ENTRIES], 13 + count: usize, 14 + valid: bool, 15 + } 16 + 17 + impl Default for DirCache { 18 + fn default() -> Self { 19 + Self::new() 20 + } 21 + } 22 + 23 + impl DirCache { 24 + pub const fn new() -> Self { 25 + Self { 26 + entries: [DirEntry::EMPTY; MAX_DIR_ENTRIES], 27 + count: 0, 28 + valid: false, 29 + } 30 + } 31 + 32 + pub fn ensure_loaded(&mut self, sd: &SdStorage) -> Result<(), &'static str> { 33 + if self.valid { 34 + return Ok(()); 35 + } 36 + 37 + let count = list_root_files(sd, &mut self.entries)?; 38 + self.count = count; 39 + sort_entries(&mut self.entries, self.count); 40 + self.load_titles(sd); 41 + self.valid = true; 42 + Ok(()) 43 + } 44 + 45 + fn load_titles(&mut self, sd: &SdStorage) { 46 + let mut buf = [0u8; 2048]; 47 + let n = match read_file_start_in_dir(sd, PULP_DIR, TITLES_FILE, &mut buf) { 48 + Ok((_, n)) => n, 49 + Err(_) => return, 50 + }; 51 + 52 + let data = &buf[..n]; 53 + let mut start = 0; 54 + while start < data.len() { 55 + let end = data[start..] 56 + .iter() 57 + .position(|&b| b == b'\n') 58 + .map(|p| start + p) 59 + .unwrap_or(data.len()); 60 + let line = &data[start..end]; 61 + if !line.is_empty() { 62 + self.apply_title_line(line); 63 + } 64 + start = end + 1; 65 + } 66 + } 67 + 68 + fn apply_title_line(&mut self, line: &[u8]) { 69 + let tab_pos = match line.iter().position(|&b| b == b'\t') { 70 + Some(p) => p, 71 + None => return, 72 + }; 73 + let file_part = &line[..tab_pos]; 74 + let title_part = &line[tab_pos + 1..]; 75 + if title_part.is_empty() { 76 + return; 77 + } 78 + 79 + let file_str = match core::str::from_utf8(file_part) { 80 + Ok(s) => s, 81 + Err(_) => return, 82 + }; 83 + 84 + for i in 0..self.count { 85 + if self.entries[i].name_str().eq_ignore_ascii_case(file_str) { 86 + self.entries[i].set_title(title_part); 87 + break; 88 + } 89 + } 90 + } 91 + 92 + pub fn page(&self, offset: usize, buf: &mut [DirEntry]) -> DirPage { 93 + let total = self.count; 94 + let start = offset.min(total); 95 + let end = (start + buf.len()).min(total); 96 + let count = end - start; 97 + buf[..count].clone_from_slice(&self.entries[start..end]); 98 + DirPage { total, count } 99 + } 100 + 101 + pub fn invalidate(&mut self) { 102 + self.valid = false; 103 + } 104 + 105 + pub fn next_untitled_epub(&self, from: usize) -> Option<(usize, [u8; 13], u8)> { 106 + for i in from..self.count { 107 + let e = &self.entries[i]; 108 + if e.title_len > 0 || e.is_dir { 109 + continue; 110 + } 111 + let name = e.name_str().as_bytes(); 112 + if name.len() >= 5 113 + && name[name.len() - 5..name.len() - 4] == [b'.'] 114 + && name[name.len() - 4..].eq_ignore_ascii_case(b"EPUB") 115 + { 116 + return Some((i, e.name, e.name_len)); 117 + } 118 + } 119 + None 120 + } 121 + 122 + pub fn set_entry_title(&mut self, index: usize, title: &[u8]) { 123 + if index < self.count { 124 + self.entries[index].set_title(title); 125 + } 126 + } 127 + } 128 + 129 + // insertion sort; count <= 128 130 + fn sort_entries(entries: &mut [DirEntry], count: usize) { 131 + for i in 1..count { 132 + let key = entries[i]; 133 + let mut j = i; 134 + while j > 0 && entry_gt(&entries[j - 1], &key) { 135 + entries[j] = entries[j - 1]; 136 + j -= 1; 137 + } 138 + entries[j] = key; 139 + } 140 + } 141 + 142 + // directories before files, then case-insensitive name order 143 + fn entry_gt(a: &DirEntry, b: &DirEntry) -> bool { 144 + if a.is_dir != b.is_dir { 145 + return !a.is_dir; 146 + } 147 + let an = a.name_str().as_bytes(); 148 + let bn = b.name_str().as_bytes(); 149 + for (ab, bb) in an.iter().zip(bn.iter()) { 150 + let ac = ab.to_ascii_lowercase(); 151 + let bc = bb.to_ascii_lowercase(); 152 + if ac != bc { 153 + return ac > bc; 154 + } 155 + } 156 + an.len() > bn.len() 157 + }
+431
kernel/src/kernel/handle.rs
··· 1 + // kernel handle: async syscall boundary for apps. 2 + // 3 + // every storage method does a synchronous operation then yields to 4 + // the executor, giving other tasks a scheduling opportunity. 5 + // 6 + // app-specific logic (bookmarks, title scan, etc.) accesses the 7 + // underlying caches directly via bookmark_cache() / dir_cache_mut() 8 + // rather than through dedicated handle methods. 9 + 10 + use crate::drivers::storage::{self, DirEntry, DirPage}; 11 + use crate::kernel::bookmarks::BookmarkCache; 12 + use crate::kernel::dir_cache::DirCache; 13 + use crate::kernel::wake::uptime_secs; 14 + 15 + // hides SPI generics from app code; detailed diagnostics go to log::warn 16 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 17 + pub enum StorageError { 18 + OpenVolume, 19 + OpenDir, 20 + OpenFile, 21 + ReadFailed, 22 + WriteFailed, 23 + SeekFailed, 24 + DeleteFailed, 25 + DirFull, 26 + NotFound, 27 + } 28 + 29 + impl core::fmt::Display for StorageError { 30 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 31 + match self { 32 + Self::OpenVolume => write!(f, "open volume failed"), 33 + Self::OpenDir => write!(f, "open dir failed"), 34 + Self::OpenFile => write!(f, "open file failed"), 35 + Self::ReadFailed => write!(f, "read failed"), 36 + Self::WriteFailed => write!(f, "write failed"), 37 + Self::SeekFailed => write!(f, "seek failed"), 38 + Self::DeleteFailed => write!(f, "delete failed"), 39 + Self::DirFull => write!(f, "directory full"), 40 + Self::NotFound => write!(f, "not found"), 41 + } 42 + } 43 + } 44 + 45 + // yield to executor after a synchronous storage call 46 + macro_rules! yield_op { 47 + ($e:expr) => {{ 48 + let r = $e; 49 + embassy_futures::yield_now().await; 50 + r 51 + }}; 52 + } 53 + 54 + fn map_read_err(e: &'static str) -> StorageError { 55 + if e.contains("volume") { 56 + StorageError::OpenVolume 57 + } else if e.contains("dir") { 58 + StorageError::OpenDir 59 + } else if e.contains("open file") { 60 + StorageError::OpenFile 61 + } else if e.contains("seek") { 62 + StorageError::SeekFailed 63 + } else { 64 + StorageError::ReadFailed 65 + } 66 + } 67 + 68 + fn map_write_err(e: &'static str) -> StorageError { 69 + if e.contains("volume") { 70 + StorageError::OpenVolume 71 + } else if e.contains("dir") && !e.contains("make") { 72 + StorageError::OpenDir 73 + } else if e.contains("make dir") { 74 + StorageError::WriteFailed 75 + } else if e.contains("open file") || e.contains("create") { 76 + StorageError::OpenFile 77 + } else { 78 + StorageError::WriteFailed 79 + } 80 + } 81 + 82 + // async API surface for apps -- the syscall boundary 83 + // 84 + // borrows the Kernel for the duration of an app lifecycle method; 85 + // no SPI, no generics, no driver types visible to apps 86 + pub struct KernelHandle<'k> { 87 + pub(crate) kernel: &'k mut super::Kernel, 88 + } 89 + 90 + impl<'k> KernelHandle<'k> { 91 + pub(crate) fn new(kernel: &'k mut super::Kernel) -> Self { 92 + Self { kernel } 93 + } 94 + 95 + // root file operations 96 + 97 + pub async fn file_size(&mut self, name: &str) -> Result<u32, StorageError> { 98 + yield_op!(self.sync_file_size(name).map_err(map_read_err)) 99 + } 100 + 101 + pub async fn read_file_chunk( 102 + &mut self, 103 + name: &str, 104 + offset: u32, 105 + buf: &mut [u8], 106 + ) -> Result<usize, StorageError> { 107 + yield_op!( 108 + self.sync_read_chunk(name, offset, buf) 109 + .map_err(map_read_err) 110 + ) 111 + } 112 + 113 + pub async fn read_file_start( 114 + &mut self, 115 + name: &str, 116 + buf: &mut [u8], 117 + ) -> Result<(u32, usize), StorageError> { 118 + yield_op!(self.sync_read_file_start(name, buf).map_err(map_read_err)) 119 + } 120 + 121 + pub async fn write_file(&mut self, name: &str, data: &[u8]) -> Result<(), StorageError> { 122 + yield_op!(storage::write_file(&self.kernel.sd, name, data).map_err(map_write_err)) 123 + } 124 + 125 + pub async fn delete_file(&mut self, name: &str) -> Result<(), StorageError> { 126 + yield_op!( 127 + storage::delete_file(&self.kernel.sd, name).map_err(|_| StorageError::DeleteFailed) 128 + ) 129 + } 130 + 131 + // directory listing (cached) 132 + 133 + pub async fn list_dir( 134 + &mut self, 135 + offset: usize, 136 + buf: &mut [DirEntry], 137 + ) -> Result<DirPage, StorageError> { 138 + { 139 + let k = &mut *self.kernel; 140 + k.dir_cache.ensure_loaded(&k.sd).map_err(map_read_err)?; 141 + } 142 + let page = self.kernel.dir_cache.page(offset, buf); 143 + embassy_futures::yield_now().await; 144 + Ok(page) 145 + } 146 + 147 + pub fn invalidate_dir_cache(&mut self) { 148 + self.kernel.dir_cache.invalidate(); 149 + } 150 + 151 + // _PULP app-data directory 152 + 153 + pub async fn ensure_app_dir(&mut self) -> Result<(), StorageError> { 154 + yield_op!(storage::ensure_dir(&self.kernel.sd, storage::PULP_DIR).map_err(map_write_err)) 155 + } 156 + 157 + pub async fn read_app_data_start( 158 + &mut self, 159 + name: &str, 160 + buf: &mut [u8], 161 + ) -> Result<(u32, usize), StorageError> { 162 + yield_op!( 163 + self.sync_read_app_data_start(name, buf) 164 + .map_err(map_read_err) 165 + ) 166 + } 167 + 168 + pub async fn read_app_data( 169 + &mut self, 170 + name: &str, 171 + offset: u32, 172 + buf: &mut [u8], 173 + ) -> Result<usize, StorageError> { 174 + yield_op!( 175 + storage::read_file_chunk_in_dir(&self.kernel.sd, storage::PULP_DIR, name, offset, buf) 176 + .map_err(map_read_err) 177 + ) 178 + } 179 + 180 + pub async fn write_app_data(&mut self, name: &str, data: &[u8]) -> Result<(), StorageError> { 181 + yield_op!(self.sync_write_app_data(name, data).map_err(map_write_err)) 182 + } 183 + 184 + pub async fn ensure_app_subdir(&mut self, dir: &str) -> Result<(), StorageError> { 185 + yield_op!(self.sync_ensure_app_subdir(dir).map_err(map_write_err)) 186 + } 187 + 188 + pub async fn read_app_subdir( 189 + &mut self, 190 + dir: &str, 191 + name: &str, 192 + offset: u32, 193 + buf: &mut [u8], 194 + ) -> Result<usize, StorageError> { 195 + yield_op!( 196 + self.sync_read_app_subdir_chunk(dir, name, offset, buf) 197 + .map_err(map_read_err) 198 + ) 199 + } 200 + 201 + pub async fn write_app_subdir( 202 + &mut self, 203 + dir: &str, 204 + name: &str, 205 + data: &[u8], 206 + ) -> Result<(), StorageError> { 207 + yield_op!( 208 + self.sync_write_app_subdir(dir, name, data) 209 + .map_err(map_write_err) 210 + ) 211 + } 212 + 213 + pub async fn append_app_subdir( 214 + &mut self, 215 + dir: &str, 216 + name: &str, 217 + data: &[u8], 218 + ) -> Result<(), StorageError> { 219 + yield_op!( 220 + self.sync_append_app_subdir(dir, name, data) 221 + .map_err(map_write_err) 222 + ) 223 + } 224 + 225 + pub async fn file_size_app_subdir( 226 + &mut self, 227 + dir: &str, 228 + name: &str, 229 + ) -> Result<u32, StorageError> { 230 + yield_op!( 231 + self.sync_file_size_app_subdir(dir, name) 232 + .map_err(map_read_err) 233 + ) 234 + } 235 + 236 + pub async fn delete_app_subdir(&mut self, dir: &str, name: &str) -> Result<(), StorageError> { 237 + yield_op!( 238 + storage::delete_in_pulp_subdir(&self.kernel.sd, dir, name) 239 + .map_err(|_| StorageError::DeleteFailed) 240 + ) 241 + } 242 + 243 + // arbitrary subdirectory reads (non-_PULP) 244 + 245 + pub async fn read_chunk_in_dir( 246 + &mut self, 247 + dir: &str, 248 + name: &str, 249 + offset: u32, 250 + buf: &mut [u8], 251 + ) -> Result<usize, StorageError> { 252 + let result = storage::read_file_chunk_in_dir(&self.kernel.sd, dir, name, offset, buf) 253 + .map_err(map_read_err); 254 + embassy_futures::yield_now().await; 255 + result 256 + } 257 + 258 + // SD card health 259 + 260 + pub async fn check_sd(&mut self) -> bool { 261 + let ok = self.kernel.sd.probe_ok(); 262 + self.kernel.sd_ok = ok; 263 + yield_op!(ok) 264 + } 265 + 266 + // system info (sync, no I/O) 267 + 268 + #[inline] 269 + pub fn battery_mv(&self) -> u16 { 270 + self.kernel.cached_battery_mv 271 + } 272 + 273 + #[inline] 274 + pub fn uptime_secs(&self) -> u32 { 275 + uptime_secs() 276 + } 277 + 278 + #[inline] 279 + pub fn sd_ok(&self) -> bool { 280 + self.kernel.sd_ok 281 + } 282 + 283 + // smol-epub sync reader bridge 284 + // 285 + // smol-epub performs I/O through closures that cannot be async; 286 + // this provides a scoped sync reader that completes before 287 + // returning -- no borrows held across any .await point 288 + 289 + pub fn with_sync_reader<F, R>(&mut self, f: F) -> R 290 + where 291 + F: FnOnce(&mut dyn FnMut(&str, u32, &mut [u8]) -> Result<usize, &'static str>) -> R, 292 + { 293 + let sd = &self.kernel.sd; 294 + let mut reader = |name: &str, offset: u32, buf: &mut [u8]| { 295 + storage::read_file_chunk(sd, name, offset, buf) 296 + }; 297 + f(&mut reader) 298 + } 299 + 300 + pub fn with_sync_reader_app_subdir<F, R>(&mut self, dir: &str, f: F) -> R 301 + where 302 + F: FnOnce(&mut dyn FnMut(&str, u32, &mut [u8]) -> Result<usize, &'static str>) -> R, 303 + { 304 + let sd = &self.kernel.sd; 305 + let mut reader = |name: &str, offset: u32, buf: &mut [u8]| { 306 + storage::read_chunk_in_pulp_subdir(sd, dir, name, offset, buf) 307 + }; 308 + f(&mut reader) 309 + } 310 + 311 + // synchronous storage primitives 312 + // 313 + // each calls a single storage::* function and returns the raw 314 + // &'static str error; the async methods above delegate to these 315 + // adding map_err + yield_now 316 + 317 + #[inline] 318 + pub fn sync_file_size(&mut self, name: &str) -> Result<u32, &'static str> { 319 + storage::file_size(&self.kernel.sd, name) 320 + } 321 + 322 + #[inline] 323 + pub fn sync_read_chunk( 324 + &mut self, 325 + name: &str, 326 + offset: u32, 327 + buf: &mut [u8], 328 + ) -> Result<usize, &'static str> { 329 + storage::read_file_chunk(&self.kernel.sd, name, offset, buf) 330 + } 331 + 332 + #[inline] 333 + pub fn sync_read_file_start( 334 + &mut self, 335 + name: &str, 336 + buf: &mut [u8], 337 + ) -> Result<(u32, usize), &'static str> { 338 + storage::read_file_start(&self.kernel.sd, name, buf) 339 + } 340 + 341 + #[inline] 342 + pub fn sync_save_title(&mut self, filename: &str, title: &str) -> Result<(), &'static str> { 343 + storage::save_title(&self.kernel.sd, filename, title) 344 + } 345 + 346 + #[inline] 347 + pub fn sync_read_app_data_start( 348 + &mut self, 349 + name: &str, 350 + buf: &mut [u8], 351 + ) -> Result<(u32, usize), &'static str> { 352 + storage::read_file_start_in_dir(&self.kernel.sd, storage::PULP_DIR, name, buf) 353 + } 354 + 355 + #[inline] 356 + pub fn sync_write_app_data(&mut self, name: &str, data: &[u8]) -> Result<(), &'static str> { 357 + storage::write_file_in_dir(&self.kernel.sd, storage::PULP_DIR, name, data) 358 + } 359 + 360 + #[inline] 361 + pub fn sync_ensure_app_subdir(&mut self, dir: &str) -> Result<(), &'static str> { 362 + storage::ensure_pulp_subdir(&self.kernel.sd, dir) 363 + } 364 + 365 + #[inline] 366 + pub fn sync_read_app_subdir_chunk( 367 + &mut self, 368 + dir: &str, 369 + name: &str, 370 + offset: u32, 371 + buf: &mut [u8], 372 + ) -> Result<usize, &'static str> { 373 + storage::read_chunk_in_pulp_subdir(&self.kernel.sd, dir, name, offset, buf) 374 + } 375 + 376 + #[inline] 377 + pub fn sync_write_app_subdir( 378 + &mut self, 379 + dir: &str, 380 + name: &str, 381 + data: &[u8], 382 + ) -> Result<(), &'static str> { 383 + storage::write_in_pulp_subdir(&self.kernel.sd, dir, name, data) 384 + } 385 + 386 + #[inline] 387 + pub fn sync_append_app_subdir( 388 + &mut self, 389 + dir: &str, 390 + name: &str, 391 + data: &[u8], 392 + ) -> Result<(), &'static str> { 393 + storage::append_in_pulp_subdir(&self.kernel.sd, dir, name, data) 394 + } 395 + 396 + #[inline] 397 + pub fn sync_file_size_app_subdir( 398 + &mut self, 399 + dir: &str, 400 + name: &str, 401 + ) -> Result<u32, &'static str> { 402 + storage::file_size_in_pulp_subdir(&self.kernel.sd, dir, name) 403 + } 404 + 405 + pub fn sync_dir_page( 406 + &mut self, 407 + offset: usize, 408 + buf: &mut [DirEntry], 409 + ) -> Result<DirPage, &'static str> { 410 + let k = &mut *self.kernel; 411 + k.dir_cache.ensure_loaded(&k.sd)?; 412 + Ok(k.dir_cache.page(offset, buf)) 413 + } 414 + 415 + // direct cache accessors 416 + 417 + #[inline] 418 + pub fn bookmark_cache(&self) -> &BookmarkCache { 419 + &*self.kernel.bm_cache 420 + } 421 + 422 + #[inline] 423 + pub fn bookmark_cache_mut(&mut self) -> &mut BookmarkCache { 424 + &mut *self.kernel.bm_cache 425 + } 426 + 427 + #[inline] 428 + pub fn dir_cache_mut(&mut self) -> &mut DirCache { 429 + &mut *self.kernel.dir_cache 430 + } 431 + }
+101
kernel/src/kernel/mod.rs
··· 1 + // kernel: owns hardware resources, caches, and system state 2 + // 3 + // constructed once during boot in main(), lives for the lifetime of 4 + // the program. not a separate Embassy task -- a struct held by main. 5 + // 6 + // apps interact exclusively through KernelHandle, which borrows the 7 + // kernel for the duration of an async lifecycle method. 8 + 9 + pub mod app; 10 + pub mod bookmarks; 11 + pub mod config; 12 + pub mod console; 13 + pub mod dir_cache; 14 + pub mod handle; 15 + pub mod scheduler; 16 + pub mod tasks; 17 + pub mod wake; 18 + pub mod work_queue; 19 + 20 + pub use app::{ 21 + App, AppContext, AppIdType, AppLayer, Launcher, NavEvent, PendingSetting, QuickAction, 22 + QuickActionKind, RECENT_FILE, Redraw, Transition, 23 + }; 24 + pub use bookmarks::BookmarkCache; 25 + pub use console::BootConsole; 26 + pub use handle::{KernelHandle, StorageError}; 27 + pub use wake::uptime_secs; 28 + 29 + use esp_hal::delay::Delay; 30 + 31 + use crate::board::Epd; 32 + use crate::drivers::sdcard::SdStorage; 33 + use crate::drivers::strip::StripBuffer; 34 + use crate::kernel::dir_cache::DirCache; 35 + 36 + // default ghost-clear interval (overridden by settings once loaded) 37 + pub const DEFAULT_GHOST_CLEAR_EVERY: u32 = 10; 38 + 39 + pub struct Kernel { 40 + pub(crate) sd: SdStorage, 41 + pub(crate) dir_cache: &'static mut DirCache, 42 + pub(crate) bm_cache: &'static mut BookmarkCache, 43 + pub(crate) epd: Epd, 44 + pub(crate) strip: &'static mut StripBuffer, 45 + pub(crate) delay: Delay, 46 + pub(crate) sd_ok: bool, 47 + pub(crate) cached_battery_mv: u16, 48 + pub(crate) partial_refreshes: u32, 49 + 50 + // true when RED RAM is out of sync with BW after a skipped 51 + // phase3_sync (rapid navigation); next partial uses inv_red 52 + pub(crate) red_stale: bool, 53 + } 54 + 55 + impl Kernel { 56 + #[allow(clippy::too_many_arguments)] 57 + pub fn new( 58 + sd: SdStorage, 59 + epd: Epd, 60 + strip: &'static mut StripBuffer, 61 + dir_cache: &'static mut DirCache, 62 + bm_cache: &'static mut BookmarkCache, 63 + delay: Delay, 64 + sd_ok: bool, 65 + battery_mv: u16, 66 + ) -> Self { 67 + Self { 68 + sd, 69 + dir_cache, 70 + bm_cache, 71 + epd, 72 + strip, 73 + delay, 74 + sd_ok, 75 + cached_battery_mv: battery_mv, 76 + partial_refreshes: 0, 77 + red_stale: false, 78 + } 79 + } 80 + 81 + #[inline] 82 + pub fn handle(&mut self) -> KernelHandle<'_> { 83 + KernelHandle::new(self) 84 + } 85 + 86 + #[inline] 87 + pub fn set_battery_mv(&mut self, mv: u16) { 88 + self.cached_battery_mv = mv; 89 + } 90 + 91 + #[inline] 92 + pub fn reset_partial_count(&mut self) { 93 + self.partial_refreshes = 0; 94 + self.red_stale = false; 95 + } 96 + 97 + #[inline] 98 + pub fn bump_partial_count(&mut self) { 99 + self.partial_refreshes += 1; 100 + } 101 + }
+382
kernel/src/kernel/scheduler.rs
··· 1 + // scheduler: main event loop, render pipeline, housekeeping, sleep 2 + // 3 + // EPD and SD share a single SPI bus via CriticalSectionDevice; 4 + // busy_wait_with_input() does NOT run background SD I/O while 5 + // the EPD is refreshing to avoid RefCell borrow conflicts 6 + 7 + use embassy_futures::select::{Either, select}; 8 + use embassy_time::{Duration, Ticker, Timer}; 9 + use log::info; 10 + 11 + use super::app::{AppLayer, Redraw, Transition}; 12 + use crate::board::button::Button; 13 + use crate::drivers::battery; 14 + use crate::drivers::input::Event; 15 + use crate::drivers::strip::StripBuffer; 16 + use crate::kernel::tasks; 17 + 18 + use crate::ui::{free_stack_bytes, stack_high_water_mark}; 19 + 20 + const TICK_MS: u64 = 10; 21 + 22 + impl super::Kernel { 23 + // render boot console to EPD -- call before boot() to show 24 + // hardware init progress in the built-in mono font 25 + pub async fn show_boot_console(&mut self, console: &super::BootConsole) { 26 + let draw = |s: &mut StripBuffer| console.draw(s); 27 + self.epd 28 + .full_refresh_async(self.strip, &mut self.delay, &draw) 29 + .await; 30 + } 31 + 32 + // one-time boot: load caches, settings, render the home screen 33 + pub async fn boot<A: AppLayer>(&mut self, app_mgr: &mut A) { 34 + self.bm_cache.ensure_loaded(&self.sd); 35 + 36 + { 37 + let mut handle = self.handle(); 38 + app_mgr.load_eager_settings(&mut handle); 39 + app_mgr.load_initial_state(&mut handle); 40 + } 41 + 42 + tasks::set_idle_timeout(app_mgr.system_settings().sleep_timeout); 43 + self.log_stats(); 44 + app_mgr.enter_initial(&mut self.handle()).await; 45 + 46 + { 47 + let draw = |s: &mut StripBuffer| app_mgr.draw(s); 48 + self.epd 49 + .full_refresh_async(self.strip, &mut self.delay, &draw) 50 + .await; 51 + } 52 + let _ = app_mgr.take_redraw(); 53 + 54 + info!("ui ready."); 55 + } 56 + 57 + // event-driven main loop -- never returns 58 + pub async fn run<A: AppLayer>(&mut self, app_mgr: &mut A) -> ! { 59 + let mut work_ticker = Ticker::every(Duration::from_millis(TICK_MS)); 60 + 61 + loop { 62 + if app_mgr.needs_special_mode() { 63 + self.handle_special_mode(app_mgr).await; 64 + continue; 65 + } 66 + 67 + let hw_event = match select(tasks::INPUT_EVENTS.receive(), work_ticker.next()).await { 68 + Either::First(ev) => Some(ev), 69 + Either::Second(_) => None, 70 + }; 71 + 72 + if let Some(ev) = hw_event { 73 + self.handle_input(ev, app_mgr).await; 74 + } 75 + 76 + if app_mgr.needs_special_mode() { 77 + continue; 78 + } 79 + 80 + // SAFETY-CRITICAL: SPI bus sharing invariant 81 + // 82 + // The EPD and SD card share a single SPI2 bus via 83 + // CriticalSectionDevice (RefCell under the hood). SD I/O 84 + // and EPD rendering must NEVER overlap, concurrent access 85 + // would cause a RefCell borrow panic at runtime. 86 + // 87 + // This ordering enforces that: 88 + // 1. All background SD I/O (app caching, title scan, etc.) 89 + // completes here, before any EPD access. 90 + // 2. poll_housekeeping may do SD I/O (bookmark flush, 91 + // SD probe), also before render. 92 + // 3. render() is the only code below that touches the EPD. 93 + // 4. busy_wait_with_input() does NOT run background work 94 + // while the EPD is refreshing, only input collection. 95 + // 96 + // If you add new SD I/O call sites, they MUST go above the 97 + // render() call. Violating this will panic, not corrupt. 98 + { 99 + let mut handle = self.handle(); 100 + app_mgr.run_background(&mut handle).await; 101 + } 102 + 103 + self.poll_housekeeping(app_mgr).await; 104 + 105 + if app_mgr.has_redraw() { 106 + let redraw = app_mgr.take_redraw(); 107 + self.render(app_mgr, redraw).await; 108 + } 109 + } 110 + } 111 + 112 + // delegate to app layer for modes that bypass normal dispatch 113 + // (e.g. wifi upload); kernel passes hardware resources through 114 + async fn handle_special_mode<A: AppLayer>(&mut self, app_mgr: &mut A) { 115 + app_mgr 116 + .run_special_mode(&mut self.epd, self.strip, &mut self.delay, &self.sd) 117 + .await; 118 + 119 + app_mgr 120 + .apply_transition(Transition::Pop, &mut self.handle()) 121 + .await; 122 + app_mgr.request_full_redraw(); 123 + } 124 + 125 + async fn handle_input<A: AppLayer>(&mut self, hw_event: Event, app_mgr: &mut A) { 126 + // power long-press -> sleep (intercept before app dispatch) 127 + if hw_event == Event::LongPress(Button::Power) { 128 + self.enter_sleep("power held").await; 129 + } 130 + 131 + let transition = app_mgr.dispatch_event(hw_event, &mut *self.bm_cache); 132 + 133 + if transition != Transition::None { 134 + app_mgr 135 + .apply_transition(transition, &mut self.handle()) 136 + .await; 137 + } 138 + } 139 + 140 + async fn poll_housekeeping<A: AppLayer>(&mut self, app_mgr: &A) { 141 + if let Some(mv) = tasks::BATTERY_MV.try_take() { 142 + self.cached_battery_mv = mv; 143 + } 144 + 145 + if tasks::SD_CHECK_DUE.try_take().is_some() { 146 + self.sd_ok = self.sd.probe_ok(); 147 + } 148 + 149 + if tasks::BOOKMARK_FLUSH_DUE.try_take().is_some() && self.bm_cache.is_dirty() { 150 + self.bm_cache.flush(&self.sd); 151 + } 152 + 153 + if tasks::STATUS_DUE.try_take().is_some() { 154 + self.log_stats(); 155 + if app_mgr.settings_loaded() { 156 + tasks::set_idle_timeout(app_mgr.system_settings().sleep_timeout); 157 + } 158 + } 159 + 160 + if tasks::IDLE_SLEEP_DUE.try_take().is_some() { 161 + self.enter_sleep("idle timeout").await; 162 + } 163 + } 164 + 165 + // partial refreshes use DU waveform (~400 ms); after ghost_clear_every 166 + // partials, a full GC refresh (~1.6 s) clears ghosting 167 + async fn render<A: AppLayer>(&mut self, app_mgr: &mut A, redraw: Redraw) { 168 + 'render: { 169 + if let Redraw::Partial(r) = redraw { 170 + let ghost_clear_every = app_mgr.ghost_clear_every(); 171 + 172 + if self.partial_refreshes < ghost_clear_every { 173 + let r = r.align8(); 174 + 175 + let rs = { 176 + let draw = |s: &mut StripBuffer| app_mgr.draw(s); 177 + if self.red_stale { 178 + self.epd.partial_phase1_bw_inv_red( 179 + self.strip, 180 + r.x, 181 + r.y, 182 + r.w, 183 + r.h, 184 + &mut self.delay, 185 + &draw, 186 + ) 187 + } else { 188 + self.epd.partial_phase1_bw( 189 + self.strip, 190 + r.x, 191 + r.y, 192 + r.w, 193 + r.h, 194 + &mut self.delay, 195 + &draw, 196 + ) 197 + } 198 + }; 199 + 200 + if let Some(rs) = rs { 201 + self.epd.partial_start_du(&rs); 202 + let deferred = self.busy_wait_with_input(app_mgr).await; 203 + 204 + if app_mgr.has_redraw() { 205 + // content changed mid-DU; leave RED stale 206 + app_mgr.ctx_mut().mark_dirty(r); 207 + self.red_stale = true; 208 + self.partial_refreshes += 1; 209 + } else { 210 + self.red_stale = false; 211 + { 212 + let draw = |s: &mut StripBuffer| app_mgr.draw(s); 213 + self.epd.partial_phase3_sync(self.strip, &rs, &draw); 214 + } 215 + self.partial_refreshes += 1; 216 + self.epd.power_off_async().await; 217 + } 218 + 219 + if let Some(transition) = deferred { 220 + app_mgr 221 + .apply_transition(transition, &mut self.handle()) 222 + .await; 223 + } 224 + 225 + break 'render; 226 + } 227 + 228 + if !self.epd.needs_initial_refresh() { 229 + break 'render; 230 + } 231 + info!("display: partial failed (initial refresh), promoting to full"); 232 + } else { 233 + info!("display: promoted partial to full (ghosting clear)"); 234 + } 235 + } 236 + 237 + if matches!(redraw, Redraw::Full | Redraw::Partial(_)) { 238 + self.epd.power_off_async().await; 239 + 240 + self.log_stats(); 241 + 242 + { 243 + let draw = |s: &mut StripBuffer| app_mgr.draw(s); 244 + self.epd 245 + .write_full_frame(self.strip, &mut self.delay, &draw); 246 + } 247 + 248 + self.epd.start_full_update(); 249 + 250 + let deferred = self.busy_wait_with_input(app_mgr).await; 251 + 252 + self.epd.finish_full_update(); 253 + self.partial_refreshes = 0; 254 + self.red_stale = false; 255 + 256 + if let Some(transition) = deferred { 257 + app_mgr 258 + .apply_transition(transition, &mut self.handle()) 259 + .await; 260 + } 261 + } 262 + } // 'render 263 + } 264 + 265 + // Collect input events while EPD is busy refreshing. 266 + // 267 + // SAFETY-CRITICAL: no SD I/O or background work may run here. 268 + // The EPD is actively driving the SPI bus during refresh; any 269 + // SD access would cause a RefCell borrow panic. Only input 270 + // events (from the ADC-based input_task) are collected. 271 + async fn busy_wait_with_input<A: AppLayer>( 272 + &mut self, 273 + app_mgr: &mut A, 274 + ) -> Option<Transition<A::Id>> { 275 + let mut deferred: Option<Transition<A::Id>> = None; 276 + 277 + loop { 278 + if !self.epd.is_busy() { 279 + break; 280 + } 281 + 282 + match select( 283 + self.epd.busy_pin().wait_for_low(), 284 + select( 285 + tasks::INPUT_EVENTS.receive(), 286 + Timer::after(Duration::from_millis(TICK_MS)), 287 + ), 288 + ) 289 + .await 290 + { 291 + Either::First(_) => break, 292 + 293 + Either::Second(Either::First(hw_event)) => { 294 + if app_mgr.suppress_deferred_input() { 295 + continue; 296 + } 297 + 298 + let t = app_mgr.dispatch_event(hw_event, &mut *self.bm_cache); 299 + if t != Transition::None && deferred.is_none() { 300 + deferred = Some(t); 301 + } 302 + } 303 + 304 + Either::Second(Either::Second(_)) => {} 305 + } 306 + } 307 + 308 + deferred 309 + } 310 + 311 + // flush bookmarks, render sleep screen, enter MCU deep sleep 312 + // on real hardware this never returns (wake = full MCU reset) 313 + pub async fn enter_sleep(&mut self, reason: &str) { 314 + use embedded_graphics::mono_font::MonoTextStyle; 315 + use embedded_graphics::mono_font::ascii::FONT_6X13; 316 + use embedded_graphics::pixelcolor::BinaryColor; 317 + use embedded_graphics::prelude::*; 318 + use embedded_graphics::text::Text; 319 + use esp_hal::gpio::RtcPinWithResistors; 320 + use esp_hal::rtc_cntl::Rtc; 321 + use esp_hal::rtc_cntl::sleep::{RtcioWakeupSource, WakeupLevel}; 322 + 323 + info!("{}: entering sleep...", reason); 324 + 325 + if self.bm_cache.is_dirty() { 326 + self.bm_cache.flush(&self.sd); 327 + } 328 + 329 + self.epd 330 + .full_refresh_async(self.strip, &mut self.delay, &|s: &mut StripBuffer| { 331 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 332 + let _ = Text::new("(sleep)", Point::new(210, 400), style).draw(s); 333 + }) 334 + .await; 335 + info!("display: sleep screen rendered"); 336 + 337 + self.epd.enter_deep_sleep(); 338 + info!("display: deep sleep mode 1"); 339 + 340 + // safety: deep sleep never returns, the MCU resets on wake, so 341 + // these stolen peripherals cannot alias with their original 342 + // owners. LPWR is not used elsewhere; GPIO3 was previously 343 + // cloned into InputHw but we are about to halt the CPU. 344 + let mut rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() }); 345 + let mut gpio3 = unsafe { esp_hal::peripherals::GPIO3::steal() }; 346 + let wakeup_pins: &mut [(&mut dyn RtcPinWithResistors, WakeupLevel)] = 347 + &mut [(&mut gpio3, WakeupLevel::Low)]; 348 + let rtcio = RtcioWakeupSource::new(wakeup_pins); 349 + 350 + info!("mcu: entering deep sleep (power button to wake)"); 351 + rtc.sleep_deep(&[&rtcio]); 352 + 353 + // deep sleep resets the MCU; backstop if sleep_deep returns 354 + #[allow(unreachable_code)] 355 + loop { 356 + core::hint::spin_loop(); 357 + } 358 + } 359 + 360 + pub fn log_stats(&self) { 361 + let stats = esp_alloc::HEAP.stats(); 362 + let bat_pct = battery::battery_percentage(self.cached_battery_mv); 363 + let uptime = super::uptime_secs(); 364 + let mins = (uptime / 60) % 60; 365 + let hrs = uptime / 3600; 366 + 367 + info!( 368 + "stats: heap {}/{}K peak {}K | stack free {}K hwm {}K | bat {}% {}.{}V | up {}:{:02} | SD:{}", 369 + stats.current_usage / 1024, 370 + stats.size / 1024, 371 + stats.max_usage / 1024, 372 + free_stack_bytes() / 1024, 373 + stack_high_water_mark() / 1024, 374 + bat_pct, 375 + self.cached_battery_mv / 1000, 376 + (self.cached_battery_mv % 1000) / 100, 377 + hrs, 378 + mins, 379 + if self.sd_ok { "ok" } else { "--" }, 380 + ); 381 + } 382 + }
+5
kernel/src/kernel/wake.rs
··· 1 + // uptime helper backed by embassy's monotonic clock 2 + pub fn uptime_secs() -> u32 { 3 + let ticks = embassy_time::Instant::now().as_ticks(); 4 + (ticks / embassy_time::TICK_HZ) as u32 5 + }
+313
kernel/src/kernel/work_queue.rs
··· 1 + // background work queue 2 + // 3 + // offloads CPU-heavy processing (HTML strip, image decode) to a 4 + // dedicated embassy task while the main UI loop stays responsive 5 + // 6 + // generation-based cancellation: bump generation and drain() to 7 + // discard stale work; no explicit cancel signal needed 8 + // 9 + // channel capacity 1 for natural back-pressure; worker drops input 10 + // buffers before sending results so peak heap is bounded 11 + 12 + extern crate alloc; 13 + 14 + use alloc::vec::Vec; 15 + use core::cell::Cell; 16 + 17 + use critical_section::Mutex; 18 + use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; 19 + use embassy_sync::channel::Channel; 20 + 21 + use smol_epub::DecodedImage; 22 + 23 + #[derive(Clone, Copy, PartialEq, Eq, Debug)] 24 + #[repr(u8)] 25 + pub enum BgWorkKind { 26 + Idle = 0, 27 + StripChapter = 1, 28 + DecodeImage = 2, 29 + } 30 + 31 + impl BgWorkKind { 32 + pub const fn label(self) -> &'static str { 33 + match self { 34 + Self::Idle => "", 35 + Self::StripChapter => "CH", 36 + Self::DecodeImage => "IMG", 37 + } 38 + } 39 + } 40 + 41 + #[derive(Clone, Copy, PartialEq, Eq)] 42 + pub struct BgStatus { 43 + pub kind: BgWorkKind, 44 + pub generation: u16, 45 + } 46 + 47 + impl BgStatus { 48 + pub const IDLE: Self = Self { 49 + kind: BgWorkKind::Idle, 50 + generation: 0, 51 + }; 52 + 53 + #[inline] 54 + pub const fn is_active(&self) -> bool { 55 + !matches!(self.kind, BgWorkKind::Idle) 56 + } 57 + 58 + #[inline] 59 + pub const fn is_active_for(&self, target_gen: u16) -> bool { 60 + self.is_active() && self.generation == target_gen 61 + } 62 + } 63 + 64 + static STATUS: Mutex<Cell<BgStatus>> = Mutex::new(Cell::new(BgStatus::IDLE)); 65 + 66 + #[inline] 67 + pub fn status() -> BgStatus { 68 + critical_section::with(|cs| STATUS.borrow(cs).get()) 69 + } 70 + 71 + #[inline] 72 + pub fn is_idle() -> bool { 73 + !status().is_active() 74 + } 75 + 76 + fn set_status(s: BgStatus) { 77 + critical_section::with(|cs| STATUS.borrow(cs).set(s)); 78 + } 79 + 80 + static ACTIVE_GEN: Mutex<Cell<u16>> = Mutex::new(Cell::new(0)); 81 + static GEN_COUNTER: Mutex<Cell<u16>> = Mutex::new(Cell::new(0)); 82 + 83 + pub fn next_generation() -> u16 { 84 + critical_section::with(|cs| { 85 + let c = GEN_COUNTER.borrow(cs); 86 + let g = c.get().wrapping_add(1); 87 + c.set(g); 88 + ACTIVE_GEN.borrow(cs).set(g); 89 + g 90 + }) 91 + } 92 + 93 + #[inline] 94 + pub fn active_generation() -> u16 { 95 + critical_section::with(|cs| ACTIVE_GEN.borrow(cs).get()) 96 + } 97 + 98 + pub fn set_active_generation(g: u16) { 99 + critical_section::with(|cs| ACTIVE_GEN.borrow(cs).set(g)); 100 + } 101 + 102 + pub enum WorkTask { 103 + StripChapter { 104 + chapter_idx: u16, 105 + xhtml: Vec<u8>, 106 + }, 107 + DecodeImage { 108 + path_hash: u32, 109 + data: Vec<u8>, 110 + is_jpeg: bool, 111 + max_w: u16, 112 + max_h: u16, 113 + }, 114 + } 115 + 116 + pub struct WorkItem { 117 + pub generation: u16, 118 + pub task: WorkTask, 119 + } 120 + 121 + pub enum WorkOutcome { 122 + ChapterReady { 123 + chapter_idx: u16, 124 + text: Vec<u8>, 125 + }, 126 + ChapterFailed { 127 + chapter_idx: u16, 128 + error: &'static str, 129 + }, 130 + ImageReady { 131 + path_hash: u32, 132 + image: DecodedImage, 133 + }, 134 + ImageFailed { 135 + path_hash: u32, 136 + error: &'static str, 137 + }, 138 + } 139 + 140 + pub struct WorkResult { 141 + pub generation: u16, 142 + pub outcome: WorkOutcome, 143 + } 144 + 145 + impl WorkResult { 146 + #[inline] 147 + pub fn is_current(&self) -> bool { 148 + self.generation == active_generation() 149 + } 150 + } 151 + 152 + static WORK_IN: Channel<CriticalSectionRawMutex, WorkItem, 1> = Channel::new(); 153 + static WORK_OUT: Channel<CriticalSectionRawMutex, WorkResult, 1> = Channel::new(); 154 + 155 + pub fn submit(generation: u16, task: WorkTask) -> bool { 156 + WORK_IN.try_send(WorkItem { generation, task }).is_ok() 157 + } 158 + 159 + #[inline] 160 + pub fn try_recv() -> Option<WorkResult> { 161 + WORK_OUT.try_receive().ok() 162 + } 163 + 164 + pub fn drain() { 165 + while WORK_IN.try_receive().is_ok() {} 166 + while WORK_OUT.try_receive().is_ok() {} 167 + } 168 + 169 + pub fn reset() -> u16 { 170 + let g = next_generation(); 171 + drain(); 172 + log::info!("[work] reset -> gen {}", g); 173 + g 174 + } 175 + 176 + #[embassy_executor::task] 177 + pub async fn worker_task() -> ! { 178 + log::info!("[work] worker ready"); 179 + 180 + loop { 181 + set_status(BgStatus::IDLE); 182 + let item = WORK_IN.receive().await; 183 + 184 + let g = item.generation; 185 + if g != active_generation() { 186 + log::info!( 187 + "[work] skip stale item (gen {} != active {})", 188 + g, 189 + active_generation() 190 + ); 191 + drop(item); 192 + continue; 193 + } 194 + 195 + match item.task { 196 + WorkTask::StripChapter { chapter_idx, xhtml } => { 197 + set_status(BgStatus { 198 + kind: BgWorkKind::StripChapter, 199 + generation: g, 200 + }); 201 + 202 + let src_len = xhtml.len(); 203 + log::info!( 204 + "[work] ch{}: strip {} bytes (gen {})", 205 + chapter_idx, 206 + src_len, 207 + g, 208 + ); 209 + 210 + let result = smol_epub::cache::strip_html_buf(&xhtml); 211 + drop(xhtml); 212 + 213 + if g != active_generation() { 214 + log::info!("[work] ch{}: discarded (gen {} stale)", chapter_idx, g,); 215 + continue; 216 + } 217 + 218 + let outcome = match result { 219 + Ok(text) => { 220 + log::info!( 221 + "[work] ch{}: {} -> {} bytes", 222 + chapter_idx, 223 + src_len, 224 + text.len(), 225 + ); 226 + WorkOutcome::ChapterReady { chapter_idx, text } 227 + } 228 + Err(e) => { 229 + log::warn!("[work] ch{}: strip failed: {}", chapter_idx, e); 230 + WorkOutcome::ChapterFailed { 231 + chapter_idx, 232 + error: e, 233 + } 234 + } 235 + }; 236 + 237 + WORK_OUT 238 + .send(WorkResult { 239 + generation: g, 240 + outcome, 241 + }) 242 + .await; 243 + } 244 + 245 + WorkTask::DecodeImage { 246 + path_hash, 247 + data, 248 + is_jpeg, 249 + max_w, 250 + max_h, 251 + } => { 252 + set_status(BgStatus { 253 + kind: BgWorkKind::DecodeImage, 254 + generation: g, 255 + }); 256 + 257 + let fmt = if is_jpeg { "JPEG" } else { "PNG" }; 258 + log::info!( 259 + "[work] img {:#010X}: decode {} ({} bytes, {}x{}, gen {})", 260 + path_hash, 261 + fmt, 262 + data.len(), 263 + max_w, 264 + max_h, 265 + g, 266 + ); 267 + 268 + let result = if is_jpeg { 269 + smol_epub::jpeg::decode_jpeg_fit(&data, max_w, max_h) 270 + } else { 271 + smol_epub::png::decode_png_fit(&data, max_w, max_h) 272 + }; 273 + drop(data); 274 + 275 + if g != active_generation() { 276 + log::info!( 277 + "[work] img {:#010X}: discarded (gen {} stale)", 278 + path_hash, 279 + g, 280 + ); 281 + continue; 282 + } 283 + 284 + let outcome = match result { 285 + Ok(image) => { 286 + log::info!( 287 + "[work] img {:#010X}: {}x{} ({}B 1-bit)", 288 + path_hash, 289 + image.width, 290 + image.height, 291 + image.data.len(), 292 + ); 293 + WorkOutcome::ImageReady { path_hash, image } 294 + } 295 + Err(e) => { 296 + log::warn!("[work] img {:#010X}: decode failed: {}", path_hash, e,); 297 + WorkOutcome::ImageFailed { 298 + path_hash, 299 + error: e, 300 + } 301 + } 302 + }; 303 + 304 + WORK_OUT 305 + .send(WorkResult { 306 + generation: g, 307 + outcome, 308 + }) 309 + .await; 310 + } 311 + } 312 + } 313 + }
+14
kernel/src/lib.rs
··· 1 + // pulp-kernel -- hardware drivers, scheduling, and system core 2 + // 3 + // generic over AppLayer; never imports concrete apps or fonts. 4 + // ships a built-in mono font (FONT_6X13) for boot console and 5 + // sleep screen. distros bring their own proportional fonts. 6 + 7 + #![no_std] 8 + 9 + extern crate alloc; 10 + 11 + pub mod board; 12 + pub mod drivers; 13 + pub mod kernel; 14 + pub mod ui;
+17
kernel/src/ui/mod.rs
··· 1 + // widget primitives for 1-bit e-paper displays 2 + // 3 + // font-independent: Region, Alignment, stack measurement, StackFmt. 4 + // font-dependent widgets (BitmapLabel, QuickMenu, ButtonFeedback) 5 + // live in the distro's apps::widgets module. 6 + 7 + pub mod stack_fmt; 8 + pub mod statusbar; 9 + mod widget; 10 + 11 + pub use stack_fmt::{StackFmt, stack_fmt}; 12 + pub use statusbar::{ 13 + BAR_HEIGHT, CONTENT_TOP, free_stack_bytes, paint_stack, stack_high_water_mark, 14 + }; 15 + pub use widget::{Alignment, Region, wrap_next, wrap_prev}; 16 + 17 + pub use crate::board::{SCREEN_H, SCREEN_W};
+97
kernel/src/ui/statusbar.rs
··· 1 + // status bar constants and stack-measurement utilities 2 + // system stats are emitted via log::info! in the scheduler 3 + 4 + pub const BAR_HEIGHT: u16 = 4; 5 + pub const CONTENT_TOP: u16 = BAR_HEIGHT; 6 + 7 + const STACK_PAINT_WORD: u32 = 0xDEAD_BEEF; 8 + 9 + // paint the unused stack with a sentinel word so stack_high_water_mark 10 + // can later measure peak usage; call very early in boot 11 + pub fn paint_stack() { 12 + #[cfg(target_arch = "riscv32")] 13 + { 14 + let sp: usize; 15 + unsafe { 16 + core::arch::asm!("mv {}, sp", out(reg) sp); 17 + } 18 + 19 + unsafe extern "C" { 20 + static _stack_end_cpu0: u8; 21 + } 22 + let bottom = (&raw const _stack_end_cpu0) as usize; 23 + 24 + let guard_skip = 256; 25 + let paint_bottom = bottom + guard_skip; 26 + 27 + let paint_top = sp.saturating_sub(256); 28 + 29 + if paint_top <= paint_bottom { 30 + return; 31 + } 32 + 33 + let start = (paint_bottom + 3) & !3; 34 + 35 + let mut addr = start; 36 + while addr + 4 <= paint_top { 37 + unsafe { 38 + core::ptr::write_volatile(addr as *mut u32, STACK_PAINT_WORD); 39 + } 40 + addr += 4; 41 + } 42 + } 43 + } 44 + 45 + pub fn free_stack_bytes() -> usize { 46 + #[cfg(target_arch = "riscv32")] 47 + { 48 + let sp: usize; 49 + unsafe { 50 + core::arch::asm!("mv {}, sp", out(reg) sp); 51 + } 52 + 53 + unsafe extern "C" { 54 + static _stack_end_cpu0: u8; 55 + } 56 + let stack_bottom = (&raw const _stack_end_cpu0) as usize; 57 + sp.saturating_sub(stack_bottom) 58 + } 59 + 60 + #[cfg(not(target_arch = "riscv32"))] 61 + { 62 + 0 63 + } 64 + } 65 + 66 + pub fn stack_high_water_mark() -> usize { 67 + #[cfg(target_arch = "riscv32")] 68 + { 69 + unsafe extern "C" { 70 + static _stack_end_cpu0: u8; 71 + static _stack_start_cpu0: u8; 72 + } 73 + let bottom = (&raw const _stack_end_cpu0) as usize; 74 + let top = (&raw const _stack_start_cpu0) as usize; 75 + 76 + let guard_skip = 256; 77 + let scan_bottom = bottom + guard_skip; 78 + 79 + let start = (scan_bottom + 3) & !3; 80 + 81 + let mut addr = start; 82 + while addr + 4 <= top { 83 + let val = unsafe { core::ptr::read_volatile(addr as *const u32) }; 84 + if val != STACK_PAINT_WORD { 85 + break; 86 + } 87 + addr += 4; 88 + } 89 + 90 + top.saturating_sub(addr) 91 + } 92 + 93 + #[cfg(not(target_arch = "riscv32"))] 94 + { 95 + 0 96 + } 97 + }
-339
src/apps/bookmarks.rs
··· 1 - // Bookmark cache: 16 slots, RAM-resident, flushed to SD on dirty. 2 - // 3 - // Record layout (little-endian, 48 bytes per slot): 4 - // [0..4) name_hash u32 5 - // [4..8) byte_offset u32 font-independent file/chapter position 6 - // [8..10) chapter u16 epub chapter; 0 for txt 7 - // [10..12) flags u16 bit 0 = valid 8 - // [12..14) generation u16 LRU counter (higher = more recent) 9 - // [14] name_len u8 10 - // [15] _pad u8 11 - // [16..48) filename [u8;32] 12 - 13 - use crate::drivers::sdcard::SdStorage; 14 - use crate::drivers::storage; 15 - pub use smol_epub::cache::fnv1a; 16 - 17 - fn fnv1a_icase(data: &[u8]) -> u32 { 18 - let mut h: u32 = 0x811c_9dc5; 19 - for &b in data { 20 - h ^= b.to_ascii_lowercase() as u32; 21 - h = h.wrapping_mul(0x0100_0193); 22 - } 23 - h 24 - } 25 - 26 - pub const BOOKMARK_FILE: &str = "BKMK.BIN"; 27 - pub const SLOTS: usize = 16; 28 - pub const RECORD_LEN: usize = 48; 29 - pub const FILE_LEN: usize = SLOTS * RECORD_LEN; // 768B 30 - pub const FILENAME_CAP: usize = 32; 31 - 32 - #[derive(Clone, Copy)] 33 - pub struct BookmarkSlot { 34 - pub name_hash: u32, 35 - pub byte_offset: u32, 36 - pub chapter: u16, 37 - pub valid: bool, 38 - pub generation: u16, 39 - pub name_len: u8, 40 - pub filename: [u8; FILENAME_CAP], 41 - } 42 - 43 - impl BookmarkSlot { 44 - pub const EMPTY: Self = Self { 45 - name_hash: 0, 46 - byte_offset: 0, 47 - chapter: 0, 48 - valid: false, 49 - generation: 0, 50 - name_len: 0, 51 - filename: [0u8; FILENAME_CAP], 52 - }; 53 - 54 - pub fn filename_str(&self) -> &str { 55 - core::str::from_utf8(&self.filename[..self.name_len as usize]).unwrap_or("?") 56 - } 57 - 58 - fn decode(rec: &[u8]) -> Self { 59 - if rec.len() < RECORD_LEN { 60 - return Self::EMPTY; 61 - } 62 - let name_hash = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]); 63 - let byte_offset = u32::from_le_bytes([rec[4], rec[5], rec[6], rec[7]]); 64 - let chapter = u16::from_le_bytes([rec[8], rec[9]]); 65 - let flags = u16::from_le_bytes([rec[10], rec[11]]); 66 - let generation = u16::from_le_bytes([rec[12], rec[13]]); 67 - let name_len = rec[14].min(FILENAME_CAP as u8); 68 - 69 - let mut filename = [0u8; FILENAME_CAP]; 70 - let n = name_len as usize; 71 - filename[..n].copy_from_slice(&rec[16..16 + n]); 72 - 73 - Self { 74 - name_hash, 75 - byte_offset, 76 - chapter, 77 - valid: flags & 1 != 0, 78 - generation, 79 - name_len, 80 - filename, 81 - } 82 - } 83 - 84 - fn encode(&self) -> [u8; RECORD_LEN] { 85 - let flags: u16 = if self.valid { 1 } else { 0 }; 86 - let mut rec = [0u8; RECORD_LEN]; 87 - rec[0..4].copy_from_slice(&self.name_hash.to_le_bytes()); 88 - rec[4..8].copy_from_slice(&self.byte_offset.to_le_bytes()); 89 - rec[8..10].copy_from_slice(&self.chapter.to_le_bytes()); 90 - rec[10..12].copy_from_slice(&flags.to_le_bytes()); 91 - rec[12..14].copy_from_slice(&self.generation.to_le_bytes()); 92 - rec[14] = self.name_len; 93 - rec[15] = 0; 94 - let n = self.name_len as usize; 95 - rec[16..16 + n].copy_from_slice(&self.filename[..n]); 96 - rec 97 - } 98 - 99 - fn matches_name(&self, name: &[u8]) -> bool { 100 - self.name_len as usize == name.len() 101 - && self.filename[..self.name_len as usize].eq_ignore_ascii_case(name) 102 - } 103 - } 104 - 105 - #[derive(Clone, Copy)] 106 - pub struct BmListEntry { 107 - pub filename: [u8; FILENAME_CAP], 108 - pub name_len: u8, 109 - pub chapter: u16, 110 - } 111 - 112 - impl BmListEntry { 113 - pub const EMPTY: Self = Self { 114 - filename: [0u8; FILENAME_CAP], 115 - name_len: 0, 116 - chapter: 0, 117 - }; 118 - 119 - pub fn filename_str(&self) -> &str { 120 - core::str::from_utf8(&self.filename[..self.name_len as usize]).unwrap_or("?") 121 - } 122 - } 123 - 124 - pub struct BookmarkCache { 125 - slots: [BookmarkSlot; SLOTS], 126 - count: usize, // slots present in file; new saves past this extend count 127 - dirty: bool, 128 - loaded: bool, 129 - } 130 - 131 - impl Default for BookmarkCache { 132 - fn default() -> Self { 133 - Self::new() 134 - } 135 - } 136 - 137 - impl BookmarkCache { 138 - pub const fn new() -> Self { 139 - Self { 140 - slots: [BookmarkSlot::EMPTY; SLOTS], 141 - count: 0, 142 - dirty: false, 143 - loaded: false, 144 - } 145 - } 146 - 147 - pub fn is_dirty(&self) -> bool { 148 - self.dirty 149 - } 150 - 151 - pub fn is_loaded(&self) -> bool { 152 - self.loaded 153 - } 154 - 155 - pub fn ensure_loaded<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 156 - if self.loaded { 157 - return; 158 - } 159 - self.force_load(sd); 160 - } 161 - 162 - pub fn force_load<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 163 - let mut buf = [0u8; FILE_LEN]; 164 - let slot_count = match storage::read_pulp_file_start(sd, BOOKMARK_FILE, &mut buf) { 165 - Ok((_, n)) => (n / RECORD_LEN).min(SLOTS), 166 - Err(_) => 0, 167 - }; 168 - 169 - for i in 0..slot_count { 170 - let base = i * RECORD_LEN; 171 - self.slots[i] = BookmarkSlot::decode(&buf[base..base + RECORD_LEN]); 172 - } 173 - for i in slot_count..SLOTS { 174 - self.slots[i] = BookmarkSlot::EMPTY; 175 - } 176 - 177 - self.count = slot_count; 178 - self.dirty = false; 179 - self.loaded = true; 180 - 181 - log::info!("bookmarks: loaded {} slots from SD", slot_count); 182 - } 183 - 184 - // find bookmark by filename; None if not found or not loaded 185 - pub fn find(&self, filename: &[u8]) -> Option<BookmarkSlot> { 186 - if !self.loaded { 187 - return None; 188 - } 189 - 190 - let key = fnv1a_icase(filename); 191 - for i in 0..self.count { 192 - let slot = &self.slots[i]; 193 - if slot.valid && slot.name_hash == key && slot.matches_name(filename) { 194 - return Some(*slot); 195 - } 196 - } 197 - None 198 - } 199 - 200 - pub fn load_all(&self, out: &mut [BmListEntry]) -> usize { 201 - if !self.loaded { 202 - return 0; 203 - } 204 - 205 - let mut gens = [0u16; SLOTS]; 206 - let mut count = 0usize; 207 - 208 - for i in 0..self.count { 209 - if count >= out.len() { 210 - break; 211 - } 212 - let slot = &self.slots[i]; 213 - if slot.valid && slot.name_len > 0 { 214 - gens[count] = slot.generation; 215 - out[count] = BmListEntry { 216 - filename: slot.filename, 217 - name_len: slot.name_len, 218 - chapter: slot.chapter, 219 - }; 220 - count += 1; 221 - } 222 - } 223 - 224 - for i in 1..count { 225 - let key_gen = gens[i]; 226 - let key_entry = out[i]; 227 - let mut j = i; 228 - while j > 0 && gens[j - 1] < key_gen { 229 - gens[j] = gens[j - 1]; 230 - out[j] = out[j - 1]; 231 - j -= 1; 232 - } 233 - gens[j] = key_gen; 234 - out[j] = key_entry; 235 - } 236 - 237 - count 238 - } 239 - 240 - pub fn save(&mut self, filename: &[u8], byte_offset: u32, chapter: u16) { 241 - if !self.loaded { 242 - log::warn!("bookmarks: save called before load, ignoring"); 243 - return; 244 - } 245 - 246 - let key = fnv1a_icase(filename); 247 - 248 - let mut max_gen: u16 = 0; 249 - let mut target: Option<usize> = None; 250 - let mut first_free: Option<usize> = None; 251 - let mut lru_slot: usize = 0; 252 - let mut lru_gen: u16 = u16::MAX; 253 - 254 - for i in 0..self.count { 255 - let slot = &self.slots[i]; 256 - 257 - if !slot.valid { 258 - if first_free.is_none() { 259 - first_free = Some(i); 260 - } 261 - continue; 262 - } 263 - 264 - if slot.generation > max_gen { 265 - max_gen = slot.generation; 266 - } 267 - if slot.generation < lru_gen { 268 - lru_gen = slot.generation; 269 - lru_slot = i; 270 - } 271 - 272 - if slot.name_hash == key && slot.matches_name(filename) { 273 - target = Some(i); 274 - break; 275 - } 276 - } 277 - 278 - let write_slot = target.or(first_free).unwrap_or(if self.count >= SLOTS { 279 - lru_slot 280 - } else { 281 - self.count 282 - }); 283 - 284 - let generation = max_gen.wrapping_add(1); 285 - let name_len = filename.len().min(FILENAME_CAP); 286 - 287 - let mut new_slot = BookmarkSlot { 288 - name_hash: key, 289 - byte_offset, 290 - chapter, 291 - valid: true, 292 - generation, 293 - name_len: name_len as u8, 294 - filename: [0u8; FILENAME_CAP], 295 - }; 296 - new_slot.filename[..name_len].copy_from_slice(&filename[..name_len]); 297 - 298 - self.slots[write_slot] = new_slot; 299 - 300 - if write_slot >= self.count { 301 - self.count = write_slot + 1; 302 - } 303 - 304 - self.dirty = true; 305 - 306 - log::info!( 307 - "bookmark: cached off={} ch={} gen={} for {:?}", 308 - byte_offset, 309 - chapter, 310 - generation, 311 - core::str::from_utf8(filename).unwrap_or("?"), 312 - ); 313 - } 314 - 315 - pub fn flush<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 316 - if !self.dirty || !self.loaded { 317 - return; 318 - } 319 - 320 - let file_len = self.count * RECORD_LEN; 321 - let mut buf = [0u8; FILE_LEN]; 322 - 323 - for i in 0..self.count { 324 - let base = i * RECORD_LEN; 325 - let rec = self.slots[i].encode(); 326 - buf[base..base + RECORD_LEN].copy_from_slice(&rec); 327 - } 328 - 329 - match storage::write_pulp_file(sd, BOOKMARK_FILE, &buf[..file_len]) { 330 - Ok(_) => { 331 - self.dirty = false; 332 - log::info!("bookmarks: flushed {} slots to SD", self.count); 333 - } 334 - Err(e) => { 335 - log::warn!("bookmarks: flush failed: {}", e); 336 - } 337 - } 338 - } 339 - }
+167 -45
src/apps/files.rs
··· 1 - // Paginated file browser for SD card root directory. 1 + // paginated file browser for SD card root directory 2 + // background title scanner resolves EPUB titles from OPF metadata 2 3 4 + extern crate alloc; 5 + 6 + use alloc::vec::Vec; 3 7 use core::fmt::Write as _; 4 8 5 9 use embedded_graphics::pixelcolor::BinaryColor; 6 10 use embedded_graphics::prelude::*; 7 11 use embedded_graphics::primitives::PrimitiveStyle; 8 12 9 - use crate::apps::{App, AppContext, AppId, Services, Transition}; 13 + use crate::apps::{App, AppContext, AppId, Transition}; 10 14 use crate::board::action::{Action, ActionEvent}; 11 15 use crate::board::{SCREEN_H, SCREEN_W}; 12 16 use crate::drivers::storage::DirEntry; 13 17 use crate::drivers::strip::StripBuffer; 14 18 use crate::fonts; 15 - use crate::fonts::bitmap::BitmapFont; 19 + use crate::kernel::KernelHandle; 16 20 use crate::ui::{Alignment, BitmapDynLabel, BitmapLabel, CONTENT_TOP, Region}; 21 + use smol_epub::epub::{self, EpubMeta, EpubSpine}; 22 + use smol_epub::zip::ZipIndex; 17 23 18 24 const PAGE_SIZE: usize = 7; 19 25 ··· 41 47 needs_load: bool, 42 48 stale_cache: bool, 43 49 error: Option<&'static str>, 44 - body_font: &'static BitmapFont, 45 - heading_font: &'static BitmapFont, 50 + ui_fonts: fonts::UiFonts, 46 51 list_y: u16, 52 + 53 + title_scan_idx: usize, 54 + title_scanning: bool, 47 55 } 48 56 49 57 impl FilesApp { 50 58 pub fn new() -> Self { 51 - let hf = fonts::heading_font(0); 59 + let uf = fonts::UiFonts::for_size(0); 52 60 Self { 53 61 entries: [DirEntry::EMPTY; PAGE_SIZE], 54 62 count: 0, ··· 58 66 needs_load: false, 59 67 stale_cache: false, 60 68 error: None, 61 - body_font: fonts::body_font(0), 62 - heading_font: hf, 63 - list_y: CONTENT_TOP + 8 + hf.line_height + HEADER_LIST_GAP, 69 + ui_fonts: uf, 70 + list_y: CONTENT_TOP + 8 + uf.heading.line_height + HEADER_LIST_GAP, 71 + title_scan_idx: 0, 72 + title_scanning: false, 64 73 } 65 74 } 66 75 67 76 pub fn set_ui_font_size(&mut self, idx: u8) { 68 - self.body_font = fonts::body_font(idx); 69 - self.heading_font = fonts::heading_font(idx); 70 - self.list_y = CONTENT_TOP + 8 + self.heading_font.line_height + HEADER_LIST_GAP; 77 + self.ui_fonts = fonts::UiFonts::for_size(idx); 78 + self.list_y = CONTENT_TOP + 8 + self.ui_fonts.heading.line_height + HEADER_LIST_GAP; 71 79 } 72 80 73 81 fn selected_entry(&self) -> Option<&DirEntry> { ··· 168 176 } 169 177 } 170 178 171 - impl App for FilesApp { 172 - fn on_enter(&mut self, ctx: &mut AppContext) { 179 + impl App<AppId> for FilesApp { 180 + async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 173 181 self.scroll = 0; 174 182 self.selected = 0; 175 183 self.needs_load = true; 176 184 self.stale_cache = true; 177 185 self.error = None; 186 + self.title_scan_idx = 0; 187 + self.title_scanning = true; 178 188 ctx.mark_dirty(Region::new( 179 189 0, 180 190 CONTENT_TOP, ··· 185 195 186 196 fn on_exit(&mut self) { 187 197 self.count = 0; 198 + self.title_scanning = false; 188 199 } 189 200 190 201 fn on_suspend(&mut self) {} 191 202 192 - fn on_resume(&mut self, ctx: &mut AppContext) { 203 + async fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 193 204 ctx.mark_dirty(Region::new( 194 205 0, 195 206 CONTENT_TOP, ··· 198 209 )); 199 210 } 200 211 201 - fn needs_work(&self) -> bool { 202 - self.needs_load 203 - } 212 + async fn background(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) { 213 + if self.needs_load { 214 + if self.stale_cache { 215 + k.invalidate_dir_cache(); 216 + self.stale_cache = false; 217 + } 204 218 205 - fn on_work<SPI: embedded_hal::spi::SpiDevice>( 206 - &mut self, 207 - svc: &mut Services<'_, SPI>, 208 - ctx: &mut AppContext, 209 - ) { 210 - if self.stale_cache { 211 - svc.invalidate_dir_cache(); 212 - self.stale_cache = false; 219 + let mut buf = [DirEntry::EMPTY; PAGE_SIZE]; 220 + match k.sync_dir_page(self.scroll, &mut buf) { 221 + Ok(page) => { 222 + self.load_page(&buf[..page.count], page.total); 223 + } 224 + Err(e) => { 225 + log::info!("SD load failed: {}", e); 226 + self.load_failed(e); 227 + } 228 + } 229 + 230 + ctx.mark_dirty(self.list_region()); 231 + ctx.mark_dirty(STATUS_REGION); 232 + return; 213 233 } 214 234 215 - let mut buf = [DirEntry::EMPTY; PAGE_SIZE]; 216 - match svc.dir_page(self.scroll, &mut buf) { 217 - Ok(page) => { 218 - self.load_page(&buf[..page.count], page.total); 219 - } 220 - Err(e) => { 221 - log::info!("SD load failed: {}", e); 222 - self.load_failed(e); 235 + if self.title_scanning { 236 + if let Some(dirty) = scan_one_epub_title(k, self.title_scan_idx) { 237 + self.title_scan_idx = dirty.next_idx; 238 + if dirty.resolved { 239 + self.needs_load = true; 240 + } 241 + } else { 242 + self.title_scanning = false; 243 + log::info!("titles: scan complete"); 223 244 } 224 245 } 225 - 226 - ctx.mark_dirty(self.list_region()); 227 - ctx.mark_dirty(STATUS_REGION); 228 246 } 229 247 230 248 fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition { ··· 278 296 } 279 297 280 298 fn draw(&self, strip: &mut StripBuffer) { 281 - let header_region = 282 - Region::new(LIST_X, CONTENT_TOP + 8, 300, self.heading_font.line_height); 283 - BitmapLabel::new(header_region, "Files", self.heading_font) 299 + let header_region = Region::new( 300 + LIST_X, 301 + CONTENT_TOP + 8, 302 + 300, 303 + self.ui_fonts.heading.line_height, 304 + ); 305 + BitmapLabel::new(header_region, "Files", self.ui_fonts.heading) 284 306 .alignment(Alignment::CenterLeft) 285 307 .draw(strip) 286 308 .unwrap(); 287 309 288 310 if self.total > 0 { 289 - let mut status = BitmapDynLabel::<20>::new(STATUS_REGION, self.body_font) 311 + let mut status = BitmapDynLabel::<20>::new(STATUS_REGION, self.ui_fonts.body) 290 312 .alignment(Alignment::CenterRight); 291 313 let _ = write!(status, "{}/{}", self.scroll + self.selected + 1, self.total); 292 314 status.draw(strip).unwrap(); 293 315 } 294 316 295 317 if let Some(msg) = self.error { 296 - BitmapLabel::new(self.row_region(0), msg, self.body_font) 318 + BitmapLabel::new(self.row_region(0), msg, self.ui_fonts.body) 297 319 .alignment(Alignment::CenterLeft) 298 320 .draw(strip) 299 321 .unwrap(); ··· 301 323 } 302 324 303 325 if self.count == 0 && self.needs_load { 304 - BitmapLabel::new(self.row_region(0), "Loading...", self.body_font) 326 + BitmapLabel::new(self.row_region(0), "Loading...", self.ui_fonts.body) 305 327 .alignment(Alignment::CenterLeft) 306 328 .draw(strip) 307 329 .unwrap(); ··· 309 331 } 310 332 311 333 if self.count == 0 && !self.needs_load { 312 - BitmapLabel::new(self.row_region(0), "No files found", self.body_font) 334 + BitmapLabel::new(self.row_region(0), "No files found", self.ui_fonts.body) 313 335 .alignment(Alignment::CenterLeft) 314 336 .draw(strip) 315 337 .unwrap(); ··· 323 345 let entry = &self.entries[i]; 324 346 let name = entry.display_name(); 325 347 326 - BitmapLabel::new(region, name, self.body_font) 348 + BitmapLabel::new(region, name, self.ui_fonts.body) 327 349 .alignment(Alignment::CenterLeft) 328 350 .inverted(i == self.selected) 329 351 .draw(strip) ··· 338 360 } 339 361 } 340 362 } 363 + 364 + struct TitleScanResult { 365 + next_idx: usize, 366 + resolved: bool, 367 + } 368 + 369 + fn scan_one_epub_title(k: &mut KernelHandle<'_>, from: usize) -> Option<TitleScanResult> { 370 + let (idx, name_buf, name_len) = k.dir_cache_mut().next_untitled_epub(from)?; 371 + let name = core::str::from_utf8(&name_buf[..name_len as usize]).unwrap_or(""); 372 + let next_idx = idx + 1; 373 + 374 + log::info!("titles: scanning {} (idx {})", name, idx); 375 + 376 + let result = (|| -> Result<(), &'static str> { 377 + let file_size = k.sync_file_size(name)?; 378 + if file_size < 22 { 379 + return Err("too small"); 380 + } 381 + 382 + let tail_size = (file_size as usize).min(512); 383 + let tail_offset = file_size - tail_size as u32; 384 + let mut buf = [0u8; 512]; 385 + let n = k.sync_read_chunk(name, tail_offset, &mut buf[..tail_size])?; 386 + 387 + let (cd_offset, cd_size) = ZipIndex::parse_eocd(&buf[..n], file_size)?; 388 + 389 + let mut cd_buf = Vec::new(); 390 + cd_buf 391 + .try_reserve_exact(cd_size as usize) 392 + .map_err(|_| "CD too large")?; 393 + cd_buf.resize(cd_size as usize, 0); 394 + 395 + let mut total = 0usize; 396 + while total < cd_buf.len() { 397 + let rd = k.sync_read_chunk(name, cd_offset + total as u32, &mut cd_buf[total..])?; 398 + if rd == 0 { 399 + return Err("CD truncated"); 400 + } 401 + total += rd; 402 + } 403 + 404 + let mut zip = ZipIndex::new(); 405 + zip.parse_central_directory(&cd_buf)?; 406 + drop(cd_buf); 407 + 408 + let mut opf_path_buf = [0u8; epub::OPF_PATH_CAP]; 409 + let opf_path_len = if let Some(ci) = zip.find("META-INF/container.xml") { 410 + let container = smol_epub::zip::extract_entry( 411 + zip.entry(ci), 412 + zip.entry(ci).local_offset, 413 + |off, b| k.sync_read_chunk(name, off, b), 414 + )?; 415 + let len = epub::parse_container(&container, &mut opf_path_buf)?; 416 + drop(container); 417 + len 418 + } else { 419 + epub::find_opf_in_zip(&zip, &mut opf_path_buf)? 420 + }; 421 + 422 + let opf_path = 423 + core::str::from_utf8(&opf_path_buf[..opf_path_len]).map_err(|_| "bad OPF path")?; 424 + 425 + let opf_idx = zip 426 + .find(opf_path) 427 + .or_else(|| zip.find_icase(opf_path)) 428 + .ok_or("OPF not found")?; 429 + 430 + let opf_data = smol_epub::zip::extract_entry( 431 + zip.entry(opf_idx), 432 + zip.entry(opf_idx).local_offset, 433 + |off, b| k.sync_read_chunk(name, off, b), 434 + )?; 435 + 436 + let opf_dir = opf_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 437 + let mut meta = EpubMeta::new(); 438 + let mut spine = EpubSpine::new(); 439 + epub::parse_opf(&opf_data, opf_dir, &zip, &mut meta, &mut spine)?; 440 + drop(opf_data); 441 + 442 + let title = meta.title_str(); 443 + if title.is_empty() { 444 + return Err("no title in OPF"); 445 + } 446 + 447 + log::info!("titles: {} -> \"{}\"", name, title); 448 + let _ = k.sync_save_title(name, title); 449 + k.dir_cache_mut().set_entry_title(idx, title.as_bytes()); 450 + 451 + Ok(()) 452 + })(); 453 + 454 + if let Err(e) = result { 455 + log::warn!("titles: {} failed: {}", name, e); 456 + } 457 + 458 + Some(TitleScanResult { 459 + next_idx, 460 + resolved: result.is_ok(), 461 + }) 462 + }
+35 -45
src/apps/home.rs
··· 1 - // Launcher screen: menu, bookmarks browser. 1 + // launcher screen: menu, bookmarks browser 2 2 3 3 use core::fmt::Write as _; 4 4 ··· 6 6 use embedded_graphics::prelude::*; 7 7 use embedded_graphics::primitives::PrimitiveStyle; 8 8 9 - use crate::apps::bookmarks::{self, BmListEntry}; 10 - use crate::apps::reader::RECENT_FILE; 11 - use crate::apps::{App, AppContext, AppId, Services, Transition}; 9 + use crate::apps::{App, AppContext, AppId, RECENT_FILE, Transition}; 12 10 use crate::board::action::{Action, ActionEvent}; 13 11 use crate::board::{SCREEN_H, SCREEN_W}; 14 12 use crate::drivers::strip::StripBuffer; 15 13 use crate::fonts; 16 - use crate::fonts::bitmap::BitmapFont; 17 14 use crate::fonts::bitmap::byte_to_char; 15 + use crate::kernel::KernelHandle; 16 + use crate::kernel::bookmarks::{self, BmListEntry}; 18 17 use crate::ui::{Alignment, BUTTON_BAR_H, BitmapDynLabel, BitmapLabel, CONTENT_TOP, Region}; 19 18 20 19 const ITEM_W: u16 = 280; ··· 55 54 pub struct HomeApp { 56 55 state: HomeState, 57 56 selected: usize, 58 - body_font: &'static BitmapFont, 59 - heading_font: &'static BitmapFont, 57 + ui_fonts: fonts::UiFonts, 60 58 item_regions: [Region; MAX_ITEMS], 61 59 item_count: usize, 62 60 ··· 79 77 80 78 impl HomeApp { 81 79 pub fn new() -> Self { 82 - let hf = fonts::heading_font(0); 80 + let uf = fonts::UiFonts::for_size(0); 83 81 Self { 84 82 state: HomeState::Menu, 85 83 selected: 0, 86 - body_font: fonts::body_font(0), 87 - heading_font: hf, 88 - item_regions: compute_item_regions(hf.line_height), 84 + ui_fonts: uf, 85 + item_regions: compute_item_regions(uf.heading.line_height), 89 86 item_count: 4, // updated after load; may include Continue 90 87 recent_book: [0u8; 32], 91 88 recent_book_len: 0, ··· 99 96 } 100 97 101 98 pub fn set_ui_font_size(&mut self, idx: u8) { 102 - self.body_font = fonts::body_font(idx); 103 - self.heading_font = fonts::heading_font(idx); 104 - self.item_regions = compute_item_regions(self.heading_font.line_height); 99 + self.ui_fonts = fonts::UiFonts::for_size(idx); 100 + self.item_regions = compute_item_regions(self.ui_fonts.heading.line_height); 105 101 } 106 102 107 - pub fn load_recent<SPI: embedded_hal::spi::SpiDevice>( 108 - &mut self, 109 - services: &mut Services<'_, SPI>, 110 - ) { 103 + pub fn load_recent(&mut self, k: &mut KernelHandle<'_>) { 111 104 let mut buf = [0u8; 32]; 112 - match services.read_pulp_start(RECENT_FILE, &mut buf) { 105 + match k.sync_read_app_data_start(RECENT_FILE, &mut buf) { 113 106 Ok((_, n)) if n > 0 => { 114 107 let n = n.min(32); 115 108 self.recent_book[..n].copy_from_slice(&buf[..n]); ··· 186 179 } 187 180 188 181 fn bm_text_y(&self) -> u16 { 189 - CONTENT_TOP + 4 + self.heading_font.line_height + BM_HEADER_GAP 182 + CONTENT_TOP + 4 + self.ui_fonts.heading.line_height + BM_HEADER_GAP 190 183 } 191 184 192 185 fn bm_visible_lines(&self) -> usize { 193 186 let area_h = BM_BOTTOM.saturating_sub(self.bm_text_y()); 194 - (area_h / self.body_font.line_height).max(1) as usize 187 + (area_h / self.ui_fonts.body.line_height).max(1) as usize 195 188 } 196 189 197 190 fn bm_page_region(&self) -> Region { ··· 199 192 } 200 193 } 201 194 202 - impl App for HomeApp { 203 - fn on_enter(&mut self, ctx: &mut AppContext) { 195 + impl App<AppId> for HomeApp { 196 + async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 204 197 ctx.clear_message(); 205 198 self.state = HomeState::Menu; 206 199 self.selected = 0; ··· 212 205 )); 213 206 } 214 207 215 - fn on_resume(&mut self, ctx: &mut AppContext) { 208 + async fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 216 209 self.state = HomeState::Menu; 217 210 self.selected = 0; 218 211 self.needs_load_recent = true; ··· 224 217 )); 225 218 } 226 219 227 - fn needs_work(&self) -> bool { 228 - self.needs_load_recent || self.needs_load_bookmarks 229 - } 230 - 231 - fn on_work<SPI: embedded_hal::spi::SpiDevice>( 232 - &mut self, 233 - svc: &mut Services<'_, SPI>, 234 - ctx: &mut AppContext, 235 - ) { 220 + async fn background(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) { 236 221 if self.needs_load_recent { 237 222 let old_count = self.item_count; 238 223 let mut buf = [0u8; 32]; 239 - match svc.read_pulp_start(RECENT_FILE, &mut buf) { 224 + match k.sync_read_app_data_start(RECENT_FILE, &mut buf) { 240 225 Ok((_, n)) if n > 0 => { 241 226 let n = n.min(32); 242 227 self.recent_book[..n].copy_from_slice(&buf[..n]); ··· 254 239 } 255 240 256 241 if self.needs_load_bookmarks { 257 - self.bm_count = svc.bookmarks().load_all(&mut self.bm_entries); 242 + self.bm_count = k.bookmark_cache().load_all(&mut self.bm_entries); 258 243 self.needs_load_bookmarks = false; 259 244 if self.state == HomeState::ShowBookmarks { 260 245 ctx.mark_dirty(self.bm_page_region()); ··· 397 382 ITEM_X, 398 383 CONTENT_TOP + 8, 399 384 ITEM_W, 400 - self.heading_font.line_height, 385 + self.ui_fonts.heading.line_height, 401 386 ); 402 - BitmapLabel::new(title_region, "pulp-os", self.heading_font) 387 + BitmapLabel::new(title_region, "pulp-os", self.ui_fonts.heading) 403 388 .alignment(Alignment::Center) 404 389 .draw(strip) 405 390 .unwrap(); 406 391 407 392 for i in 0..self.item_count { 408 393 let label = self.item_label(i); 409 - BitmapLabel::new(self.item_regions[i], label, self.body_font) 394 + BitmapLabel::new(self.item_regions[i], label, self.ui_fonts.body) 410 395 .alignment(Alignment::Center) 411 396 .inverted(i == self.selected) 412 397 .draw(strip) ··· 419 404 BM_MARGIN, 420 405 CONTENT_TOP + 4, 421 406 SCREEN_W - BM_MARGIN * 2, 422 - self.heading_font.line_height, 407 + self.ui_fonts.heading.line_height, 423 408 ); 424 - BitmapLabel::new(header_region, "Bookmarks", self.heading_font) 409 + BitmapLabel::new(header_region, "Bookmarks", self.ui_fonts.heading) 425 410 .alignment(Alignment::CenterLeft) 426 411 .draw(strip) 427 412 .unwrap(); ··· 431 416 SCREEN_W / 2, 432 417 CONTENT_TOP + 4, 433 418 SCREEN_W / 2 - BM_MARGIN, 434 - self.heading_font.line_height, 419 + self.ui_fonts.heading.line_height, 435 420 ); 436 - let mut status = BitmapDynLabel::<20>::new(status_region, self.body_font) 421 + let mut status = BitmapDynLabel::<20>::new(status_region, self.ui_fonts.body) 437 422 .alignment(Alignment::CenterRight); 438 423 let _ = write!(status, "{}/{}", self.bm_selected + 1, self.bm_count); 439 424 status.draw(strip).unwrap(); 440 425 } 441 426 442 427 if self.bm_count == 0 { 443 - let r = Region::new(BM_MARGIN, self.bm_text_y(), 300, self.body_font.line_height); 444 - BitmapLabel::new(r, "No bookmarks saved", self.body_font) 428 + let r = Region::new( 429 + BM_MARGIN, 430 + self.bm_text_y(), 431 + 300, 432 + self.ui_fonts.body.line_height, 433 + ); 434 + BitmapLabel::new(r, "No bookmarks saved", self.ui_fonts.body) 445 435 .alignment(Alignment::CenterLeft) 446 436 .draw(strip) 447 437 .unwrap(); 448 438 return; 449 439 } 450 440 451 - let font = self.body_font; 441 + let font = self.ui_fonts.body; 452 442 let line_h = font.line_height as i32; 453 443 let ascent = font.ascent as i32; 454 444 let text_y = self.bm_text_y() as i32;
+487
src/apps/manager.rs
··· 1 + // app lifecycle manager: nav stack, dispatch, font propagation, draw 2 + // 3 + // all dispatch is static (monomorphized via with_app!); no dyn, no vtable 4 + 5 + use crate::apps::files::FilesApp; 6 + use crate::apps::home::HomeApp; 7 + use crate::apps::reader::ReaderApp; 8 + use crate::apps::settings::SettingsApp; 9 + use crate::apps::{App, AppContext, AppId, Launcher, PendingSetting, Redraw, Transition}; 10 + use esp_hal::delay::Delay; 11 + 12 + use crate::apps::widgets::quick_menu::{MAX_APP_ACTIONS, QuickMenuResult}; 13 + use crate::apps::widgets::{ButtonFeedback, QuickMenu}; 14 + use crate::board::action::{Action, ActionEvent, ButtonMapper}; 15 + use crate::board::{Epd, SCREEN_H, SCREEN_W}; 16 + use crate::drivers::input::Event; 17 + use crate::drivers::sdcard::SdStorage; 18 + use crate::drivers::strip::StripBuffer; 19 + use crate::fonts; 20 + use crate::kernel::KernelHandle; 21 + use crate::kernel::app::AppLayer; 22 + use crate::kernel::bookmarks::BookmarkCache; 23 + use crate::kernel::config::{SystemSettings, WifiConfig}; 24 + use crate::ui::Region; 25 + 26 + // monomorphized dispatch from AppId to concrete app type 27 + macro_rules! with_app { 28 + ($id:expr, $mgr:expr, |$app:ident| $body:expr) => { 29 + match $id { 30 + AppId::Home => { 31 + let $app = &mut *$mgr.home; 32 + $body 33 + } 34 + AppId::Files => { 35 + let $app = &mut *$mgr.files; 36 + $body 37 + } 38 + AppId::Reader => { 39 + let $app = &mut *$mgr.reader; 40 + $body 41 + } 42 + AppId::Settings => { 43 + let $app = &mut *$mgr.settings; 44 + $body 45 + } 46 + AppId::Upload => { 47 + unreachable!("Upload mode is handled outside the app dispatch loop"); 48 + } 49 + } 50 + }; 51 + } 52 + 53 + // shared-ref variant for read-only dispatch (draw, quick_actions) 54 + macro_rules! with_app_ref { 55 + ($id:expr, $mgr:expr, |$app:ident| $body:expr) => { 56 + match $id { 57 + AppId::Home => { 58 + let $app = &*$mgr.home; 59 + $body 60 + } 61 + AppId::Files => { 62 + let $app = &*$mgr.files; 63 + $body 64 + } 65 + AppId::Reader => { 66 + let $app = &*$mgr.reader; 67 + $body 68 + } 69 + AppId::Settings => { 70 + let $app = &*$mgr.settings; 71 + $body 72 + } 73 + AppId::Upload => { 74 + unreachable!("Upload mode is handled outside the app dispatch loop"); 75 + } 76 + } 77 + }; 78 + } 79 + 80 + #[allow(unused_imports)] 81 + pub(crate) use with_app; 82 + #[allow(unused_imports)] 83 + pub(crate) use with_app_ref; 84 + 85 + pub struct AppManager { 86 + pub launcher: &'static mut Launcher, 87 + 88 + pub home: &'static mut HomeApp, 89 + pub files: &'static mut FilesApp, 90 + pub reader: &'static mut ReaderApp, 91 + pub settings: &'static mut SettingsApp, 92 + 93 + pub quick_menu: &'static mut QuickMenu, 94 + pub bumps: &'static mut ButtonFeedback, 95 + 96 + pub mapper: ButtonMapper, 97 + } 98 + 99 + impl AppManager { 100 + #[allow(clippy::too_many_arguments)] 101 + pub fn new( 102 + launcher: &'static mut Launcher, 103 + home: &'static mut HomeApp, 104 + files: &'static mut FilesApp, 105 + reader: &'static mut ReaderApp, 106 + settings: &'static mut SettingsApp, 107 + quick_menu: &'static mut QuickMenu, 108 + bumps: &'static mut ButtonFeedback, 109 + mapper: ButtonMapper, 110 + ) -> Self { 111 + Self { 112 + launcher, 113 + home, 114 + files, 115 + reader, 116 + settings, 117 + quick_menu, 118 + bumps, 119 + mapper, 120 + } 121 + } 122 + 123 + #[inline] 124 + pub fn active(&self) -> AppId { 125 + self.launcher.active() 126 + } 127 + 128 + #[inline] 129 + pub fn ctx(&self) -> &AppContext { 130 + &self.launcher.ctx 131 + } 132 + 133 + #[inline] 134 + pub fn ctx_mut(&mut self) -> &mut AppContext { 135 + &mut self.launcher.ctx 136 + } 137 + 138 + #[inline] 139 + pub fn has_redraw(&self) -> bool { 140 + self.launcher.ctx.has_redraw() 141 + } 142 + 143 + #[inline] 144 + pub fn take_redraw(&mut self) -> Redraw { 145 + self.launcher.ctx.take_redraw() 146 + } 147 + 148 + #[inline] 149 + pub fn request_full_redraw(&mut self) { 150 + self.launcher.ctx.request_full_redraw(); 151 + } 152 + 153 + #[inline] 154 + pub fn apply_nav(&mut self, transition: Transition) -> Option<crate::apps::NavEvent> { 155 + self.launcher.apply(transition) 156 + } 157 + 158 + pub fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>) { 159 + self.settings.load_eager(k); 160 + self.propagate_fonts(); 161 + } 162 + 163 + pub fn load_home_recent(&mut self, k: &mut KernelHandle<'_>) { 164 + self.home.load_recent(k); 165 + } 166 + 167 + pub async fn enter_initial(&mut self, k: &mut KernelHandle<'_>) { 168 + self.home.on_enter(&mut self.launcher.ctx, k).await; 169 + } 170 + 171 + // power-button long-press must be intercepted by the scheduler 172 + // before calling this method 173 + pub fn dispatch_event(&mut self, hw_event: Event, bm_cache: &mut BookmarkCache) -> Transition { 174 + let event = self.mapper.map_event(hw_event); 175 + 176 + if self.quick_menu.open { 177 + return self.handle_quick_menu(event, bm_cache); 178 + } 179 + 180 + if matches!(event, ActionEvent::Press(Action::Menu)) { 181 + let active = self.launcher.active(); 182 + let actions: &[_] = with_app!(active, self, |app| app.quick_actions()); 183 + self.quick_menu.show(actions); 184 + self.launcher.ctx.mark_dirty(self.quick_menu.region()); 185 + return Transition::None; 186 + } 187 + 188 + let active = self.launcher.active(); 189 + with_app!(active, self, |app| { 190 + app.on_event(event, &mut self.launcher.ctx) 191 + }) 192 + } 193 + 194 + fn handle_quick_menu( 195 + &mut self, 196 + event: ActionEvent, 197 + bm_cache: &mut BookmarkCache, 198 + ) -> Transition { 199 + let action = match event { 200 + ActionEvent::Press(a) | ActionEvent::Repeat(a) => a, 201 + _ => return Transition::None, 202 + }; 203 + 204 + let result = self.quick_menu.on_action(action); 205 + 206 + match result { 207 + QuickMenuResult::Consumed => { 208 + if self.quick_menu.dirty { 209 + self.launcher.ctx.mark_dirty(self.quick_menu.region()); 210 + self.quick_menu.dirty = false; 211 + } 212 + Transition::None 213 + } 214 + 215 + QuickMenuResult::Close => { 216 + let region = self.quick_menu.region(); 217 + self.sync_quick_menu(); 218 + self.launcher.ctx.mark_dirty(region); 219 + Transition::None 220 + } 221 + 222 + QuickMenuResult::RefreshScreen => { 223 + self.sync_quick_menu(); 224 + self.launcher.ctx.request_full_redraw(); 225 + Transition::None 226 + } 227 + 228 + QuickMenuResult::GoHome => { 229 + self.sync_quick_menu(); 230 + Transition::Home 231 + } 232 + 233 + QuickMenuResult::AppTrigger(id) => { 234 + let active = self.launcher.active(); 235 + let region = self.quick_menu.region(); 236 + self.sync_quick_menu(); 237 + 238 + with_app!(active, self, |app| { 239 + app.on_quick_trigger(id, &mut self.launcher.ctx); 240 + // Save app state after trigger (e.g. font change 241 + // may invalidate the reader's current page offset). 242 + app.save_state(bm_cache); 243 + }); 244 + 245 + self.launcher.ctx.mark_dirty(region); 246 + Transition::None 247 + } 248 + } 249 + } 250 + 251 + pub async fn apply_transition(&mut self, transition: Transition, k: &mut KernelHandle<'_>) { 252 + if let Some(nav) = self.launcher.apply(transition) { 253 + log::info!("app: {:?} -> {:?}", nav.from, nav.to); 254 + 255 + if nav.from != AppId::Upload { 256 + with_app!(nav.from, self, |app| { 257 + app.save_state(k.bookmark_cache_mut()); 258 + if nav.suspend { 259 + app.on_suspend(); 260 + } else { 261 + app.on_exit(); 262 + } 263 + }); 264 + } 265 + 266 + self.propagate_fonts(); 267 + 268 + if nav.to != AppId::Upload { 269 + if nav.resume { 270 + with_app!(nav.to, self, |app| { 271 + app.on_resume(&mut self.launcher.ctx, k).await 272 + }); 273 + } else { 274 + with_app!(nav.to, self, |app| { 275 + app.on_enter(&mut self.launcher.ctx, k).await 276 + }); 277 + } 278 + } 279 + 280 + if nav.resume { 281 + self.launcher 282 + .ctx 283 + .mark_dirty(Region::new(0, 0, SCREEN_W, SCREEN_H)); 284 + } else { 285 + self.launcher.ctx.request_full_redraw(); 286 + } 287 + } 288 + } 289 + 290 + pub async fn run_background(&mut self, k: &mut KernelHandle<'_>) { 291 + let active = self.launcher.active(); 292 + with_app!(active, self, |app| { 293 + app.background(&mut self.launcher.ctx, k).await 294 + }); 295 + 296 + for &id in &[AppId::Home, AppId::Files, AppId::Reader, AppId::Settings] { 297 + if id != active { 298 + with_app!(id, self, |app| { 299 + if app.has_background_when_suspended() { 300 + app.background_suspended(k); 301 + } 302 + }); 303 + } 304 + } 305 + } 306 + 307 + pub fn draw(&self, strip: &mut StripBuffer) { 308 + let active = self.launcher.active(); 309 + with_app_ref!(active, self, |app| app.draw(strip)); 310 + 311 + if self.quick_menu.open { 312 + self.quick_menu.draw(strip); 313 + } 314 + 315 + self.bumps.draw(strip); 316 + } 317 + 318 + pub fn propagate_fonts(&mut self) { 319 + let ui_idx = self.settings.system_settings().ui_font_size_idx; 320 + let book_idx = self.settings.system_settings().book_font_size_idx; 321 + 322 + self.home.set_ui_font_size(ui_idx); 323 + self.files.set_ui_font_size(ui_idx); 324 + self.settings.set_ui_font_size(ui_idx); 325 + self.reader.set_book_font_size(book_idx); 326 + 327 + let chrome = fonts::chrome_font(); 328 + self.reader.set_chrome_font(chrome); 329 + self.quick_menu.set_chrome_font(chrome); 330 + self.bumps.set_chrome_font(chrome); 331 + } 332 + 333 + fn sync_quick_menu(&mut self) { 334 + let active = self.launcher.active(); 335 + 336 + for id in 0..MAX_APP_ACTIONS as u8 { 337 + if let Some(value) = self.quick_menu.app_cycle_value(id) { 338 + with_app!(active, self, |app| { 339 + app.on_quick_cycle_update(id, value, &mut self.launcher.ctx); 340 + }); 341 + } 342 + } 343 + 344 + let pending = with_app!(active, self, |app| app.pending_setting()); 345 + if let Some(setting) = pending { 346 + match setting { 347 + PendingSetting::BookFontSize(idx) => { 348 + let ss = self.settings.system_settings_mut(); 349 + if ss.book_font_size_idx != idx { 350 + ss.book_font_size_idx = idx; 351 + self.settings.mark_save_needed(); 352 + } 353 + } 354 + } 355 + } 356 + } 357 + 358 + #[inline] 359 + pub fn system_settings(&self) -> &crate::kernel::config::SystemSettings { 360 + self.settings.system_settings() 361 + } 362 + 363 + #[inline] 364 + pub fn settings_loaded(&self) -> bool { 365 + self.settings.is_loaded() 366 + } 367 + 368 + #[inline] 369 + pub fn wifi_config(&self) -> &crate::kernel::config::WifiConfig { 370 + self.settings.wifi_config() 371 + } 372 + 373 + pub fn ghost_clear_every(&self) -> u32 { 374 + if self.settings.is_loaded() { 375 + self.settings.system_settings().ghost_clear_every as u32 376 + } else { 377 + crate::kernel::DEFAULT_GHOST_CLEAR_EVERY 378 + } 379 + } 380 + } 381 + 382 + impl AppLayer for AppManager { 383 + type Id = AppId; 384 + 385 + #[inline] 386 + fn active(&self) -> AppId { 387 + self.launcher.active() 388 + } 389 + 390 + fn dispatch_event(&mut self, event: Event, bm: &mut BookmarkCache) -> Transition { 391 + AppManager::dispatch_event(self, event, bm) 392 + } 393 + 394 + async fn apply_transition(&mut self, t: Transition, k: &mut KernelHandle<'_>) { 395 + AppManager::apply_transition(self, t, k).await; 396 + } 397 + 398 + async fn run_background(&mut self, k: &mut KernelHandle<'_>) { 399 + AppManager::run_background(self, k).await; 400 + } 401 + 402 + fn draw(&self, strip: &mut StripBuffer) { 403 + AppManager::draw(self, strip); 404 + } 405 + 406 + #[inline] 407 + fn has_redraw(&self) -> bool { 408 + self.launcher.ctx.has_redraw() 409 + } 410 + 411 + #[inline] 412 + fn take_redraw(&mut self) -> Redraw { 413 + self.launcher.ctx.take_redraw() 414 + } 415 + 416 + #[inline] 417 + fn request_full_redraw(&mut self) { 418 + self.launcher.ctx.request_full_redraw(); 419 + } 420 + 421 + #[inline] 422 + fn ctx_mut(&mut self) -> &mut AppContext { 423 + &mut self.launcher.ctx 424 + } 425 + 426 + fn system_settings(&self) -> &SystemSettings { 427 + self.settings.system_settings() 428 + } 429 + 430 + fn settings_loaded(&self) -> bool { 431 + self.settings.is_loaded() 432 + } 433 + 434 + fn ghost_clear_every(&self) -> u32 { 435 + AppManager::ghost_clear_every(self) 436 + } 437 + 438 + fn wifi_config(&self) -> &WifiConfig { 439 + self.settings.wifi_config() 440 + } 441 + 442 + fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>) { 443 + AppManager::load_eager_settings(self, k); 444 + } 445 + 446 + fn load_initial_state(&mut self, k: &mut KernelHandle<'_>) { 447 + AppManager::load_home_recent(self, k); 448 + } 449 + 450 + async fn enter_initial(&mut self, k: &mut KernelHandle<'_>) { 451 + AppManager::enter_initial(self, k).await; 452 + } 453 + 454 + fn needs_special_mode(&self) -> bool { 455 + self.launcher.active() == AppId::Upload 456 + } 457 + 458 + async fn run_special_mode( 459 + &mut self, 460 + epd: &mut Epd, 461 + strip: &mut StripBuffer, 462 + delay: &mut Delay, 463 + sd: &SdStorage, 464 + ) { 465 + // Safety: WIFI is not owned by any other driver. Upload mode 466 + // runs in isolation (the scheduler exits the main dispatch loop 467 + // first) and tears down the radio stack before returning. The 468 + // peripheral is not accessed again until the next upload session. 469 + let wifi = unsafe { esp_hal::peripherals::WIFI::steal() }; 470 + 471 + crate::apps::upload::run_upload_mode( 472 + wifi, 473 + epd, 474 + strip, 475 + delay, 476 + sd, 477 + self.settings.system_settings().ui_font_size_idx, 478 + &*self.bumps, 479 + self.settings.wifi_config(), 480 + ) 481 + .await; 482 + } 483 + 484 + fn suppress_deferred_input(&self) -> bool { 485 + self.quick_menu.open 486 + } 487 + }
+16 -348
src/apps/mod.rs
··· 1 - // App trait, nav stack, and Services syscall boundary. 1 + // app modules, AppId definition, and re-exports from kernel::app 2 + // 3 + // AppId is defined here (the distro side) -- the kernel is generic 4 + // over AppIdType and never knows which concrete apps exist. 2 5 3 - pub mod bookmarks; 4 6 pub mod files; 5 7 pub mod home; 8 + pub mod manager; 6 9 pub mod reader; 10 + pub mod widgets; 11 + 7 12 pub mod settings; 8 13 pub mod upload; 9 14 10 - use crate::board::action::ActionEvent; 11 - use crate::drivers::sdcard::SdStorage; 12 - use crate::drivers::storage::{self, DirCache, DirEntry, DirPage}; 13 - use crate::drivers::strip::StripBuffer; 14 - use crate::ui::Region; 15 - use crate::ui::quick_menu::QuickAction; 16 - 17 - pub use bookmarks::BookmarkCache; 15 + use crate::kernel::app::AppIdType; 18 16 19 17 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 20 18 pub enum AppId { ··· 22 20 Files, 23 21 Reader, 24 22 Settings, 23 + // upload bypasses the App trait; AppManager::needs_special_mode 24 + // returns true for this variant and run_special_mode handles it 25 25 Upload, 26 26 } 27 27 28 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 29 - pub enum Transition { 30 - None, 31 - Push(AppId), 32 - Pop, 33 - Replace(AppId), 34 - Home, 35 - } 36 - 37 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 38 - pub enum Redraw { 39 - None, 40 - Partial(Region), 41 - Full, 42 - } 43 - 44 - const MSG_BUF_SIZE: usize = 64; 45 - 46 - pub struct AppContext { 47 - msg_buf: [u8; MSG_BUF_SIZE], 48 - msg_len: usize, 49 - redraw: Redraw, 50 - } 51 - 52 - impl Default for AppContext { 53 - fn default() -> Self { 54 - Self::new() 55 - } 56 - } 57 - 58 - impl AppContext { 59 - pub const fn new() -> Self { 60 - Self { 61 - msg_buf: [0u8; MSG_BUF_SIZE], 62 - msg_len: 0, 63 - redraw: Redraw::None, 64 - } 65 - } 66 - 67 - pub fn set_message(&mut self, data: &[u8]) { 68 - let len = data.len().min(MSG_BUF_SIZE); 69 - self.msg_buf[..len].copy_from_slice(&data[..len]); 70 - self.msg_len = len; 71 - } 72 - 73 - pub fn message(&self) -> &[u8] { 74 - &self.msg_buf[..self.msg_len] 75 - } 76 - 77 - pub fn message_str(&self) -> &str { 78 - core::str::from_utf8(self.message()).unwrap_or("") 79 - } 80 - 81 - pub fn clear_message(&mut self) { 82 - self.msg_len = 0; 83 - } 84 - 85 - pub fn request_full_redraw(&mut self) { 86 - self.redraw = Redraw::Full; 87 - } 88 - 89 - pub fn request_partial_redraw(&mut self, region: Region) { 90 - match self.redraw { 91 - Redraw::Full => {} 92 - Redraw::Partial(existing) => { 93 - self.redraw = Redraw::Partial(existing.union(region)); 94 - } 95 - Redraw::None => self.redraw = Redraw::Partial(region), 96 - } 97 - } 98 - 99 - #[inline] 100 - pub fn mark_dirty(&mut self, region: Region) { 101 - self.request_partial_redraw(region); 102 - } 103 - 104 - pub fn has_redraw(&self) -> bool { 105 - !matches!(self.redraw, Redraw::None) 106 - } 107 - 108 - pub fn take_redraw(&mut self) -> Redraw { 109 - let r = self.redraw; 110 - self.redraw = Redraw::None; 111 - r 112 - } 113 - } 114 - 115 - pub struct Services<'a, SPI: embedded_hal::spi::SpiDevice> { 116 - dir_cache: &'a mut DirCache, 117 - bookmarks: &'a mut BookmarkCache, 118 - sd: &'a SdStorage<SPI>, 119 - } 120 - 121 - impl<'a, SPI: embedded_hal::spi::SpiDevice> Services<'a, SPI> { 122 - pub fn new( 123 - dir_cache: &'a mut DirCache, 124 - bookmarks: &'a mut BookmarkCache, 125 - sd: &'a SdStorage<SPI>, 126 - ) -> Self { 127 - Self { 128 - dir_cache, 129 - bookmarks, 130 - sd, 131 - } 132 - } 133 - 134 - pub fn bookmarks(&self) -> &BookmarkCache { 135 - self.bookmarks 136 - } 137 - 138 - pub fn bookmarks_mut(&mut self) -> &mut BookmarkCache { 139 - self.bookmarks 140 - } 141 - 142 - pub fn dir_page( 143 - &mut self, 144 - offset: usize, 145 - buf: &mut [DirEntry], 146 - ) -> Result<DirPage, &'static str> { 147 - self.dir_cache.ensure_loaded(self.sd)?; 148 - Ok(self.dir_cache.page(offset, buf)) 149 - } 150 - 151 - pub fn invalidate_dir_cache(&mut self) { 152 - self.dir_cache.invalidate(); 153 - } 154 - 155 - pub fn read_file_chunk( 156 - &self, 157 - name: &str, 158 - offset: u32, 159 - buf: &mut [u8], 160 - ) -> Result<usize, &'static str> { 161 - storage::read_file_chunk(self.sd, name, offset, buf) 162 - } 163 - 164 - pub fn read_file_start( 165 - &self, 166 - name: &str, 167 - buf: &mut [u8], 168 - ) -> Result<(u32, usize), &'static str> { 169 - storage::read_file_start(self.sd, name, buf) 170 - } 171 - 172 - pub fn file_size(&self, name: &str) -> Result<u32, &'static str> { 173 - storage::file_size(self.sd, name) 174 - } 175 - 176 - pub fn save_title(&self, filename: &str, title: &str) -> Result<(), &'static str> { 177 - storage::save_title(self.sd, filename, title) 178 - } 179 - 180 - pub fn read_chunk_in_dir( 181 - &self, 182 - dir: &str, 183 - name: &str, 184 - offset: u32, 185 - buf: &mut [u8], 186 - ) -> Result<usize, &'static str> { 187 - storage::read_file_chunk_in_dir(self.sd, dir, name, offset, buf) 188 - } 189 - 190 - pub fn ensure_pulp_dir(&self) -> Result<(), &'static str> { 191 - storage::ensure_pulp_dir(self.sd) 192 - } 193 - 194 - pub fn read_pulp_start( 195 - &self, 196 - name: &str, 197 - buf: &mut [u8], 198 - ) -> Result<(u32, usize), &'static str> { 199 - storage::read_pulp_file_start(self.sd, name, buf) 200 - } 201 - 202 - pub fn read_pulp_chunk( 203 - &self, 204 - name: &str, 205 - offset: u32, 206 - buf: &mut [u8], 207 - ) -> Result<usize, &'static str> { 208 - storage::read_pulp_file_chunk(self.sd, name, offset, buf) 209 - } 210 - 211 - pub fn write_pulp(&self, name: &str, data: &[u8]) -> Result<(), &'static str> { 212 - storage::write_pulp_file(self.sd, name, data) 213 - } 214 - 215 - pub fn ensure_pulp_subdir(&self, name: &str) -> Result<(), &'static str> { 216 - storage::ensure_pulp_subdir(self.sd, name) 217 - } 218 - 219 - pub fn write_pulp_sub(&self, dir: &str, name: &str, data: &[u8]) -> Result<(), &'static str> { 220 - storage::write_in_pulp_subdir(self.sd, dir, name, data) 221 - } 222 - 223 - pub fn append_pulp_sub(&self, dir: &str, name: &str, data: &[u8]) -> Result<(), &'static str> { 224 - storage::append_in_pulp_subdir(self.sd, dir, name, data) 225 - } 226 - 227 - pub fn read_pulp_sub_chunk( 228 - &self, 229 - dir: &str, 230 - name: &str, 231 - offset: u32, 232 - buf: &mut [u8], 233 - ) -> Result<usize, &'static str> { 234 - storage::read_chunk_in_pulp_subdir(self.sd, dir, name, offset, buf) 235 - } 236 - 237 - pub fn file_size_pulp_sub(&self, dir: &str, name: &str) -> Result<u32, &'static str> { 238 - storage::file_size_in_pulp_subdir(self.sd, dir, name) 239 - } 240 - 241 - pub fn delete_pulp_sub(&self, dir: &str, name: &str) -> Result<(), &'static str> { 242 - storage::delete_in_pulp_subdir(self.sd, dir, name) 243 - } 244 - } 245 - 246 - pub trait App { 247 - fn on_enter(&mut self, ctx: &mut AppContext); 248 - fn on_exit(&mut self) {} 249 - fn on_suspend(&mut self) { 250 - self.on_exit(); 251 - } 252 - fn on_resume(&mut self, ctx: &mut AppContext) { 253 - self.on_enter(ctx); 254 - } 255 - fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition; 256 - 257 - fn quick_actions(&self) -> &[QuickAction] { 258 - &[] 259 - } 260 - 261 - fn on_quick_trigger(&mut self, _id: u8, _ctx: &mut AppContext) {} 262 - 263 - fn on_quick_cycle_update(&mut self, _id: u8, _value: u8, _ctx: &mut AppContext) {} 264 - 265 - fn draw(&self, strip: &mut StripBuffer); 266 - 267 - fn needs_work(&self) -> bool { 268 - false 269 - } 270 - fn on_work<SPI: embedded_hal::spi::SpiDevice>( 271 - &mut self, 272 - _services: &mut Services<'_, SPI>, 273 - _ctx: &mut AppContext, 274 - ) { 275 - } 276 - } 277 - 278 - const MAX_STACK_DEPTH: usize = 4; 279 - 280 - #[derive(Debug, Clone, Copy)] 281 - pub struct NavEvent { 282 - pub from: AppId, 283 - pub to: AppId, 284 - pub suspend: bool, 285 - pub resume: bool, 28 + impl AppIdType for AppId { 29 + const HOME: Self = Self::Home; 286 30 } 287 31 288 - pub struct Launcher { 289 - stack: [AppId; MAX_STACK_DEPTH], 290 - depth: usize, 291 - pub ctx: AppContext, 292 - } 293 - 294 - impl Default for Launcher { 295 - fn default() -> Self { 296 - Self::new() 297 - } 298 - } 299 - 300 - impl Launcher { 301 - pub const fn new() -> Self { 302 - Self { 303 - stack: [AppId::Home; MAX_STACK_DEPTH], 304 - depth: 1, 305 - ctx: AppContext::new(), 306 - } 307 - } 308 - 309 - pub fn active(&self) -> AppId { 310 - self.stack[self.depth - 1] 311 - } 32 + pub type Transition = crate::kernel::app::Transition<AppId>; 33 + pub type NavEvent = crate::kernel::app::NavEvent<AppId>; 34 + pub type Launcher = crate::kernel::app::Launcher<AppId>; 312 35 313 - pub fn apply(&mut self, transition: Transition) -> Option<NavEvent> { 314 - let old = self.active(); 315 - 316 - let (suspend, resume) = match transition { 317 - Transition::None => return None, 318 - 319 - Transition::Push(id) => { 320 - if self.depth >= MAX_STACK_DEPTH { 321 - log::warn!( 322 - "nav stack full (depth {}), Push({:?}) degraded to Replace", 323 - self.depth, 324 - id 325 - ); 326 - self.stack[self.depth - 1] = id; 327 - (false, false) 328 - } else { 329 - self.stack[self.depth] = id; 330 - self.depth += 1; 331 - (true, false) 332 - } 333 - } 334 - 335 - Transition::Pop => { 336 - if self.depth > 1 { 337 - self.depth -= 1; 338 - (false, true) 339 - } else { 340 - return None; 341 - } 342 - } 343 - 344 - Transition::Replace(id) => { 345 - self.stack[self.depth - 1] = id; 346 - (false, false) 347 - } 348 - 349 - Transition::Home => { 350 - self.depth = 1; 351 - self.stack[0] = AppId::Home; 352 - (false, true) 353 - } 354 - }; 355 - 356 - let new = self.active(); 357 - if new != old { 358 - Some(NavEvent { 359 - from: old, 360 - to: new, 361 - suspend, 362 - resume, 363 - }) 364 - } else { 365 - None 366 - } 367 - } 368 - } 36 + pub use crate::kernel::app::{App, AppContext, PendingSetting, RECENT_FILE, Redraw};
-2391
src/apps/reader.rs
··· 1 - // Reader: TXT (lazy page-indexed) or EPUB (ZIP/OPF → SD cache). 2 - 3 - extern crate alloc; 4 - 5 - use crate::fonts::bitmap::{self, BitmapFont}; 6 - 7 - use alloc::vec::Vec; 8 - use core::fmt::Write; 9 - 10 - use embedded_graphics::mono_font::MonoTextStyle; 11 - use embedded_graphics::mono_font::ascii::FONT_6X13; 12 - use embedded_graphics::pixelcolor::BinaryColor; 13 - use embedded_graphics::prelude::*; 14 - use embedded_graphics::primitives::{PrimitiveStyle, Rectangle}; 15 - use embedded_graphics::text::Text; 16 - 17 - use crate::apps::bookmarks; 18 - use crate::apps::{App, AppContext, Services, Transition}; 19 - use crate::board::action::{Action, ActionEvent}; 20 - use crate::board::{SCREEN_H, SCREEN_W}; 21 - use crate::drivers::strip::StripBuffer; 22 - use crate::fonts; 23 - use crate::ui::quick_menu::QuickAction; 24 - use crate::ui::{Alignment, BUTTON_BAR_H, CONTENT_TOP, Region, StackFmt}; 25 - use smol_epub::DecodedImage; 26 - use smol_epub::cache; 27 - use smol_epub::epub::{self, EpubMeta, EpubSpine, EpubToc, TocSource}; 28 - use smol_epub::html_strip::{ 29 - BOLD_OFF, BOLD_ON, HEADING_OFF, HEADING_ON, IMG_REF, ITALIC_OFF, ITALIC_ON, MARKER, QUOTE_OFF, 30 - QUOTE_ON, 31 - }; 32 - use smol_epub::zip::{self, ZipIndex}; 33 - 34 - const MARGIN: u16 = 8; 35 - const HEADER_Y: u16 = CONTENT_TOP + 2; 36 - const HEADER_H: u16 = 16; 37 - const TEXT_Y: u16 = HEADER_Y + HEADER_H + 2; 38 - const LINE_H: u16 = 13; 39 - const CHARS_PER_LINE: usize = 66; 40 - const LINES_PER_PAGE: usize = 58; 41 - const PAGE_BUF: usize = 8192; 42 - const MAX_PAGES: usize = 1024; 43 - 44 - const HEADER_REGION: Region = Region::new(MARGIN, HEADER_Y, 300, HEADER_H); 45 - const STATUS_REGION: Region = Region::new(308, HEADER_Y, 164, HEADER_H); 46 - 47 - const PAGE_REGION: Region = Region::new(0, HEADER_Y, SCREEN_W, SCREEN_H - HEADER_Y); 48 - 49 - const NO_PREFETCH: usize = usize::MAX; 50 - 51 - const TEXT_W: u32 = (SCREEN_W - 2 * MARGIN) as u32; 52 - const TEXT_AREA_H: u16 = SCREEN_H - TEXT_Y - BUTTON_BAR_H; 53 - const EOCD_TAIL: usize = 512; 54 - const INDENT_PX: u32 = 24; 55 - const IMAGE_DISPLAY_H: u16 = 200; 56 - const CHAPTER_CACHE_MAX: usize = 98304; 57 - 58 - const PROGRESS_H: u16 = 2; 59 - const PROGRESS_Y: u16 = SCREEN_H - PROGRESS_H - 1; 60 - const PROGRESS_W: u16 = SCREEN_W - 2 * MARGIN; 61 - 62 - const POSITION_OVERLAY_W: u16 = 280; 63 - const POSITION_OVERLAY_H: u16 = 40; 64 - const POSITION_OVERLAY: Region = Region::new( 65 - (SCREEN_W - POSITION_OVERLAY_W) / 2, 66 - (SCREEN_H - POSITION_OVERLAY_H) / 2, 67 - POSITION_OVERLAY_W, 68 - POSITION_OVERLAY_H, 69 - ); 70 - 71 - const LOADING_REGION: Region = Region::new(MARGIN, TEXT_Y, 464, 20); 72 - 73 - pub const QA_FONT_SIZE: u8 = 1; 74 - const QA_PREV_CHAPTER: u8 = 3; 75 - const QA_NEXT_CHAPTER: u8 = 4; 76 - const QA_TOC: u8 = 5; 77 - 78 - const QA_MAX: usize = 4; 79 - 80 - pub const RECENT_FILE: &str = "RECENT"; 81 - 82 - #[derive(Clone, Copy, PartialEq)] 83 - enum State { 84 - NeedBookmark, 85 - NeedInit, 86 - NeedOpf, 87 - NeedToc, 88 - NeedCache, 89 - NeedCacheChapter, 90 - NeedIndex, 91 - NeedPage, 92 - Ready, 93 - ShowToc, 94 - Error, 95 - } 96 - 97 - #[derive(Clone, Copy)] 98 - struct LineSpan { 99 - start: u16, 100 - len: u16, 101 - flags: u8, 102 - indent: u8, 103 - } 104 - 105 - impl LineSpan { 106 - const EMPTY: Self = Self { 107 - start: 0, 108 - len: 0, 109 - flags: 0, 110 - indent: 0, 111 - }; 112 - 113 - const FLAG_BOLD: u8 = 1 << 0; 114 - const FLAG_ITALIC: u8 = 1 << 1; 115 - const FLAG_HEADING: u8 = 1 << 2; 116 - // image origin: len > 0, start/len = src path; continuation lines: len == 0 117 - const FLAG_IMAGE: u8 = 1 << 3; 118 - 119 - #[inline] 120 - fn is_image(&self) -> bool { 121 - self.flags & Self::FLAG_IMAGE != 0 122 - } 123 - 124 - #[inline] 125 - fn is_image_origin(&self) -> bool { 126 - self.is_image() && self.len > 0 127 - } 128 - 129 - fn style(&self) -> fonts::Style { 130 - if self.flags & Self::FLAG_HEADING != 0 { 131 - fonts::Style::Heading 132 - } else if self.flags & Self::FLAG_BOLD != 0 { 133 - fonts::Style::Bold 134 - } else if self.flags & Self::FLAG_ITALIC != 0 { 135 - fonts::Style::Italic 136 - } else { 137 - fonts::Style::Regular 138 - } 139 - } 140 - 141 - fn pack_flags(bold: bool, italic: bool, heading: bool) -> u8 { 142 - (bold as u8) | ((italic as u8) << 1) | ((heading as u8) << 2) 143 - } 144 - } 145 - 146 - impl Default for ReaderApp { 147 - fn default() -> Self { 148 - Self::new() 149 - } 150 - } 151 - 152 - pub struct ReaderApp { 153 - filename: [u8; 32], 154 - filename_len: usize, 155 - title: [u8; 96], 156 - title_len: usize, 157 - file_size: u32, 158 - 159 - offsets: [u32; MAX_PAGES], 160 - total_pages: usize, 161 - fully_indexed: bool, 162 - 163 - page: usize, 164 - buf: [u8; PAGE_BUF], 165 - buf_len: usize, 166 - lines: [LineSpan; LINES_PER_PAGE], 167 - line_count: usize, 168 - 169 - prefetch: [u8; PAGE_BUF], 170 - prefetch_len: usize, 171 - prefetch_page: usize, 172 - 173 - state: State, 174 - error: Option<&'static str>, 175 - show_position: bool, 176 - 177 - is_epub: bool, 178 - zip: ZipIndex, 179 - meta: EpubMeta, 180 - spine: EpubSpine, 181 - chapter: u16, 182 - goto_last_page: bool, 183 - restore_offset: Option<u32>, 184 - 185 - cache_dir: [u8; 8], 186 - epub_name_hash: u32, 187 - epub_file_size: u32, 188 - chapter_sizes: [u32; cache::MAX_CACHE_CHAPTERS], 189 - chapters_cached: bool, 190 - cache_chapter: u16, 191 - 192 - ch_cache: Vec<u8>, 193 - page_img: Option<DecodedImage>, 194 - toc: EpubToc, 195 - toc_source: Option<TocSource>, 196 - toc_selected: usize, 197 - toc_scroll: usize, 198 - 199 - fonts: Option<fonts::FontSet>, 200 - font_line_h: u16, 201 - font_ascent: u16, 202 - max_lines: usize, 203 - 204 - book_font_size_idx: u8, 205 - applied_font_idx: u8, 206 - 207 - chrome_font: Option<&'static BitmapFont>, 208 - qa_buf: [QuickAction; QA_MAX], 209 - qa_count: usize, 210 - } 211 - 212 - impl ReaderApp { 213 - pub const fn new() -> Self { 214 - Self { 215 - filename: [0u8; 32], 216 - filename_len: 0, 217 - title: [0u8; 96], 218 - title_len: 0, 219 - file_size: 0, 220 - 221 - offsets: [0u32; MAX_PAGES], 222 - total_pages: 0, 223 - fully_indexed: false, 224 - 225 - page: 0, 226 - buf: [0u8; PAGE_BUF], 227 - buf_len: 0, 228 - lines: [LineSpan::EMPTY; LINES_PER_PAGE], 229 - line_count: 0, 230 - 231 - prefetch: [0u8; PAGE_BUF], 232 - prefetch_len: 0, 233 - prefetch_page: NO_PREFETCH, 234 - 235 - state: State::NeedPage, 236 - error: None, 237 - show_position: false, 238 - 239 - is_epub: false, 240 - zip: ZipIndex::new(), 241 - meta: EpubMeta::new(), 242 - spine: EpubSpine::new(), 243 - chapter: 0, 244 - goto_last_page: false, 245 - restore_offset: None, 246 - 247 - cache_dir: [0u8; 8], 248 - epub_name_hash: 0, 249 - epub_file_size: 0, 250 - chapter_sizes: [0u32; cache::MAX_CACHE_CHAPTERS], 251 - chapters_cached: false, 252 - cache_chapter: 0, 253 - 254 - ch_cache: Vec::new(), 255 - 256 - page_img: None, 257 - 258 - toc: EpubToc::new(), 259 - toc_source: None, 260 - toc_selected: 0, 261 - toc_scroll: 0, 262 - 263 - fonts: None, 264 - font_line_h: LINE_H, 265 - font_ascent: LINE_H, 266 - max_lines: LINES_PER_PAGE, 267 - 268 - book_font_size_idx: 0, 269 - applied_font_idx: 0, 270 - 271 - chrome_font: None, 272 - 273 - qa_buf: [QuickAction::trigger(0, "", ""); QA_MAX], 274 - qa_count: 0, 275 - } 276 - } 277 - 278 - // 0 = Small, 1 = Medium, 2 = Large 279 - pub fn set_book_font_size(&mut self, idx: u8) { 280 - self.book_font_size_idx = idx; 281 - self.apply_font_metrics(); 282 - self.rebuild_quick_actions(); 283 - } 284 - 285 - pub fn set_chrome_font(&mut self, font: &'static BitmapFont) { 286 - self.chrome_font = Some(font); 287 - } 288 - 289 - fn rebuild_quick_actions(&mut self) { 290 - let mut n = 0usize; 291 - 292 - self.qa_buf[n] = QuickAction::cycle( 293 - QA_FONT_SIZE, 294 - "Book Font", 295 - self.book_font_size_idx, 296 - fonts::FONT_SIZE_NAMES, 297 - ); 298 - n += 1; 299 - 300 - if self.is_epub && self.spine.len() > 1 { 301 - self.qa_buf[n] = QuickAction::trigger(QA_PREV_CHAPTER, "Prev Ch", "<<<"); 302 - n += 1; 303 - self.qa_buf[n] = QuickAction::trigger(QA_NEXT_CHAPTER, "Next Ch", ">>>"); 304 - n += 1; 305 - } 306 - 307 - if self.is_epub && !self.toc.is_empty() { 308 - self.qa_buf[n] = QuickAction::trigger(QA_TOC, "Contents", "Open"); 309 - n += 1; 310 - } 311 - 312 - self.qa_count = n; 313 - } 314 - 315 - fn apply_font_metrics(&mut self) { 316 - self.fonts = None; 317 - self.font_line_h = LINE_H; 318 - self.font_ascent = LINE_H; 319 - self.max_lines = LINES_PER_PAGE; 320 - 321 - if fonts::font_data::HAS_REGULAR { 322 - let fs = fonts::FontSet::for_size(self.book_font_size_idx); 323 - self.font_line_h = fs.line_height(fonts::Style::Regular); 324 - self.font_ascent = fs.ascent(fonts::Style::Regular); 325 - self.max_lines = ((TEXT_AREA_H / self.font_line_h) as usize).min(LINES_PER_PAGE); 326 - log::info!( 327 - "font: size_idx={} line_h={} ascent={} max_lines={}", 328 - self.book_font_size_idx, 329 - self.font_line_h, 330 - self.font_ascent, 331 - self.max_lines 332 - ); 333 - self.fonts = Some(fs); 334 - } 335 - self.applied_font_idx = self.book_font_size_idx; 336 - } 337 - 338 - fn name(&self) -> &str { 339 - core::str::from_utf8(&self.filename[..self.filename_len]).unwrap_or("???") 340 - } 341 - 342 - fn name_copy(&self) -> ([u8; 32], usize) { 343 - let mut buf = [0u8; 32]; 344 - buf[..self.filename_len].copy_from_slice(&self.filename[..self.filename_len]); 345 - (buf, self.filename_len) 346 - } 347 - 348 - pub fn save_position(&self, bm: &mut bookmarks::BookmarkCache) { 349 - if self.state == State::Ready { 350 - bm.save( 351 - &self.filename[..self.filename_len], 352 - self.offsets[self.page], 353 - self.chapter, 354 - ); 355 - } 356 - } 357 - 358 - fn bookmark_load(&mut self, bm: &bookmarks::BookmarkCache) -> bool { 359 - if let Some(slot) = bm.find(&self.filename[..self.filename_len]) { 360 - log::info!( 361 - "bookmark: restoring off={} ch={} for {}", 362 - slot.byte_offset, 363 - slot.chapter, 364 - slot.filename_str(), 365 - ); 366 - self.chapter = slot.chapter; 367 - self.restore_offset = if slot.byte_offset > 0 { 368 - Some(slot.byte_offset) 369 - } else { 370 - None 371 - }; 372 - true 373 - } else { 374 - false 375 - } 376 - } 377 - 378 - fn display_name(&self) -> &str { 379 - if self.title_len > 0 { 380 - core::str::from_utf8(&self.title[..self.title_len]).unwrap_or(self.name()) 381 - } else { 382 - self.name() 383 - } 384 - } 385 - 386 - fn progress_pct(&self) -> u8 { 387 - if self.is_epub && !self.spine.is_empty() { 388 - let spine_len = self.spine.len() as u64; 389 - let ch = self.chapter as u64; 390 - 391 - if ch + 1 >= spine_len && self.fully_indexed && self.page + 1 >= self.total_pages { 392 - return 100; 393 - } 394 - 395 - let in_ch = if self.file_size == 0 { 396 - 0u64 397 - } else { 398 - let pos = self.offsets[self.page] as u64; 399 - let size = self.file_size as u64; 400 - ((pos * 100) / size).min(100) 401 - }; 402 - 403 - let overall = (ch * 100 + in_ch) / spine_len; 404 - return overall.min(100) as u8; 405 - } 406 - 407 - if self.file_size == 0 { 408 - return 100; 409 - } 410 - if self.fully_indexed && self.page + 1 >= self.total_pages { 411 - return 100; 412 - } 413 - let pos = self.offsets[self.page] as u64; 414 - let size = self.file_size as u64; 415 - ((pos * 100) / size).min(100) as u8 416 - } 417 - 418 - fn wrap_lines_counted(&mut self, n: usize) -> usize { 419 - let fonts_copy = self.fonts; 420 - 421 - if let Some(fs) = fonts_copy { 422 - let (c, count) = 423 - wrap_proportional(&self.buf, n, &fs, &mut self.lines, self.max_lines, TEXT_W); 424 - self.line_count = count; 425 - c 426 - } else { 427 - self.wrap_monospace(n) 428 - } 429 - } 430 - 431 - fn wrap_monospace(&mut self, n: usize) -> usize { 432 - let max = self.max_lines; 433 - self.line_count = 0; 434 - let mut col: usize = 0; 435 - let mut line_start: usize = 0; 436 - 437 - for i in 0..n { 438 - let b = self.buf[i]; 439 - match b { 440 - b'\r' => {} 441 - b'\n' => { 442 - let end = trim_trailing_cr(&self.buf, line_start, i); 443 - self.push_line(line_start, end); 444 - line_start = i + 1; 445 - col = 0; 446 - if self.line_count >= max { 447 - return line_start; 448 - } 449 - } 450 - _ => { 451 - col += 1; 452 - if col >= CHARS_PER_LINE { 453 - self.push_line(line_start, i + 1); 454 - line_start = i + 1; 455 - col = 0; 456 - if self.line_count >= max { 457 - return line_start; 458 - } 459 - } 460 - } 461 - } 462 - } 463 - 464 - if line_start < n && self.line_count < max { 465 - let end = trim_trailing_cr(&self.buf, line_start, n); 466 - self.push_line(line_start, end); 467 - } 468 - 469 - n 470 - } 471 - 472 - fn push_line(&mut self, start: usize, end: usize) { 473 - if self.line_count < LINES_PER_PAGE { 474 - self.lines[self.line_count] = LineSpan { 475 - start: start as u16, 476 - len: (end - start) as u16, 477 - flags: 0, 478 - indent: 0, 479 - }; 480 - self.line_count += 1; 481 - } 482 - } 483 - 484 - fn reset_paging(&mut self) { 485 - self.page = 0; 486 - self.offsets[0] = 0; 487 - self.total_pages = 1; 488 - self.fully_indexed = false; 489 - self.buf_len = 0; 490 - self.line_count = 0; 491 - self.prefetch_page = NO_PREFETCH; 492 - self.prefetch_len = 0; 493 - self.page_img = None; 494 - } 495 - 496 - fn load_and_prefetch<SPI: embedded_hal::spi::SpiDevice>( 497 - &mut self, 498 - svc: &mut Services<'_, SPI>, 499 - ) -> Result<(), &'static str> { 500 - if !self.ch_cache.is_empty() { 501 - let start = (self.offsets[self.page] as usize).min(self.ch_cache.len()); 502 - let end = (start + PAGE_BUF).min(self.ch_cache.len()); 503 - let n = end - start; 504 - if n > 0 { 505 - self.buf[..n].copy_from_slice(&self.ch_cache[start..end]); 506 - } 507 - self.buf_len = n; 508 - self.prefetch_page = NO_PREFETCH; 509 - self.prefetch_len = 0; 510 - self.wrap_lines_counted(n); 511 - self.decode_page_images(svc); 512 - return Ok(()); 513 - } 514 - 515 - let (nb, nl) = self.name_copy(); 516 - let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 517 - 518 - if self.prefetch_page == self.page { 519 - core::mem::swap(&mut self.buf, &mut self.prefetch); 520 - self.buf_len = self.prefetch_len; 521 - self.prefetch_page = NO_PREFETCH; 522 - self.prefetch_len = 0; 523 - } else if self.is_epub && self.chapters_cached { 524 - let dir_buf = self.cache_dir; 525 - let dir = cache::dir_name_str(&dir_buf); 526 - let ch_file = cache::chapter_file_name(self.chapter); 527 - let ch_str = cache::chapter_file_str(&ch_file); 528 - let n = svc.read_pulp_sub_chunk(dir, ch_str, self.offsets[self.page], &mut self.buf)?; 529 - self.buf_len = n; 530 - } else if self.file_size == 0 { 531 - let (size, n) = svc.read_file_start(name, &mut self.buf)?; 532 - self.file_size = size; 533 - self.buf_len = n; 534 - log::info!("reader: opened {} ({} bytes)", name, size); 535 - 536 - if size == 0 { 537 - self.fully_indexed = true; 538 - self.line_count = 0; 539 - return Ok(()); 540 - } 541 - } else { 542 - let n = svc.read_file_chunk(name, self.offsets[self.page], &mut self.buf)?; 543 - self.buf_len = n; 544 - } 545 - 546 - let consumed = self.wrap_lines_counted(self.buf_len); 547 - let next_offset = self.offsets[self.page] + consumed as u32; 548 - 549 - if self.page + 1 >= self.total_pages && !self.fully_indexed { 550 - if self.line_count >= self.max_lines && next_offset < self.file_size { 551 - if self.total_pages < MAX_PAGES { 552 - self.offsets[self.total_pages] = next_offset; 553 - self.total_pages += 1; 554 - } else { 555 - self.fully_indexed = true; 556 - } 557 - } else { 558 - self.fully_indexed = true; 559 - } 560 - } 561 - 562 - if self.page + 1 < self.total_pages { 563 - let pf_offset = self.offsets[self.page + 1]; 564 - let pf_result = if self.is_epub && self.chapters_cached { 565 - let dir_buf = self.cache_dir; 566 - let dir = cache::dir_name_str(&dir_buf); 567 - let ch_file = cache::chapter_file_name(self.chapter); 568 - let ch_str = cache::chapter_file_str(&ch_file); 569 - svc.read_pulp_sub_chunk(dir, ch_str, pf_offset, &mut self.prefetch) 570 - } else { 571 - svc.read_file_chunk(name, pf_offset, &mut self.prefetch) 572 - }; 573 - match pf_result { 574 - Ok(n) => { 575 - self.prefetch_len = n; 576 - self.prefetch_page = self.page + 1; 577 - } 578 - Err(_) => { 579 - self.prefetch_page = NO_PREFETCH; 580 - self.prefetch_len = 0; 581 - } 582 - } 583 - } else { 584 - self.prefetch_page = NO_PREFETCH; 585 - self.prefetch_len = 0; 586 - } 587 - 588 - self.decode_page_images(svc); 589 - Ok(()) 590 - } 591 - 592 - fn decode_page_images<SPI: embedded_hal::spi::SpiDevice>( 593 - &mut self, 594 - svc: &mut Services<'_, SPI>, 595 - ) { 596 - self.page_img = None; 597 - 598 - if !self.is_epub || self.spine.is_empty() { 599 - return; 600 - } 601 - 602 - // copy src path to local buf to avoid borrowing self.buf below 603 - let mut src_buf = [0u8; 128]; 604 - let mut src_len = 0usize; 605 - for i in 0..self.line_count { 606 - if self.lines[i].is_image_origin() { 607 - let start = self.lines[i].start as usize; 608 - let len = self.lines[i].len as usize; 609 - if start + len <= self.buf_len { 610 - let n = len.min(src_buf.len()); 611 - src_buf[..n].copy_from_slice(&self.buf[start..start + n]); 612 - src_len = n; 613 - } 614 - break; 615 - } 616 - } 617 - 618 - if src_len == 0 { 619 - return; 620 - } 621 - 622 - let src_str = match core::str::from_utf8(&src_buf[..src_len]) { 623 - Ok(s) => s, 624 - Err(_) => return, 625 - }; 626 - 627 - log::info!("reader: decoding image: {}", src_str); 628 - 629 - let ch_zip_idx = self.spine.items[self.chapter as usize] as usize; 630 - let ch_path = self.zip.entry_name(ch_zip_idx); 631 - let ch_dir = ch_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 632 - 633 - let mut path_buf = [0u8; 512]; 634 - let path_len = epub::resolve_path(ch_dir, src_str, &mut path_buf); 635 - let full_path = match core::str::from_utf8(&path_buf[..path_len]) { 636 - Ok(s) => s, 637 - Err(_) => return, 638 - }; 639 - 640 - let dir_buf = self.cache_dir; 641 - let dir = cache::dir_name_str(&dir_buf); 642 - let img_name = img_cache_name(cache::fnv1a(full_path.as_bytes())); 643 - let img_file = img_cache_str(&img_name); 644 - 645 - if let Ok(img) = load_cached_image(svc, dir, img_file) { 646 - log::info!( 647 - "reader: image cache hit {} ({}x{})", 648 - img_file, 649 - img.width, 650 - img.height 651 - ); 652 - self.page_img = Some(img); 653 - return; 654 - } 655 - 656 - let zip_idx = match self 657 - .zip 658 - .find(full_path) 659 - .or_else(|| self.zip.find_icase(full_path)) 660 - { 661 - Some(idx) => idx, 662 - None => { 663 - log::warn!("reader: image not in ZIP: {}", full_path); 664 - return; 665 - } 666 - }; 667 - 668 - let entry = *self.zip.entry(zip_idx); 669 - let (nb, nl) = self.name_copy(); 670 - let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 671 - 672 - let data_offset = { 673 - let mut hdr = [0u8; 30]; 674 - if svc 675 - .read_file_chunk(epub_name, entry.local_offset, &mut hdr) 676 - .is_err() 677 - { 678 - log::warn!("reader: failed to read ZIP local header"); 679 - return; 680 - } 681 - match zip::ZipIndex::local_header_data_skip(&hdr) { 682 - Ok(skip) => entry.local_offset + skip, 683 - Err(e) => { 684 - log::warn!("reader: {}", e); 685 - return; 686 - } 687 - } 688 - }; 689 - 690 - let ext_jpeg = full_path.ends_with(".jpg") 691 - || full_path.ends_with(".jpeg") 692 - || full_path.ends_with(".JPG") 693 - || full_path.ends_with(".JPEG"); 694 - let ext_png = full_path.ends_with(".png") || full_path.ends_with(".PNG"); 695 - 696 - let (is_jpeg, is_png) = if ext_jpeg || ext_png { 697 - (ext_jpeg, ext_png) 698 - } else if entry.method == zip::METHOD_STORED { 699 - let mut magic = [0u8; 8]; 700 - let n = svc 701 - .read_file_chunk(epub_name, data_offset, &mut magic) 702 - .unwrap_or(0); 703 - ( 704 - n >= 2 && magic[0] == 0xFF && magic[1] == 0xD8, 705 - n >= 8 && magic[..8] == [137, 80, 78, 71, 13, 10, 26, 10], 706 - ) 707 - } else { 708 - (false, false) 709 - }; 710 - 711 - if !is_jpeg && !is_png { 712 - log::warn!("reader: unsupported image format: {}", full_path); 713 - return; 714 - } 715 - 716 - if !self.ch_cache.is_empty() { 717 - log::info!( 718 - "reader: releasing {} KB chapter cache for image decode", 719 - self.ch_cache.len() / 1024 720 - ); 721 - self.ch_cache = Vec::new(); 722 - } 723 - 724 - let result = if is_jpeg && entry.method == zip::METHOD_STORED { 725 - let svc_ref = &*svc; 726 - smol_epub::jpeg::decode_jpeg_sd( 727 - |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), 728 - data_offset, 729 - entry.uncomp_size, 730 - TEXT_W as u16, 731 - IMAGE_DISPLAY_H, 732 - ) 733 - } else if is_jpeg { 734 - let svc_ref = &*svc; 735 - smol_epub::jpeg::decode_jpeg_deflate_sd( 736 - |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), 737 - data_offset, 738 - entry.comp_size, 739 - entry.uncomp_size, 740 - TEXT_W as u16, 741 - IMAGE_DISPLAY_H, 742 - ) 743 - } else if entry.method == zip::METHOD_STORED { 744 - let svc_ref = &*svc; 745 - smol_epub::png::decode_png_sd( 746 - |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), 747 - data_offset, 748 - entry.uncomp_size, 749 - TEXT_W as u16, 750 - IMAGE_DISPLAY_H, 751 - ) 752 - } else { 753 - let svc_ref = &*svc; 754 - smol_epub::png::decode_png_deflate_sd( 755 - |off, buf| svc_ref.read_file_chunk(epub_name, off, buf), 756 - data_offset, 757 - entry.comp_size, 758 - TEXT_W as u16, 759 - IMAGE_DISPLAY_H, 760 - ) 761 - }; 762 - 763 - match result { 764 - Ok(img) => { 765 - log::info!( 766 - "reader: decoded {}x{} image ({} bytes 1-bit)", 767 - img.width, 768 - img.height, 769 - img.data.len() 770 - ); 771 - if let Err(e) = save_cached_image(svc, dir, img_file, &img) { 772 - log::warn!("reader: image cache write failed: {}", e); 773 - } else { 774 - log::info!("reader: cached image as {}", img_file); 775 - } 776 - self.page_img = Some(img); 777 - } 778 - Err(e) => { 779 - log::warn!("reader: image decode failed: {}", e); 780 - } 781 - } 782 - } 783 - 784 - fn epub_init_zip<SPI: embedded_hal::spi::SpiDevice>( 785 - &mut self, 786 - svc: &mut Services<'_, SPI>, 787 - ) -> Result<(), &'static str> { 788 - let (nb, nl) = self.name_copy(); 789 - let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 790 - 791 - let epub_size = svc.file_size(name)?; 792 - if epub_size < 22 { 793 - return Err("epub: file too small"); 794 - } 795 - self.epub_file_size = epub_size; 796 - self.epub_name_hash = cache::fnv1a(name.as_bytes()); 797 - self.cache_dir = cache::dir_name_for_hash(self.epub_name_hash); 798 - 799 - let tail_size = (epub_size as usize).min(EOCD_TAIL); 800 - let tail_offset = epub_size - tail_size as u32; 801 - let n = svc.read_file_chunk(name, tail_offset, &mut self.buf[..tail_size])?; 802 - let (cd_offset, cd_size) = ZipIndex::parse_eocd(&self.buf[..n], epub_size)?; 803 - 804 - log::info!( 805 - "epub: CD at offset {} size {} ({} file bytes)", 806 - cd_offset, 807 - cd_size, 808 - epub_size 809 - ); 810 - 811 - let mut cd_buf = Vec::new(); 812 - cd_buf 813 - .try_reserve_exact(cd_size as usize) 814 - .map_err(|_| "epub: CD too large for memory")?; 815 - cd_buf.resize(cd_size as usize, 0); 816 - read_full(svc, name, cd_offset, &mut cd_buf)?; 817 - self.zip.clear(); 818 - self.zip.parse_central_directory(&cd_buf)?; 819 - drop(cd_buf); 820 - 821 - log::info!("epub: {} entries in ZIP", self.zip.count()); 822 - 823 - Ok(()) 824 - } 825 - 826 - fn epub_init_opf<SPI: embedded_hal::spi::SpiDevice>( 827 - &mut self, 828 - svc: &mut Services<'_, SPI>, 829 - ) -> Result<(), &'static str> { 830 - let (nb, nl) = self.name_copy(); 831 - let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 832 - 833 - let mut opf_path_buf = [0u8; epub::OPF_PATH_CAP]; 834 - let opf_path_len = if let Some(container_idx) = self.zip.find("META-INF/container.xml") { 835 - let container_data = extract_zip_entry(svc, name, &self.zip, container_idx)?; 836 - let len = epub::parse_container(&container_data, &mut opf_path_buf)?; 837 - drop(container_data); 838 - len 839 - } else { 840 - log::warn!("epub: no container.xml, scanning for .opf"); 841 - epub::find_opf_in_zip(&self.zip, &mut opf_path_buf)? 842 - }; 843 - 844 - let opf_path = core::str::from_utf8(&opf_path_buf[..opf_path_len]) 845 - .map_err(|_| "epub: bad opf path")?; 846 - 847 - log::info!("epub: OPF at {}", opf_path); 848 - 849 - let opf_idx = self 850 - .zip 851 - .find(opf_path) 852 - .or_else(|| self.zip.find_icase(opf_path)) 853 - .ok_or("epub: opf not found in zip")?; 854 - let opf_data = extract_zip_entry(svc, name, &self.zip, opf_idx)?; 855 - 856 - let opf_dir = opf_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 857 - epub::parse_opf( 858 - &opf_data, 859 - opf_dir, 860 - &self.zip, 861 - &mut self.meta, 862 - &mut self.spine, 863 - )?; 864 - 865 - // defer TOC to NeedToc to avoid stack overflow while OPF is live 866 - self.toc_source = epub::find_toc_source(&opf_data, opf_dir, &self.zip); 867 - drop(opf_data); 868 - 869 - log::info!( 870 - "epub: \"{}\" by {} — {} chapters", 871 - self.meta.title_str(), 872 - self.meta.author_str(), 873 - self.spine.len() 874 - ); 875 - 876 - let tlen = self.meta.title_len as usize; 877 - if tlen > 0 { 878 - let n = tlen.min(self.title.len()); 879 - self.title[..n].copy_from_slice(&self.meta.title[..n]); 880 - self.title_len = n; 881 - 882 - if let Err(e) = svc.save_title(name, self.meta.title_str()) { 883 - log::warn!("epub: failed to save title mapping: {}", e); 884 - } 885 - } 886 - 887 - self.toc.clear(); 888 - 889 - Ok(()) 890 - } 891 - 892 - fn epub_check_cache<SPI: embedded_hal::spi::SpiDevice>( 893 - &mut self, 894 - svc: &mut Services<'_, SPI>, 895 - ) -> Result<bool, &'static str> { 896 - let dir_buf = self.cache_dir; 897 - let dir = cache::dir_name_str(&dir_buf); 898 - 899 - // read into self.buf to avoid ~2KB stack temporaries (esp-rtos overflow) 900 - let meta_cap = cache::META_MAX_SIZE.min(self.buf.len()); 901 - if let Ok(n) = svc.read_pulp_sub_chunk(dir, cache::META_FILE, 0, &mut self.buf[..meta_cap]) 902 - && let Ok(count) = cache::parse_cache_meta( 903 - &self.buf[..n], 904 - self.epub_file_size, 905 - self.epub_name_hash, 906 - self.spine.len(), 907 - &mut self.chapter_sizes, 908 - ) 909 - { 910 - self.chapters_cached = true; 911 - log::info!("epub: cache hit ({} chapters)", count); 912 - return Ok(true); 913 - } 914 - 915 - log::info!("epub: building cache for {} chapters", self.spine.len()); 916 - svc.ensure_pulp_subdir(dir)?; 917 - self.cache_chapter = 0; 918 - Ok(false) 919 - } 920 - 921 - fn epub_cache_one_chapter<SPI: embedded_hal::spi::SpiDevice>( 922 - &mut self, 923 - svc: &mut Services<'_, SPI>, 924 - ) -> Result<bool, &'static str> { 925 - let ch = self.cache_chapter as usize; 926 - let spine_len = self.spine.len(); 927 - 928 - if ch >= spine_len { 929 - return self.epub_finish_cache(svc); 930 - } 931 - 932 - let dir_buf = self.cache_dir; 933 - let dir = cache::dir_name_str(&dir_buf); 934 - 935 - let (nb, nl) = self.name_copy(); 936 - let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 937 - 938 - let entry_idx = self.spine.items[ch] as usize; 939 - let entry = *self.zip.entry(entry_idx); 940 - 941 - let ch_file = cache::chapter_file_name(ch as u16); 942 - let ch_str = cache::chapter_file_str(&ch_file); 943 - 944 - svc.write_pulp_sub(dir, ch_str, &[])?; // truncate stale data 945 - let svc_ref = &*svc; 946 - let text_size = cache::stream_strip_entry( 947 - &entry, 948 - entry.local_offset, 949 - |offset, buf| svc_ref.read_file_chunk(epub_name, offset, buf), 950 - |chunk| svc_ref.append_pulp_sub(dir, ch_str, chunk), 951 - )?; 952 - 953 - self.chapter_sizes[ch] = text_size; 954 - log::info!("epub: cached ch{}/{} = {} bytes", ch, spine_len, text_size); 955 - 956 - self.cache_chapter += 1; 957 - 958 - if (self.cache_chapter as usize) < spine_len { 959 - Ok(true) 960 - } else { 961 - self.epub_finish_cache(svc) 962 - } 963 - } 964 - 965 - fn epub_finish_cache<SPI: embedded_hal::spi::SpiDevice>( 966 - &mut self, 967 - svc: &mut Services<'_, SPI>, 968 - ) -> Result<bool, &'static str> { 969 - let dir_buf = self.cache_dir; 970 - let dir = cache::dir_name_str(&dir_buf); 971 - let spine_len = self.spine.len(); 972 - 973 - let mut meta_buf = [0u8; cache::META_MAX_SIZE]; 974 - let meta_len = cache::encode_cache_meta( 975 - self.epub_file_size, 976 - self.epub_name_hash, 977 - &self.chapter_sizes[..spine_len], 978 - &mut meta_buf, 979 - ); 980 - svc.write_pulp_sub(dir, cache::META_FILE, &meta_buf[..meta_len])?; 981 - 982 - self.chapters_cached = true; 983 - log::info!("epub: cache complete"); 984 - Ok(false) 985 - } 986 - 987 - fn epub_index_chapter(&mut self) { 988 - self.reset_paging(); 989 - let ch = self.chapter as usize; 990 - self.file_size = if ch < cache::MAX_CACHE_CHAPTERS { 991 - self.chapter_sizes[ch] 992 - } else { 993 - 0 994 - }; 995 - log::info!( 996 - "epub: index chapter {}/{} ({} bytes cached text)", 997 - self.chapter + 1, 998 - self.spine.len(), 999 - self.file_size, 1000 - ); 1001 - } 1002 - 1003 - fn try_cache_chapter<SPI: embedded_hal::spi::SpiDevice>( 1004 - &mut self, 1005 - svc: &mut Services<'_, SPI>, 1006 - ) -> bool { 1007 - if !self.is_epub || !self.chapters_cached { 1008 - return false; 1009 - } 1010 - 1011 - let ch = self.chapter as usize; 1012 - let ch_size = if ch < cache::MAX_CACHE_CHAPTERS { 1013 - self.chapter_sizes[ch] as usize 1014 - } else { 1015 - return false; 1016 - }; 1017 - 1018 - if ch_size == 0 || ch_size > CHAPTER_CACHE_MAX { 1019 - self.ch_cache = Vec::new(); 1020 - return false; 1021 - } 1022 - 1023 - if self.ch_cache.len() == ch_size { 1024 - log::info!("chapter cache: reusing {} bytes in RAM", ch_size); 1025 - return true; 1026 - } 1027 - 1028 - self.ch_cache = Vec::new(); 1029 - if self.ch_cache.try_reserve_exact(ch_size).is_err() { 1030 - log::info!("chapter cache: OOM for {} bytes", ch_size); 1031 - return false; 1032 - } 1033 - self.ch_cache.resize(ch_size, 0); 1034 - 1035 - let dir_buf = self.cache_dir; 1036 - let dir = cache::dir_name_str(&dir_buf); 1037 - let ch_file = cache::chapter_file_name(self.chapter); 1038 - let ch_str = cache::chapter_file_str(&ch_file); 1039 - 1040 - let mut pos = 0usize; 1041 - while pos < ch_size { 1042 - let chunk = (ch_size - pos).min(PAGE_BUF); 1043 - match svc.read_pulp_sub_chunk( 1044 - dir, 1045 - ch_str, 1046 - pos as u32, 1047 - &mut self.ch_cache[pos..pos + chunk], 1048 - ) { 1049 - Ok(n) if n > 0 => pos += n, 1050 - Ok(_) => break, 1051 - Err(e) => { 1052 - log::info!("chapter cache: SD read failed at {}: {}", pos, e); 1053 - self.ch_cache = Vec::new(); 1054 - return false; 1055 - } 1056 - } 1057 - } 1058 - 1059 - log::info!( 1060 - "chapter cache: loaded ch{} ({} bytes) into RAM", 1061 - self.chapter, 1062 - ch_size, 1063 - ); 1064 - true 1065 - } 1066 - 1067 - fn preindex_all_pages(&mut self) { 1068 - if self.ch_cache.is_empty() { 1069 - return; 1070 - } 1071 - 1072 - let total = self.ch_cache.len(); 1073 - self.offsets[0] = 0; 1074 - self.total_pages = 1; 1075 - 1076 - let mut offset = 0usize; 1077 - while offset < total && self.total_pages < MAX_PAGES { 1078 - let end = (offset + PAGE_BUF).min(total); 1079 - let n = end - offset; 1080 - self.buf[..n].copy_from_slice(&self.ch_cache[offset..end]); 1081 - self.buf_len = n; 1082 - 1083 - let consumed = self.wrap_lines_counted(n); 1084 - let next_offset = offset + consumed; 1085 - 1086 - if self.line_count >= self.max_lines && next_offset < total { 1087 - self.offsets[self.total_pages] = next_offset as u32; 1088 - self.total_pages += 1; 1089 - offset = next_offset; 1090 - } else { 1091 - break; 1092 - } 1093 - } 1094 - 1095 - self.fully_indexed = true; 1096 - log::info!("chapter pre-indexed: {} pages", self.total_pages); 1097 - } 1098 - 1099 - fn scan_to_last_page<SPI: embedded_hal::spi::SpiDevice>( 1100 - &mut self, 1101 - svc: &mut Services<'_, SPI>, 1102 - ) -> Result<(), &'static str> { 1103 - while !self.fully_indexed && self.total_pages < MAX_PAGES { 1104 - self.page = self.total_pages - 1; 1105 - self.load_and_prefetch(svc)?; 1106 - if self.page + 1 < self.total_pages { 1107 - self.page += 1; 1108 - } else { 1109 - break; 1110 - } 1111 - } 1112 - if self.total_pages > 0 { 1113 - self.page = self.total_pages - 1; 1114 - } 1115 - self.prefetch_page = NO_PREFETCH; 1116 - self.load_and_prefetch(svc) 1117 - } 1118 - 1119 - fn page_forward(&mut self) -> bool { 1120 - if self.state != State::Ready { 1121 - return false; 1122 - } 1123 - 1124 - if self.page + 1 < self.total_pages { 1125 - self.page += 1; 1126 - self.state = State::NeedPage; 1127 - return true; 1128 - } 1129 - 1130 - if self.is_epub && self.fully_indexed && (self.chapter as usize + 1) < self.spine.len() { 1131 - self.chapter += 1; 1132 - self.goto_last_page = false; 1133 - self.state = State::NeedIndex; 1134 - return true; 1135 - } 1136 - 1137 - false 1138 - } 1139 - 1140 - fn page_backward(&mut self) -> bool { 1141 - if self.state != State::Ready { 1142 - return false; 1143 - } 1144 - 1145 - if self.page > 0 { 1146 - self.page -= 1; 1147 - self.state = State::NeedPage; 1148 - return true; 1149 - } 1150 - 1151 - if self.is_epub && self.chapter > 0 { 1152 - self.chapter -= 1; 1153 - self.goto_last_page = true; 1154 - self.state = State::NeedIndex; 1155 - return true; 1156 - } 1157 - 1158 - false 1159 - } 1160 - 1161 - // next chapter (EPUB) or +10 pages (TXT) 1162 - fn jump_forward(&mut self) -> bool { 1163 - if self.state != State::Ready { 1164 - return false; 1165 - } 1166 - if self.is_epub { 1167 - if (self.chapter as usize + 1) < self.spine.len() { 1168 - self.chapter += 1; 1169 - self.goto_last_page = false; 1170 - self.state = State::NeedIndex; 1171 - return true; 1172 - } 1173 - } else { 1174 - let last = if self.total_pages > 0 { 1175 - self.total_pages - 1 1176 - } else { 1177 - 0 1178 - }; 1179 - let target = (self.page + 10).min(last); 1180 - if target != self.page { 1181 - self.page = target; 1182 - self.state = State::NeedPage; 1183 - return true; 1184 - } 1185 - } 1186 - false 1187 - } 1188 - 1189 - // prev chapter (EPUB) or -10 pages (TXT) 1190 - fn jump_backward(&mut self) -> bool { 1191 - if self.state != State::Ready { 1192 - return false; 1193 - } 1194 - if self.is_epub { 1195 - if self.chapter > 0 { 1196 - self.chapter -= 1; 1197 - self.goto_last_page = false; 1198 - self.state = State::NeedIndex; 1199 - return true; 1200 - } 1201 - } else { 1202 - let target = self.page.saturating_sub(10); 1203 - if target != self.page { 1204 - self.page = target; 1205 - self.state = State::NeedPage; 1206 - return true; 1207 - } 1208 - } 1209 - false 1210 - } 1211 - } 1212 - 1213 - /// Decode one UTF-8 character starting at `buf[pos]` (a lead byte >= 0xC0) 1214 - /// and map the codepoint to a printable ASCII replacement. 1215 - /// Returns `(ascii_byte, byte_length_consumed)`. 1216 - fn decode_utf8_to_ascii(buf: &[u8], pos: usize) -> (u8, usize) { 1217 - let b0 = buf[pos]; 1218 - let (mut cp, expected) = if b0 < 0xE0 { 1219 - ((b0 as u32) & 0x1F, 2) 1220 - } else if b0 < 0xF0 { 1221 - ((b0 as u32) & 0x0F, 3) 1222 - } else { 1223 - ((b0 as u32) & 0x07, 4) 1224 - }; 1225 - let len = buf.len(); 1226 - if pos + expected > len { 1227 - return (b'?', len - pos); 1228 - } 1229 - for i in 1..expected { 1230 - let cont = buf[pos + i]; 1231 - if cont & 0xC0 != 0x80 { 1232 - return (b'?', i); 1233 - } 1234 - cp = (cp << 6) | (cont as u32 & 0x3F); 1235 - } 1236 - let ascii = match cp { 1237 - 0x00A0 => b' ', // non-breaking space 1238 - 0x00AB | 0x00BB => b'"', // « » 1239 - 0x00AD => b'-', // soft hyphen 1240 - 0x00B7 => b'.', // middle dot 1241 - 0x00D7 => b'x', // multiplication sign 1242 - 0x00F7 => b'/', // division sign 1243 - 0x2010..=0x2015 => b'-', // hyphens, en-dash, em-dash, etc. 1244 - 0x2018..=0x201B => b'\'', // single curly quotes 1245 - 0x201C..=0x201F => b'"', // double curly quotes 1246 - 0x2022 => b'*', // bullet 1247 - 0x2026 => b'.', // horizontal ellipsis 1248 - 0x2032 => b'\'', // prime 1249 - 0x2033 => b'"', // double prime 1250 - 0x2039 | 0x203A => b'\'', // single guillemets 1251 - 0x2212 => b'-', // minus sign 1252 - _ => b'?', 1253 - }; 1254 - (ascii, expected) 1255 - } 1256 - 1257 - fn trim_trailing_cr(buf: &[u8], start: usize, end: usize) -> usize { 1258 - if end > start && buf[end - 1] == b'\r' { 1259 - end - 1 1260 - } else { 1261 - end 1262 - } 1263 - } 1264 - 1265 - fn wrap_proportional( 1266 - buf: &[u8], 1267 - n: usize, 1268 - fonts: &fonts::FontSet, 1269 - lines: &mut [LineSpan], 1270 - max_lines: usize, 1271 - max_width_px: u32, 1272 - ) -> (usize, usize) { 1273 - let max_l = max_lines.min(lines.len()); 1274 - let base_max_w = max_width_px; 1275 - let mut lc: usize = 0; 1276 - let mut ls: usize = 0; 1277 - let mut px: u32 = 0; 1278 - let mut sp: usize = 0; 1279 - let mut sp_px: u32 = 0; 1280 - 1281 - let mut bold = false; 1282 - let mut italic = false; 1283 - let mut heading = false; 1284 - let mut indent: u8 = 0; 1285 - let mut max_w = base_max_w; 1286 - 1287 - #[inline] 1288 - fn current_style(bold: bool, italic: bool, heading: bool) -> fonts::Style { 1289 - if heading { 1290 - fonts::Style::Heading 1291 - } else if bold { 1292 - fonts::Style::Bold 1293 - } else if italic { 1294 - fonts::Style::Italic 1295 - } else { 1296 - fonts::Style::Regular 1297 - } 1298 - } 1299 - 1300 - macro_rules! emit { 1301 - ($start:expr, $end:expr) => { 1302 - if lc < max_l { 1303 - let e = trim_trailing_cr(buf, $start, $end); 1304 - lines[lc] = LineSpan { 1305 - start: ($start) as u16, 1306 - len: (e - ($start)) as u16, 1307 - flags: LineSpan::pack_flags(bold, italic, heading), 1308 - indent, 1309 - }; 1310 - lc += 1; 1311 - } 1312 - }; 1313 - } 1314 - 1315 - let mut i = 0; 1316 - while i < n { 1317 - let b = buf[i]; 1318 - 1319 - if b == MARKER && i + 1 < n { 1320 - if buf[i + 1] == IMG_REF && i + 2 < n { 1321 - let path_len = buf[i + 2] as usize; 1322 - let path_start = i + 3; 1323 - if path_start + path_len <= n && path_len > 0 { 1324 - if ls < i { 1325 - emit!(ls, i); 1326 - if lc >= max_l { 1327 - return (i, lc); 1328 - } 1329 - } 1330 - 1331 - let line_h = fonts.line_height(fonts::Style::Regular); 1332 - let img_lines = (IMAGE_DISPLAY_H / line_h).max(1) as usize; 1333 - 1334 - if lc < max_l { 1335 - lines[lc] = LineSpan { 1336 - start: path_start as u16, 1337 - len: path_len as u16, 1338 - flags: LineSpan::FLAG_IMAGE, 1339 - indent: 0, 1340 - }; 1341 - lc += 1; 1342 - } 1343 - 1344 - for _ in 1..img_lines { 1345 - if lc >= max_l { 1346 - break; 1347 - } 1348 - lines[lc] = LineSpan { 1349 - start: 0, 1350 - len: 0, 1351 - flags: LineSpan::FLAG_IMAGE, 1352 - indent: 0, 1353 - }; 1354 - lc += 1; 1355 - } 1356 - 1357 - i = path_start + path_len; 1358 - ls = i; 1359 - px = 0; 1360 - sp = ls; 1361 - sp_px = 0; 1362 - if lc >= max_l { 1363 - return (ls, lc); 1364 - } 1365 - continue; 1366 - } 1367 - } 1368 - 1369 - match buf[i + 1] { 1370 - BOLD_ON => bold = true, 1371 - BOLD_OFF => bold = false, 1372 - ITALIC_ON => italic = true, 1373 - ITALIC_OFF => italic = false, 1374 - HEADING_ON => heading = true, 1375 - HEADING_OFF => heading = false, 1376 - QUOTE_ON => { 1377 - indent = indent.saturating_add(1); 1378 - max_w = base_max_w.saturating_sub(INDENT_PX * indent as u32); 1379 - } 1380 - QUOTE_OFF => { 1381 - indent = indent.saturating_sub(1); 1382 - max_w = base_max_w.saturating_sub(INDENT_PX * indent as u32); 1383 - } 1384 - _ => {} 1385 - } 1386 - i += 2; 1387 - continue; 1388 - } 1389 - 1390 - if b == b'\r' { 1391 - i += 1; 1392 - continue; 1393 - } 1394 - 1395 - if b == b'\n' { 1396 - emit!(ls, i); 1397 - ls = i + 1; 1398 - px = 0; 1399 - sp = ls; 1400 - sp_px = 0; 1401 - if lc >= max_l { 1402 - return (ls, lc); 1403 - } 1404 - i += 1; 1405 - continue; 1406 - } 1407 - 1408 - // UTF-8 multi-byte 1409 - if b >= 0xC0 { 1410 - let (repl, seq_len) = decode_utf8_to_ascii(buf, i); 1411 - let sty = current_style(bold, italic, heading); 1412 - let adv = fonts.advance(repl as char, sty) as u32; 1413 - px += adv; 1414 - if px > max_w { 1415 - if sp > ls { 1416 - emit!(ls, sp); 1417 - px -= sp_px; 1418 - ls = sp; 1419 - } else { 1420 - emit!(ls, i); 1421 - ls = i; 1422 - px = adv; 1423 - } 1424 - sp = ls; 1425 - sp_px = 0; 1426 - if lc >= max_l { 1427 - return (ls, lc); 1428 - } 1429 - } 1430 - i += seq_len; 1431 - continue; 1432 - } 1433 - if b >= 0x80 { 1434 - // stray continuation byte; skip without affecting layout 1435 - i += 1; 1436 - continue; 1437 - } 1438 - 1439 - let sty = current_style(bold, italic, heading); 1440 - let adv = fonts.advance_byte(b, sty) as u32; 1441 - 1442 - if b == b' ' { 1443 - px += adv; 1444 - sp = i + 1; 1445 - sp_px = px; 1446 - if px > max_w { 1447 - emit!(ls, i); 1448 - ls = i + 1; 1449 - px = 0; 1450 - sp = ls; 1451 - sp_px = 0; 1452 - if lc >= max_l { 1453 - return (ls, lc); 1454 - } 1455 - } 1456 - i += 1; 1457 - continue; 1458 - } 1459 - 1460 - px += adv; 1461 - if px > max_w { 1462 - if sp > ls { 1463 - // word-wrap at last space 1464 - emit!(ls, sp); 1465 - px -= sp_px; 1466 - ls = sp; 1467 - } else { 1468 - // no space; character-wrap 1469 - emit!(ls, i); 1470 - ls = i; 1471 - px = adv; 1472 - } 1473 - sp = ls; 1474 - sp_px = 0; 1475 - if lc >= max_l { 1476 - return (ls, lc); 1477 - } 1478 - } 1479 - 1480 - i += 1; 1481 - } 1482 - 1483 - if ls < n && lc < max_l { 1484 - let e = trim_trailing_cr(buf, ls, n); 1485 - if e > ls { 1486 - lines[lc] = LineSpan { 1487 - start: ls as u16, 1488 - len: (e - ls) as u16, 1489 - flags: LineSpan::pack_flags(bold, italic, heading), 1490 - indent, 1491 - }; 1492 - lc += 1; 1493 - } 1494 - } 1495 - 1496 - (n, lc) 1497 - } 1498 - 1499 - fn read_full<SPI: embedded_hal::spi::SpiDevice>( 1500 - svc: &mut Services<'_, SPI>, 1501 - name: &str, 1502 - offset: u32, 1503 - buf: &mut [u8], 1504 - ) -> Result<(), &'static str> { 1505 - let mut total = 0usize; 1506 - while total < buf.len() { 1507 - let n = svc.read_file_chunk(name, offset + total as u32, &mut buf[total..])?; 1508 - if n == 0 { 1509 - return Err("epub: unexpected EOF"); 1510 - } 1511 - total += n; 1512 - } 1513 - Ok(()) 1514 - } 1515 - 1516 - fn draw_chrome_text( 1517 - strip: &mut StripBuffer, 1518 - region: Region, 1519 - text: &str, 1520 - align: Alignment, 1521 - font: Option<&'static BitmapFont>, 1522 - ) { 1523 - region 1524 - .to_rect() 1525 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off)) 1526 - .draw(strip) 1527 - .unwrap(); 1528 - if text.is_empty() { 1529 - return; 1530 - } 1531 - if let Some(f) = font { 1532 - f.draw_aligned(strip, region, text, align, BinaryColor::On); 1533 - } else { 1534 - let tw = text.len() as u32 * 6; 1535 - let pos = align.position(region, Size::new(tw, 13)); 1536 - let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 1537 - Text::new(text, Point::new(pos.x, pos.y + 13), style) 1538 - .draw(strip) 1539 - .unwrap(); 1540 - } 1541 - } 1542 - 1543 - fn img_cache_name(hash: u32) -> [u8; 12] { 1544 - let h = hash & 0x00FF_FFFF; 1545 - let mut n = *b"IM000000.BIN"; 1546 - for i in 0..6 { 1547 - let nibble = ((h >> (20 - i * 4)) & 0xF) as u8; 1548 - n[2 + i] = if nibble < 10 { 1549 - b'0' + nibble 1550 - } else { 1551 - b'A' + nibble - 10 1552 - }; 1553 - } 1554 - n 1555 - } 1556 - 1557 - #[inline] 1558 - fn img_cache_str(buf: &[u8; 12]) -> &str { 1559 - core::str::from_utf8(buf).unwrap_or("IM000000.BIN") 1560 - } 1561 - 1562 - fn load_cached_image<SPI: embedded_hal::spi::SpiDevice>( 1563 - svc: &Services<'_, SPI>, 1564 - dir: &str, 1565 - name: &str, 1566 - ) -> Result<DecodedImage, &'static str> { 1567 - let size = svc 1568 - .file_size_pulp_sub(dir, name) 1569 - .map_err(|_| "no cache file")?; 1570 - if size < 5 { 1571 - return Err("cache file too small"); 1572 - } 1573 - let mut header = [0u8; 4]; 1574 - svc.read_pulp_sub_chunk(dir, name, 0, &mut header) 1575 - .map_err(|_| "read header failed")?; 1576 - let width = u16::from_le_bytes([header[0], header[1]]); 1577 - let height = u16::from_le_bytes([header[2], header[3]]); 1578 - if width == 0 || height == 0 { 1579 - return Err("zero dimensions in cache"); 1580 - } 1581 - let stride = (width as usize).div_ceil(8); 1582 - let data_len = stride * height as usize; 1583 - if size as usize != 4 + data_len { 1584 - return Err("cache size mismatch"); 1585 - } 1586 - let mut data = Vec::new(); 1587 - data.try_reserve_exact(data_len) 1588 - .map_err(|_| "OOM for cached image")?; 1589 - data.resize(data_len, 0); 1590 - svc.read_pulp_sub_chunk(dir, name, 4, &mut data) 1591 - .map_err(|_| "read data failed")?; 1592 - Ok(DecodedImage { 1593 - width, 1594 - height, 1595 - data, 1596 - stride, 1597 - }) 1598 - } 1599 - 1600 - fn save_cached_image<SPI: embedded_hal::spi::SpiDevice>( 1601 - svc: &Services<'_, SPI>, 1602 - dir: &str, 1603 - name: &str, 1604 - img: &DecodedImage, 1605 - ) -> Result<(), &'static str> { 1606 - let mut header = [0u8; 4]; 1607 - header[0..2].copy_from_slice(&img.width.to_le_bytes()); 1608 - header[2..4].copy_from_slice(&img.height.to_le_bytes()); 1609 - svc.write_pulp_sub(dir, name, &header)?; 1610 - svc.append_pulp_sub(dir, name, &img.data)?; 1611 - Ok(()) 1612 - } 1613 - 1614 - fn extract_zip_entry<SPI: embedded_hal::spi::SpiDevice>( 1615 - svc: &mut Services<'_, SPI>, 1616 - name: &str, 1617 - zip_index: &ZipIndex, 1618 - entry_idx: usize, 1619 - ) -> Result<Vec<u8>, &'static str> { 1620 - let entry = zip_index.entry(entry_idx); 1621 - 1622 - zip::extract_entry(entry, entry.local_offset, |offset, buf| { 1623 - svc.read_file_chunk(name, offset, buf) 1624 - }) 1625 - } 1626 - 1627 - impl App for ReaderApp { 1628 - fn on_enter(&mut self, ctx: &mut AppContext) { 1629 - let msg = ctx.message(); 1630 - let len = msg.len().min(32); 1631 - self.filename[..len].copy_from_slice(&msg[..len]); 1632 - self.filename_len = len; 1633 - 1634 - let n = self.filename_len.min(self.title.len()); 1635 - self.title[..n].copy_from_slice(&self.filename[..n]); 1636 - self.title_len = n; 1637 - 1638 - self.is_epub = epub::is_epub_filename(self.name()); 1639 - self.rebuild_quick_actions(); 1640 - self.reset_paging(); 1641 - self.ch_cache = Vec::new(); 1642 - self.file_size = 0; 1643 - self.chapter = 0; 1644 - self.error = None; 1645 - self.show_position = false; 1646 - self.goto_last_page = false; 1647 - self.restore_offset = None; 1648 - 1649 - self.apply_font_metrics(); 1650 - 1651 - self.state = State::NeedBookmark; 1652 - 1653 - log::info!("reader: opening {}", self.name()); 1654 - 1655 - ctx.mark_dirty(PAGE_REGION); 1656 - } 1657 - 1658 - fn on_exit(&mut self) { 1659 - self.line_count = 0; 1660 - self.buf_len = 0; 1661 - self.prefetch_page = NO_PREFETCH; 1662 - self.prefetch_len = 0; 1663 - self.restore_offset = None; 1664 - self.show_position = false; 1665 - self.ch_cache = Vec::new(); 1666 - self.page_img = None; 1667 - 1668 - if self.is_epub { 1669 - self.toc.clear(); 1670 - self.toc_source = None; 1671 - } 1672 - } 1673 - 1674 - fn on_suspend(&mut self) {} 1675 - 1676 - fn on_resume(&mut self, ctx: &mut AppContext) { 1677 - let font_changed = self.book_font_size_idx != self.applied_font_idx; 1678 - self.apply_font_metrics(); 1679 - if font_changed { 1680 - self.reset_paging(); 1681 - if self.is_epub && self.chapters_cached { 1682 - self.state = State::NeedIndex; 1683 - } else { 1684 - self.state = State::NeedPage; 1685 - } 1686 - } 1687 - ctx.mark_dirty(PAGE_REGION); 1688 - } 1689 - 1690 - fn needs_work(&self) -> bool { 1691 - matches!( 1692 - self.state, 1693 - State::NeedBookmark 1694 - | State::NeedInit 1695 - | State::NeedOpf 1696 - | State::NeedToc 1697 - | State::NeedCache 1698 - | State::NeedCacheChapter 1699 - | State::NeedIndex 1700 - | State::NeedPage 1701 - ) 1702 - } 1703 - 1704 - fn on_work<SPI: embedded_hal::spi::SpiDevice>( 1705 - &mut self, 1706 - svc: &mut Services<'_, SPI>, 1707 - ctx: &mut AppContext, 1708 - ) { 1709 - loop { 1710 - match self.state { 1711 - State::NeedBookmark => { 1712 - self.bookmark_load(svc.bookmarks()); 1713 - 1714 - let _ = svc.write_pulp(RECENT_FILE, &self.filename[..self.filename_len]); 1715 - 1716 - if self.is_epub { 1717 - self.zip.clear(); 1718 - self.meta = EpubMeta::new(); 1719 - self.spine = EpubSpine::new(); 1720 - self.chapters_cached = false; 1721 - self.goto_last_page = false; 1722 - self.state = State::NeedInit; 1723 - } else { 1724 - self.state = State::NeedPage; 1725 - } 1726 - continue; 1727 - } 1728 - 1729 - State::NeedInit => match self.epub_init_zip(svc) { 1730 - Ok(()) => { 1731 - self.state = State::NeedOpf; // yield; CD heap freed 1732 - } 1733 - Err(e) => { 1734 - log::info!("reader: epub init (zip) failed: {}", e); 1735 - self.error = Some(e); 1736 - self.state = State::Error; 1737 - ctx.mark_dirty(PAGE_REGION); 1738 - } 1739 - }, 1740 - 1741 - State::NeedOpf => match self.epub_init_opf(svc) { 1742 - Ok(()) => { 1743 - self.state = State::NeedToc; // yield; OPF heap freed 1744 - } 1745 - Err(e) => { 1746 - log::info!("reader: epub init (opf) failed: {}", e); 1747 - self.error = Some(e); 1748 - self.state = State::Error; 1749 - ctx.mark_dirty(PAGE_REGION); 1750 - } 1751 - }, 1752 - 1753 - State::NeedToc => { 1754 - if let Some(source) = self.toc_source.take() { 1755 - let (nb, nl) = self.name_copy(); 1756 - let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 1757 - let toc_idx = source.zip_index(); 1758 - 1759 - let mut toc_dir_buf = [0u8; 256]; 1760 - let toc_dir_len = { 1761 - let toc_path = self.zip.entry_name(toc_idx); 1762 - let dir = toc_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 1763 - let n = dir.len().min(toc_dir_buf.len()); 1764 - toc_dir_buf[..n].copy_from_slice(dir.as_bytes()); 1765 - n 1766 - }; 1767 - let toc_dir = 1768 - core::str::from_utf8(&toc_dir_buf[..toc_dir_len]).unwrap_or(""); 1769 - 1770 - match extract_zip_entry(svc, name, &self.zip, toc_idx) { 1771 - Ok(toc_data) => { 1772 - epub::parse_toc( 1773 - source, 1774 - &toc_data, 1775 - toc_dir, 1776 - &self.spine, 1777 - &self.zip, 1778 - &mut self.toc, 1779 - ); 1780 - log::info!("epub: TOC has {} entries", self.toc.len()); 1781 - } 1782 - Err(e) => { 1783 - log::warn!("epub: failed to read TOC: {}", e); 1784 - } 1785 - } 1786 - } 1787 - self.rebuild_quick_actions(); 1788 - self.state = State::NeedCache; 1789 - continue; 1790 - } 1791 - 1792 - State::NeedCache => match self.epub_check_cache(svc) { 1793 - Ok(true) => { 1794 - self.state = State::NeedIndex; 1795 - continue; 1796 - } 1797 - Ok(false) => { 1798 - self.state = State::NeedCacheChapter; // yield; more chapters remain 1799 - ctx.mark_dirty(LOADING_REGION); 1800 - } 1801 - Err(e) => { 1802 - log::info!("reader: cache check failed: {}", e); 1803 - self.error = Some(e); 1804 - self.state = State::Error; 1805 - ctx.mark_dirty(PAGE_REGION); 1806 - } 1807 - }, 1808 - 1809 - State::NeedCacheChapter => match self.epub_cache_one_chapter(svc) { 1810 - Ok(true) => { 1811 - ctx.mark_dirty(LOADING_REGION); 1812 - } 1813 - Ok(false) => { 1814 - self.state = State::NeedIndex; 1815 - continue; 1816 - } 1817 - Err(e) => { 1818 - log::info!("reader: cache ch{} failed: {}", self.cache_chapter, e); 1819 - self.error = Some(e); 1820 - self.state = State::Error; 1821 - ctx.mark_dirty(PAGE_REGION); 1822 - } 1823 - }, 1824 - 1825 - State::NeedIndex => { 1826 - let want_last = self.goto_last_page; 1827 - self.goto_last_page = false; 1828 - 1829 - self.epub_index_chapter(); 1830 - 1831 - if self.try_cache_chapter(svc) { 1832 - self.preindex_all_pages(); 1833 - } 1834 - 1835 - if want_last { 1836 - match self.scan_to_last_page(svc) { 1837 - Ok(()) => { 1838 - self.state = State::Ready; 1839 - ctx.mark_dirty(PAGE_REGION); 1840 - } 1841 - Err(e) => { 1842 - self.error = Some(e); 1843 - self.state = State::Error; 1844 - ctx.mark_dirty(PAGE_REGION); 1845 - } 1846 - } 1847 - } else { 1848 - self.state = State::NeedPage; 1849 - continue; 1850 - } 1851 - } 1852 - 1853 - State::NeedPage => { 1854 - if let Some(target_off) = self.restore_offset.take() { 1855 - self.page = 0; 1856 - loop { 1857 - match self.load_and_prefetch(svc) { 1858 - Ok(()) => {} 1859 - Err(e) => { 1860 - self.error = Some(e); 1861 - self.state = State::Error; 1862 - ctx.mark_dirty(PAGE_REGION); 1863 - break; 1864 - } 1865 - } 1866 - if self.page + 1 >= self.total_pages { 1867 - break; 1868 - } 1869 - if self.offsets[self.page + 1] > target_off { 1870 - break; 1871 - } 1872 - self.page += 1; 1873 - } 1874 - if self.state != State::Error { 1875 - self.state = State::Ready; 1876 - ctx.mark_dirty(PAGE_REGION); 1877 - } 1878 - } else { 1879 - match self.load_and_prefetch(svc) { 1880 - Ok(()) => { 1881 - self.state = State::Ready; 1882 - ctx.mark_dirty(PAGE_REGION); 1883 - } 1884 - Err(e) => { 1885 - log::info!("reader: load failed: {}", e); 1886 - self.error = Some(e); 1887 - self.state = State::Error; 1888 - ctx.mark_dirty(PAGE_REGION); 1889 - } 1890 - } 1891 - } 1892 - } 1893 - 1894 - _ => {} 1895 - } 1896 - break; 1897 - } 1898 - } 1899 - 1900 - fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition { 1901 - if self.state == State::ShowToc { 1902 - match event { 1903 - ActionEvent::Press(Action::Back) => { 1904 - self.state = State::Ready; 1905 - ctx.mark_dirty(PAGE_REGION); 1906 - return Transition::None; 1907 - } 1908 - ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => { 1909 - let len = self.toc.len(); 1910 - if len > 0 { 1911 - if self.toc_selected + 1 < len { 1912 - self.toc_selected += 1; 1913 - } else { 1914 - self.toc_selected = 0; 1915 - self.toc_scroll = 0; 1916 - } 1917 - let vis = (TEXT_AREA_H / self.font_line_h) as usize; 1918 - if self.toc_selected >= self.toc_scroll + vis { 1919 - self.toc_scroll = self.toc_selected + 1 - vis; 1920 - } 1921 - ctx.mark_dirty(PAGE_REGION); 1922 - } 1923 - return Transition::None; 1924 - } 1925 - ActionEvent::Press(Action::Prev) | ActionEvent::Repeat(Action::Prev) => { 1926 - let len = self.toc.len(); 1927 - if len > 0 { 1928 - if self.toc_selected > 0 { 1929 - self.toc_selected -= 1; 1930 - } else { 1931 - self.toc_selected = len - 1; 1932 - let vis = (TEXT_AREA_H / self.font_line_h) as usize; 1933 - if self.toc_selected >= vis { 1934 - self.toc_scroll = self.toc_selected + 1 - vis; 1935 - } 1936 - } 1937 - if self.toc_selected < self.toc_scroll { 1938 - self.toc_scroll = self.toc_selected; 1939 - } 1940 - ctx.mark_dirty(PAGE_REGION); 1941 - } 1942 - return Transition::None; 1943 - } 1944 - ActionEvent::Press(Action::Select) | ActionEvent::Press(Action::NextJump) => { 1945 - let entry = &self.toc.entries[self.toc_selected]; 1946 - if entry.spine_idx != 0xFFFF { 1947 - log::info!( 1948 - "toc: jumping to \"{}\" -> spine {}", 1949 - entry.title_str(), 1950 - entry.spine_idx 1951 - ); 1952 - self.chapter = entry.spine_idx; 1953 - self.page = 0; 1954 - self.goto_last_page = false; 1955 - self.state = State::NeedIndex; 1956 - } else { 1957 - log::warn!( 1958 - "toc: entry \"{}\" unresolved (spine_idx=0xFFFF), ignoring", 1959 - entry.title_str() 1960 - ); 1961 - self.state = State::Ready; 1962 - ctx.mark_dirty(PAGE_REGION); 1963 - } 1964 - return Transition::None; 1965 - } 1966 - _ => return Transition::None, 1967 - } 1968 - } 1969 - 1970 - match event { 1971 - ActionEvent::Press(Action::Back) => Transition::Pop, 1972 - ActionEvent::LongPress(Action::Back) => Transition::Home, 1973 - 1974 - ActionEvent::LongPress(Action::Next) => { 1975 - if self.state == State::Ready { 1976 - self.show_position = true; 1977 - } 1978 - self.page_forward(); 1979 - Transition::None 1980 - } 1981 - ActionEvent::LongPress(Action::Prev) => { 1982 - if self.state == State::Ready { 1983 - self.show_position = true; 1984 - } 1985 - self.page_backward(); 1986 - Transition::None 1987 - } 1988 - 1989 - ActionEvent::Release(Action::Next) | ActionEvent::Release(Action::Prev) => { 1990 - if self.show_position { 1991 - self.show_position = false; 1992 - ctx.mark_dirty(POSITION_OVERLAY); 1993 - } 1994 - Transition::None 1995 - } 1996 - 1997 - ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => { 1998 - self.page_forward(); 1999 - Transition::None 2000 - } 2001 - 2002 - ActionEvent::Press(Action::Prev) | ActionEvent::Repeat(Action::Prev) => { 2003 - self.page_backward(); 2004 - Transition::None 2005 - } 2006 - 2007 - ActionEvent::Press(Action::NextJump) | ActionEvent::Repeat(Action::NextJump) => { 2008 - self.jump_forward(); 2009 - Transition::None 2010 - } 2011 - 2012 - ActionEvent::Press(Action::PrevJump) | ActionEvent::Repeat(Action::PrevJump) => { 2013 - self.jump_backward(); 2014 - Transition::None 2015 - } 2016 - 2017 - _ => Transition::None, 2018 - } 2019 - } 2020 - 2021 - fn quick_actions(&self) -> &[QuickAction] { 2022 - &self.qa_buf[..self.qa_count] 2023 - } 2024 - 2025 - fn on_quick_trigger(&mut self, id: u8, ctx: &mut AppContext) { 2026 - match id { 2027 - QA_PREV_CHAPTER => { 2028 - if self.is_epub && self.chapter > 0 { 2029 - self.chapter -= 1; 2030 - self.goto_last_page = false; 2031 - self.state = State::NeedIndex; 2032 - } 2033 - } 2034 - QA_NEXT_CHAPTER => { 2035 - if self.is_epub && (self.chapter as usize + 1) < self.spine.len() { 2036 - self.chapter += 1; 2037 - self.goto_last_page = false; 2038 - self.state = State::NeedIndex; 2039 - } 2040 - } 2041 - QA_TOC => { 2042 - if self.is_epub && !self.toc.is_empty() { 2043 - log::info!("toc: opening ({} entries)", self.toc.len()); 2044 - self.toc_selected = 0; 2045 - self.toc_scroll = 0; 2046 - for i in 0..self.toc.len() { 2047 - if self.toc.entries[i].spine_idx == self.chapter { 2048 - self.toc_selected = i; 2049 - let vis = (TEXT_AREA_H / self.font_line_h) as usize; 2050 - if self.toc_selected >= vis { 2051 - self.toc_scroll = self.toc_selected + 1 - vis; 2052 - } 2053 - break; 2054 - } 2055 - } 2056 - self.state = State::ShowToc; 2057 - ctx.mark_dirty(PAGE_REGION); 2058 - } 2059 - } 2060 - _ => {} 2061 - } 2062 - } 2063 - 2064 - fn on_quick_cycle_update(&mut self, id: u8, value: u8, _ctx: &mut AppContext) { 2065 - if id == QA_FONT_SIZE { 2066 - self.book_font_size_idx = value; 2067 - self.apply_font_metrics(); 2068 - if self.state == State::Ready { 2069 - if self.is_epub && self.chapters_cached { 2070 - self.state = State::NeedIndex; 2071 - } else { 2072 - self.state = State::NeedPage; 2073 - } 2074 - } 2075 - self.rebuild_quick_actions(); 2076 - } 2077 - } 2078 - 2079 - fn draw(&self, strip: &mut StripBuffer) { 2080 - let cf = self.chrome_font; 2081 - 2082 - draw_chrome_text( 2083 - strip, 2084 - HEADER_REGION, 2085 - self.display_name(), 2086 - Alignment::CenterLeft, 2087 - cf, 2088 - ); 2089 - 2090 - if self.state == State::ShowToc { 2091 - draw_chrome_text(strip, STATUS_REGION, "Contents", Alignment::CenterRight, cf); 2092 - } else if self.is_epub && !self.spine.is_empty() { 2093 - let mut sbuf = StackFmt::<32>::new(); 2094 - if self.spine.len() > 1 { 2095 - if self.fully_indexed { 2096 - let _ = write!( 2097 - sbuf, 2098 - "Ch{}/{} {}/{}", 2099 - self.chapter + 1, 2100 - self.spine.len(), 2101 - self.page + 1, 2102 - self.total_pages 2103 - ); 2104 - } else { 2105 - let _ = write!( 2106 - sbuf, 2107 - "Ch{}/{} p{}", 2108 - self.chapter + 1, 2109 - self.spine.len(), 2110 - self.page + 1 2111 - ); 2112 - } 2113 - } else if self.fully_indexed { 2114 - let _ = write!(sbuf, "{}/{}", self.page + 1, self.total_pages); 2115 - } else { 2116 - let _ = write!(sbuf, "p{}", self.page + 1); 2117 - } 2118 - draw_chrome_text( 2119 - strip, 2120 - STATUS_REGION, 2121 - sbuf.as_str(), 2122 - Alignment::CenterRight, 2123 - cf, 2124 - ); 2125 - } else if self.file_size > 0 { 2126 - let mut sbuf = StackFmt::<24>::new(); 2127 - if self.fully_indexed { 2128 - let _ = write!(sbuf, "{}/{}", self.page + 1, self.total_pages); 2129 - } else { 2130 - let _ = write!(sbuf, "{} | {}%", self.page + 1, self.progress_pct()); 2131 - } 2132 - draw_chrome_text( 2133 - strip, 2134 - STATUS_REGION, 2135 - sbuf.as_str(), 2136 - Alignment::CenterRight, 2137 - cf, 2138 - ); 2139 - } 2140 - 2141 - if let Some(msg) = self.error { 2142 - draw_chrome_text(strip, LOADING_REGION, msg, Alignment::CenterLeft, cf); 2143 - return; 2144 - } 2145 - 2146 - if self.state != State::Ready && self.state != State::Error && self.state != State::ShowToc 2147 - { 2148 - let mut lbuf = StackFmt::<48>::new(); 2149 - match self.state { 2150 - State::NeedCache | State::NeedCacheChapter => { 2151 - let _ = write!( 2152 - lbuf, 2153 - "Caching ch {}/{}...", 2154 - self.cache_chapter + 1, 2155 - self.spine.len() 2156 - ); 2157 - } 2158 - State::NeedIndex => { 2159 - let _ = write!(lbuf, "Indexing..."); 2160 - } 2161 - State::NeedPage => { 2162 - let _ = write!(lbuf, "Loading..."); 2163 - } 2164 - _ => { 2165 - let _ = write!(lbuf, "Loading..."); 2166 - } 2167 - } 2168 - draw_chrome_text( 2169 - strip, 2170 - LOADING_REGION, 2171 - lbuf.as_str(), 2172 - Alignment::CenterLeft, 2173 - cf, 2174 - ); 2175 - return; 2176 - } 2177 - 2178 - if self.state == State::ShowToc { 2179 - let toc_len = self.toc.len(); 2180 - if self.fonts.is_some() { 2181 - let font = fonts::body_font(self.book_font_size_idx); 2182 - let line_h = font.line_height as i32; 2183 - let ascent = font.ascent as i32; 2184 - let vis_max = (TEXT_AREA_H / font.line_height) as usize; 2185 - let visible = vis_max.min(toc_len.saturating_sub(self.toc_scroll)); 2186 - for i in 0..visible { 2187 - let idx = self.toc_scroll + i; 2188 - let entry = &self.toc.entries[idx]; 2189 - let y_top = TEXT_Y as i32 + i as i32 * line_h; 2190 - let baseline = y_top + ascent; 2191 - let selected = idx == self.toc_selected; 2192 - 2193 - if selected { 2194 - Rectangle::new( 2195 - Point::new(0, y_top), 2196 - Size::new(SCREEN_W as u32, line_h as u32), 2197 - ) 2198 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 2199 - .draw(strip) 2200 - .unwrap(); 2201 - } 2202 - 2203 - let fg = if selected { 2204 - BinaryColor::Off 2205 - } else { 2206 - BinaryColor::On 2207 - }; 2208 - let mut cx = MARGIN as i32; 2209 - if entry.spine_idx != 0xFFFF && entry.spine_idx == self.chapter { 2210 - cx += font.draw_char_fg(strip, '>', fg, cx, baseline) as i32; 2211 - cx += font.draw_char_fg(strip, ' ', fg, cx, baseline) as i32; 2212 - } 2213 - font.draw_str_fg(strip, entry.title_str(), fg, cx, baseline); 2214 - } 2215 - } else { 2216 - let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 2217 - let vis_max = (TEXT_AREA_H / LINE_H) as usize; 2218 - let visible = vis_max.min(toc_len.saturating_sub(self.toc_scroll)); 2219 - for i in 0..visible { 2220 - let idx = self.toc_scroll + i; 2221 - let entry = &self.toc.entries[idx]; 2222 - let y = TEXT_Y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 2223 - let marker = if idx == self.toc_selected { "> " } else { " " }; 2224 - Text::new(marker, Point::new(0, y), style) 2225 - .draw(strip) 2226 - .unwrap(); 2227 - Text::new(entry.title_str(), Point::new(MARGIN as i32, y), style) 2228 - .draw(strip) 2229 - .unwrap(); 2230 - } 2231 - } 2232 - return; 2233 - } 2234 - 2235 - if let Some(ref fs) = self.fonts { 2236 - let line_h = self.font_line_h as i32; 2237 - let ascent = self.font_ascent as i32; 2238 - for i in 0..self.line_count { 2239 - let span = self.lines[i]; 2240 - 2241 - if span.is_image() { 2242 - if span.is_image_origin() { 2243 - let y_top = TEXT_Y as i32 + i as i32 * line_h; 2244 - if let Some(ref img) = self.page_img { 2245 - let img_x = 2246 - MARGIN as i32 + ((TEXT_W as i32 - img.width as i32) / 2).max(0); 2247 - strip.blit_1bpp( 2248 - &img.data, 2249 - 0, 2250 - img.width as usize, 2251 - img.height as usize, 2252 - img.stride, 2253 - img_x, 2254 - y_top, 2255 - true, 2256 - ); 2257 - } else { 2258 - let baseline = y_top + ascent; 2259 - fs.draw_str( 2260 - strip, 2261 - "[image]", 2262 - fonts::Style::Italic, 2263 - MARGIN as i32, 2264 - baseline, 2265 - ); 2266 - } 2267 - } 2268 - continue; 2269 - } 2270 - 2271 - let start = span.start as usize; 2272 - let end = start + span.len as usize; 2273 - let baseline = TEXT_Y as i32 + i as i32 * line_h + ascent; 2274 - let x_indent = INDENT_PX as i32 * span.indent as i32; 2275 - 2276 - let line = &self.buf[start..end]; 2277 - let mut cx = MARGIN as i32 + x_indent; 2278 - let mut sty = span.style(); 2279 - let mut j = 0usize; 2280 - while j < line.len() { 2281 - let b = line[j]; 2282 - if b == MARKER && j + 1 < line.len() { 2283 - sty = match line[j + 1] { 2284 - BOLD_ON => fonts::Style::Bold, 2285 - ITALIC_ON => fonts::Style::Italic, 2286 - HEADING_ON => fonts::Style::Heading, 2287 - BOLD_OFF | ITALIC_OFF | HEADING_OFF => fonts::Style::Regular, 2288 - _ => sty, 2289 - }; 2290 - j += 2; 2291 - continue; 2292 - } 2293 - if b >= 0xC0 { 2294 - let (repl, seq_len) = decode_utf8_to_ascii(line, j); 2295 - if (bitmap::FIRST_CHAR..=bitmap::LAST_CHAR).contains(&repl) { 2296 - cx += fs.draw_char(strip, repl as char, sty, cx, baseline) as i32; 2297 - } 2298 - j += seq_len; 2299 - continue; 2300 - } 2301 - if !(bitmap::FIRST_CHAR..=bitmap::LAST_CHAR).contains(&b) { 2302 - j += 1; 2303 - continue; // stray continuation byte or control char 2304 - } 2305 - cx += fs.draw_char(strip, b as char, sty, cx, baseline) as i32; 2306 - j += 1; 2307 - } 2308 - } 2309 - } else { 2310 - let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 2311 - for i in 0..self.line_count { 2312 - let span = self.lines[i]; 2313 - let start = span.start as usize; 2314 - let end = start + span.len as usize; 2315 - let text = core::str::from_utf8(&self.buf[start..end]).unwrap_or(""); 2316 - let y = TEXT_Y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 2317 - Text::new(text, Point::new(MARGIN as i32, y), style) 2318 - .draw(strip) 2319 - .unwrap(); 2320 - } 2321 - } 2322 - 2323 - if self.state == State::Ready && (self.file_size > 0 || self.is_epub) { 2324 - let pct = self.progress_pct() as u32; 2325 - let filled_w = (PROGRESS_W as u32 * pct / 100).min(PROGRESS_W as u32); 2326 - if filled_w > 0 { 2327 - Rectangle::new( 2328 - Point::new(MARGIN as i32, PROGRESS_Y as i32), 2329 - Size::new(filled_w, PROGRESS_H as u32), 2330 - ) 2331 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 2332 - .draw(strip) 2333 - .unwrap(); 2334 - } 2335 - } 2336 - 2337 - if self.show_position 2338 - && self.state == State::Ready 2339 - && POSITION_OVERLAY.intersects(strip.logical_window()) 2340 - { 2341 - let mut pbuf = StackFmt::<48>::new(); 2342 - if self.is_epub && self.spine.len() > 1 { 2343 - if self.fully_indexed { 2344 - let _ = write!( 2345 - pbuf, 2346 - "Ch {}/{} Page {}/{}", 2347 - self.chapter + 1, 2348 - self.spine.len(), 2349 - self.page + 1, 2350 - self.total_pages 2351 - ); 2352 - } else { 2353 - let _ = write!( 2354 - pbuf, 2355 - "Ch {}/{} Page {}", 2356 - self.chapter + 1, 2357 - self.spine.len(), 2358 - self.page + 1 2359 - ); 2360 - } 2361 - } else if self.fully_indexed { 2362 - let _ = write!(pbuf, "Page {}/{}", self.page + 1, self.total_pages); 2363 - } else { 2364 - let _ = write!(pbuf, "Page {} ({}%)", self.page + 1, self.progress_pct()); 2365 - } 2366 - 2367 - POSITION_OVERLAY 2368 - .to_rect() 2369 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 2370 - .draw(strip) 2371 - .unwrap(); 2372 - let text = pbuf.as_str(); 2373 - if let Some(f) = cf { 2374 - f.draw_aligned( 2375 - strip, 2376 - POSITION_OVERLAY, 2377 - text, 2378 - Alignment::Center, 2379 - BinaryColor::Off, 2380 - ); 2381 - } else { 2382 - let tw = text.len() as u32 * 6; 2383 - let pos = Alignment::Center.position(POSITION_OVERLAY, Size::new(tw, 13)); 2384 - let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::Off); 2385 - Text::new(text, Point::new(pos.x, pos.y + 13), style) 2386 - .draw(strip) 2387 - .unwrap(); 2388 - } 2389 - } 2390 - } 2391 - }
+570
src/apps/reader/epub_pipeline.rs
··· 1 + // epub init, chapter cache pipeline, and background cache state machine 2 + 3 + use alloc::vec::Vec; 4 + use core::cell::RefCell; 5 + 6 + use smol_epub::cache; 7 + use smol_epub::epub; 8 + 9 + use crate::kernel::KernelHandle; 10 + use crate::kernel::work_queue; 11 + 12 + use super::{BgCacheState, CHAPTER_CACHE_MAX, EOCD_TAIL, PAGE_BUF, ReaderApp, ZipIndex}; 13 + 14 + impl ReaderApp { 15 + pub(super) fn epub_init_zip(&mut self, k: &mut KernelHandle<'_>) -> Result<(), &'static str> { 16 + let (nb, nl) = self.name_copy(); 17 + let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 18 + 19 + let epub_size = k.sync_file_size(name)?; 20 + if epub_size < 22 { 21 + return Err("epub: file too small"); 22 + } 23 + self.epub_file_size = epub_size; 24 + self.epub_name_hash = cache::fnv1a(name.as_bytes()); 25 + self.cache_dir = cache::dir_name_for_hash(self.epub_name_hash); 26 + 27 + let tail_size = (epub_size as usize).min(EOCD_TAIL); 28 + let tail_offset = epub_size - tail_size as u32; 29 + let n = k.sync_read_chunk(name, tail_offset, &mut self.buf[..tail_size])?; 30 + let (cd_offset, cd_size) = ZipIndex::parse_eocd(&self.buf[..n], epub_size)?; 31 + 32 + log::info!( 33 + "epub: CD at offset {} size {} ({} file bytes)", 34 + cd_offset, 35 + cd_size, 36 + epub_size 37 + ); 38 + 39 + let mut cd_buf = Vec::new(); 40 + cd_buf 41 + .try_reserve_exact(cd_size as usize) 42 + .map_err(|_| "epub: CD too large for memory")?; 43 + cd_buf.resize(cd_size as usize, 0); 44 + super::read_full(k, name, cd_offset, &mut cd_buf)?; 45 + self.zip.clear(); 46 + self.zip.parse_central_directory(&cd_buf)?; 47 + drop(cd_buf); 48 + 49 + log::info!("epub: {} entries in ZIP", self.zip.count()); 50 + 51 + Ok(()) 52 + } 53 + 54 + pub(super) fn epub_init_opf(&mut self, k: &mut KernelHandle<'_>) -> Result<(), &'static str> { 55 + let (nb, nl) = self.name_copy(); 56 + let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 57 + 58 + let mut opf_path_buf = [0u8; epub::OPF_PATH_CAP]; 59 + let opf_path_len = if let Some(container_idx) = self.zip.find("META-INF/container.xml") { 60 + let container_data = super::extract_zip_entry(k, name, &self.zip, container_idx)?; 61 + let len = epub::parse_container(&container_data, &mut opf_path_buf)?; 62 + drop(container_data); 63 + len 64 + } else { 65 + log::warn!("epub: no container.xml, scanning for .opf"); 66 + epub::find_opf_in_zip(&self.zip, &mut opf_path_buf)? 67 + }; 68 + 69 + let opf_path = core::str::from_utf8(&opf_path_buf[..opf_path_len]) 70 + .map_err(|_| "epub: bad opf path")?; 71 + 72 + log::info!("epub: OPF at {}", opf_path); 73 + 74 + let opf_idx = self 75 + .zip 76 + .find(opf_path) 77 + .or_else(|| self.zip.find_icase(opf_path)) 78 + .ok_or("epub: opf not found in zip")?; 79 + let opf_data = super::extract_zip_entry(k, name, &self.zip, opf_idx)?; 80 + 81 + let opf_dir = opf_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 82 + epub::parse_opf( 83 + &opf_data, 84 + opf_dir, 85 + &self.zip, 86 + &mut self.meta, 87 + &mut self.spine, 88 + )?; 89 + 90 + // defer TOC to NeedToc to avoid stack overflow while OPF is live 91 + self.toc_source = epub::find_toc_source(&opf_data, opf_dir, &self.zip); 92 + drop(opf_data); 93 + 94 + log::info!( 95 + "epub: \"{}\" by {} -- {} chapters", 96 + self.meta.title_str(), 97 + self.meta.author_str(), 98 + self.spine.len() 99 + ); 100 + 101 + let tlen = self.meta.title_len as usize; 102 + if tlen > 0 { 103 + let n = tlen.min(self.title.len()); 104 + self.title[..n].copy_from_slice(&self.meta.title[..n]); 105 + self.title_len = n; 106 + 107 + if let Err(e) = k.sync_save_title(name, self.meta.title_str()) { 108 + log::warn!("epub: failed to save title mapping: {}", e); 109 + } 110 + } 111 + 112 + self.toc.clear(); 113 + 114 + Ok(()) 115 + } 116 + 117 + pub(super) fn epub_check_cache( 118 + &mut self, 119 + k: &mut KernelHandle<'_>, 120 + ) -> Result<bool, &'static str> { 121 + let dir_buf = self.cache_dir; 122 + let dir = cache::dir_name_str(&dir_buf); 123 + 124 + // read into self.buf to avoid ~2 KB stack temporaries 125 + let meta_cap = cache::META_MAX_SIZE.min(self.buf.len()); 126 + if let Ok(n) = 127 + k.sync_read_app_subdir_chunk(dir, cache::META_FILE, 0, &mut self.buf[..meta_cap]) 128 + && let Ok(count) = cache::parse_cache_meta( 129 + &self.buf[..n], 130 + self.epub_file_size, 131 + self.epub_name_hash, 132 + self.spine.len(), 133 + &mut self.chapter_sizes, 134 + ) 135 + { 136 + self.chapters_cached = true; 137 + for i in 0..count { 138 + self.ch_cached[i] = true; 139 + } 140 + log::info!("epub: cache hit ({} chapters)", count); 141 + return Ok(true); 142 + } 143 + 144 + log::info!("epub: building cache for {} chapters", self.spine.len()); 145 + k.sync_ensure_app_subdir(dir)?; 146 + self.cache_chapter = 0; 147 + Ok(false) 148 + } 149 + 150 + pub(super) fn epub_finish_cache( 151 + &mut self, 152 + k: &mut KernelHandle<'_>, 153 + ) -> Result<bool, &'static str> { 154 + let dir_buf = self.cache_dir; 155 + let dir = cache::dir_name_str(&dir_buf); 156 + let spine_len = self.spine.len(); 157 + 158 + let mut meta_buf = [0u8; cache::META_MAX_SIZE]; 159 + let meta_len = cache::encode_cache_meta( 160 + self.epub_file_size, 161 + self.epub_name_hash, 162 + &self.chapter_sizes[..spine_len], 163 + &mut meta_buf, 164 + ); 165 + k.sync_write_app_subdir(dir, cache::META_FILE, &meta_buf[..meta_len])?; 166 + 167 + self.chapters_cached = true; 168 + log::info!("epub: cache complete"); 169 + Ok(false) 170 + } 171 + 172 + // synchronously cache a single chapter by index; skipped if already cached 173 + pub(super) fn epub_cache_single_chapter( 174 + &mut self, 175 + k: &mut KernelHandle<'_>, 176 + ch: usize, 177 + ) -> Result<(), &'static str> { 178 + if ch >= self.spine.len() || self.ch_cached[ch] { 179 + return Ok(()); 180 + } 181 + 182 + let dir_buf = self.cache_dir; 183 + let dir = cache::dir_name_str(&dir_buf); 184 + let (nb, nl) = self.name_copy(); 185 + let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 186 + 187 + let entry_idx = self.spine.items[ch] as usize; 188 + let entry = *self.zip.entry(entry_idx); 189 + 190 + let ch_file = cache::chapter_file_name(ch as u16); 191 + let ch_str = cache::chapter_file_str(&ch_file); 192 + 193 + k.sync_write_app_subdir(dir, ch_str, &[])?; 194 + let text_size = { 195 + let k_cell = RefCell::new(&mut *k); 196 + cache::stream_strip_entry( 197 + &entry, 198 + entry.local_offset, 199 + |offset, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, offset, buf), 200 + |chunk| { 201 + k_cell 202 + .borrow_mut() 203 + .sync_append_app_subdir(dir, ch_str, chunk) 204 + }, 205 + )? 206 + }; 207 + 208 + self.chapter_sizes[ch] = text_size; 209 + self.ch_cached[ch] = true; 210 + 211 + log::info!( 212 + "epub: sync-cached ch{}/{} = {} bytes", 213 + ch, 214 + self.spine.len(), 215 + text_size 216 + ); 217 + Ok(()) 218 + } 219 + 220 + // extract chapter XHTML from ZIP and dispatch to worker for HTML stripping 221 + pub(super) fn epub_dispatch_chapter_strip( 222 + &mut self, 223 + k: &mut KernelHandle<'_>, 224 + ) -> Result<bool, &'static str> { 225 + let spine_len = self.spine.len(); 226 + 227 + // advance past chapters that were already sync-cached 228 + while (self.cache_chapter as usize) < spine_len 229 + && self.ch_cached[self.cache_chapter as usize] 230 + { 231 + self.cache_chapter += 1; 232 + } 233 + 234 + // priority: sync-cache chapters adjacent to the reading position 235 + // before continuing the sequential scan, so forward/backward 236 + // chapter navigation is always instant 237 + let reading_ch = self.chapter as usize; 238 + for &adj in &[reading_ch + 1, reading_ch.saturating_sub(1)] { 239 + if adj < spine_len && adj != reading_ch && !self.ch_cached[adj] { 240 + log::info!( 241 + "epub: priority cache ch{} (adjacent to ch{})", 242 + adj, 243 + reading_ch, 244 + ); 245 + if let Err(e) = self.epub_cache_single_chapter(k, adj) { 246 + log::warn!("epub: priority cache ch{} failed: {}", adj, e); 247 + } 248 + } 249 + } 250 + 251 + let ch = self.cache_chapter as usize; 252 + if ch >= spine_len { 253 + return self.epub_finish_cache(k); 254 + } 255 + 256 + // large chapters need ~2x their uncompressed size in heap 257 + // (extract Vec + strip output Vec simultaneously); on a 140 KB 258 + // heap anything over ~32 KB risks OOM in the worker; fall back 259 + // to the streaming pipeline which uses fixed ~51 KB overhead 260 + const ASYNC_THRESHOLD: u32 = 32768; 261 + let entry_idx = self.spine.items[ch] as usize; 262 + let uncomp = self.zip.entry(entry_idx).uncomp_size; 263 + if uncomp > ASYNC_THRESHOLD { 264 + log::info!( 265 + "epub: ch{}/{} large ({} bytes), sync-caching", 266 + ch, 267 + spine_len, 268 + uncomp, 269 + ); 270 + self.epub_cache_single_chapter(k, ch)?; 271 + self.cache_chapter += 1; 272 + return Ok(true); 273 + } 274 + 275 + let dir_buf = self.cache_dir; 276 + let dir = cache::dir_name_str(&dir_buf); 277 + let ch_file = cache::chapter_file_name(ch as u16); 278 + let ch_str = cache::chapter_file_str(&ch_file); 279 + 280 + // truncate any stale data before the worker produces output 281 + k.sync_write_app_subdir(dir, ch_str, &[])?; 282 + 283 + let (nb, nl) = self.name_copy(); 284 + let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 285 + 286 + // extract full XHTML into memory; if OOM fall back to sync 287 + let xhtml = match super::extract_zip_entry(k, epub_name, &self.zip, entry_idx) { 288 + Ok(data) => data, 289 + Err(e) => { 290 + log::info!( 291 + "epub: ch{}/{} extract failed ({}), sync-caching", 292 + ch, 293 + spine_len, 294 + e, 295 + ); 296 + self.epub_cache_single_chapter(k, ch)?; 297 + self.cache_chapter += 1; 298 + return Ok(true); 299 + } 300 + }; 301 + 302 + log::info!( 303 + "epub: dispatch ch{}/{} ({} bytes XHTML) to worker", 304 + ch, 305 + spine_len, 306 + xhtml.len() 307 + ); 308 + 309 + let task = work_queue::WorkTask::StripChapter { 310 + chapter_idx: ch as u16, 311 + xhtml, 312 + }; 313 + if !work_queue::submit(self.work_gen, task) { 314 + return Err("cache: worker channel full"); 315 + } 316 + Ok(true) 317 + } 318 + 319 + // poll worker for a completed chapter-strip result 320 + pub(super) fn epub_recv_chapter_strip( 321 + &mut self, 322 + k: &mut KernelHandle<'_>, 323 + ) -> Result<Option<bool>, &'static str> { 324 + let result = match work_queue::try_recv() { 325 + Some(r) if r.is_current() => r, 326 + Some(_) => return Ok(None), // stale generation -- discard 327 + None => return Ok(None), // worker still busy 328 + }; 329 + 330 + match result.outcome { 331 + work_queue::WorkOutcome::ChapterReady { chapter_idx, text } => { 332 + let ch = chapter_idx as usize; 333 + let text_size = text.len() as u32; 334 + 335 + // if the user sync-cached this chapter while the worker 336 + // was processing, skip the SD write 337 + if !self.ch_cached[ch] { 338 + let dir_buf = self.cache_dir; 339 + let dir = cache::dir_name_str(&dir_buf); 340 + let ch_file = cache::chapter_file_name(chapter_idx); 341 + let ch_str = cache::chapter_file_str(&ch_file); 342 + 343 + k.sync_write_app_subdir(dir, ch_str, &text)?; 344 + self.chapter_sizes[ch] = text_size; 345 + } 346 + self.ch_cached[ch] = true; 347 + drop(text); 348 + 349 + log::info!( 350 + "epub: cached ch{}/{} = {} bytes", 351 + ch, 352 + self.spine.len(), 353 + text_size 354 + ); 355 + 356 + self.cache_chapter += 1; 357 + 358 + if (self.cache_chapter as usize) < self.spine.len() { 359 + Ok(Some(true)) 360 + } else { 361 + self.epub_finish_cache(k)?; 362 + Ok(Some(false)) 363 + } 364 + } 365 + work_queue::WorkOutcome::ChapterFailed { chapter_idx, error } => { 366 + let ch = chapter_idx as usize; 367 + log::warn!( 368 + "epub: worker failed ch{} ({}), falling back to sync", 369 + ch, 370 + error, 371 + ); 372 + // streaming pipeline uses fixed ~51 KB overhead -- won't OOM 373 + if let Err(e) = self.epub_cache_single_chapter(k, ch) { 374 + log::warn!("epub: sync fallback also failed ch{}: {}", ch, e); 375 + } 376 + self.cache_chapter += 1; 377 + 378 + if (self.cache_chapter as usize) < self.spine.len() { 379 + Ok(Some(true)) 380 + } else { 381 + self.epub_finish_cache(k)?; 382 + Ok(Some(false)) 383 + } 384 + } 385 + _ => { 386 + // unexpected result type -- discard and keep waiting 387 + log::warn!("epub: unexpected result while waiting for chapter strip"); 388 + Ok(None) 389 + } 390 + } 391 + } 392 + 393 + pub(super) fn epub_index_chapter(&mut self) { 394 + self.reset_paging(); 395 + // force reload -- ch_cache may hold a different chapter's data 396 + // with the same byte count (try_cache_chapter only checks len) 397 + self.ch_cache = Vec::new(); 398 + let ch = self.chapter as usize; 399 + self.file_size = if ch < cache::MAX_CACHE_CHAPTERS { 400 + self.chapter_sizes[ch] 401 + } else { 402 + 0 403 + }; 404 + log::info!( 405 + "epub: index chapter {}/{} ({} bytes cached text)", 406 + self.chapter + 1, 407 + self.spine.len(), 408 + self.file_size, 409 + ); 410 + } 411 + 412 + pub(super) fn try_cache_chapter(&mut self, k: &mut KernelHandle<'_>) -> bool { 413 + if !self.is_epub || !self.chapters_cached { 414 + return false; 415 + } 416 + 417 + let ch = self.chapter as usize; 418 + let ch_size = if ch < cache::MAX_CACHE_CHAPTERS { 419 + self.chapter_sizes[ch] as usize 420 + } else { 421 + return false; 422 + }; 423 + 424 + if ch_size == 0 || ch_size > CHAPTER_CACHE_MAX { 425 + self.ch_cache = Vec::new(); 426 + return false; 427 + } 428 + 429 + if self.ch_cache.len() == ch_size { 430 + log::info!("chapter cache: reusing {} bytes in RAM", ch_size); 431 + return true; 432 + } 433 + 434 + self.ch_cache = Vec::new(); 435 + if self.ch_cache.try_reserve_exact(ch_size).is_err() { 436 + log::info!("chapter cache: OOM for {} bytes", ch_size); 437 + return false; 438 + } 439 + self.ch_cache.resize(ch_size, 0); 440 + 441 + let dir_buf = self.cache_dir; 442 + let dir = cache::dir_name_str(&dir_buf); 443 + let ch_file = cache::chapter_file_name(self.chapter); 444 + let ch_str = cache::chapter_file_str(&ch_file); 445 + 446 + let mut pos = 0usize; 447 + while pos < ch_size { 448 + let chunk = (ch_size - pos).min(PAGE_BUF); 449 + match k.sync_read_app_subdir_chunk( 450 + dir, 451 + ch_str, 452 + pos as u32, 453 + &mut self.ch_cache[pos..pos + chunk], 454 + ) { 455 + Ok(n) if n > 0 => pos += n, 456 + Ok(_) => break, 457 + Err(e) => { 458 + log::info!("chapter cache: SD read failed at {}: {}", pos, e); 459 + self.ch_cache = Vec::new(); 460 + return false; 461 + } 462 + } 463 + } 464 + 465 + log::info!( 466 + "chapter cache: loaded ch{} ({} bytes) into RAM", 467 + self.chapter, 468 + ch_size, 469 + ); 470 + true 471 + } 472 + 473 + // run one step of background caching; returns true if self.buf was dirtied 474 + pub(super) fn bg_cache_step(&mut self, k: &mut KernelHandle<'_>) -> bool { 475 + match self.bg_cache { 476 + BgCacheState::CacheChapter => { 477 + match self.epub_dispatch_chapter_strip(k) { 478 + Ok(true) => self.bg_cache = BgCacheState::WaitChapter, 479 + Ok(false) => { 480 + // all chapters cached; start image scan from 481 + // the current reading chapter 482 + self.img_cache_ch = self.chapter; 483 + self.img_cache_offset = 0; 484 + self.img_scan_wrapped = false; 485 + self.bg_cache = BgCacheState::CacheImage; 486 + } 487 + Err(e) => { 488 + log::warn!("bg: ch dispatch failed: {}, skipping", e); 489 + self.cache_chapter += 1; 490 + // stay in CacheChapter; next tick tries the next one 491 + } 492 + } 493 + false 494 + } 495 + BgCacheState::WaitChapter => { 496 + match self.epub_recv_chapter_strip(k) { 497 + Ok(Some(true)) => { 498 + // after caching a chapter, try dispatching a nearby 499 + // image before continuing with the next chapter 500 + if self.try_dispatch_nearby_image(k) { 501 + self.bg_cache = BgCacheState::WaitNearbyImage; 502 + } else { 503 + self.bg_cache = BgCacheState::CacheChapter; 504 + } 505 + } 506 + Ok(Some(false)) => { 507 + self.img_cache_ch = self.chapter; 508 + self.img_cache_offset = 0; 509 + self.img_scan_wrapped = false; 510 + self.bg_cache = BgCacheState::CacheImage; 511 + } 512 + Ok(None) => {} 513 + Err(e) => { 514 + log::warn!("bg: ch recv failed: {}, continuing", e); 515 + self.bg_cache = BgCacheState::CacheChapter; 516 + } 517 + } 518 + false 519 + } 520 + BgCacheState::WaitNearbyImage => { 521 + match self.epub_recv_image_result(k) { 522 + Ok(Some(_)) => { 523 + if self.try_dispatch_nearby_image(k) { 524 + // stay in WaitNearbyImage 525 + } else { 526 + self.bg_cache = BgCacheState::CacheChapter; 527 + } 528 + } 529 + Ok(None) => {} 530 + Err(e) => { 531 + log::warn!("bg: nearby image error: {}, continuing", e); 532 + self.bg_cache = BgCacheState::CacheChapter; 533 + } 534 + } 535 + false 536 + } 537 + BgCacheState::CacheImage => { 538 + match self.epub_find_and_dispatch_image(k) { 539 + Ok(true) => { 540 + // worker busy: dispatched a small image, wait 541 + // worker idle: decoded inline, scan next tick 542 + if !work_queue::is_idle() { 543 + self.bg_cache = BgCacheState::WaitImage; 544 + } 545 + } 546 + Ok(false) => self.bg_cache = BgCacheState::Idle, 547 + Err(e) => { 548 + log::warn!("bg: image error: {}, continuing", e); 549 + // stay in CacheImage; next tick scans for the next one 550 + } 551 + } 552 + // image scanning uses the prefetch buffer, leaving 553 + // self.buf (current page data) untouched 554 + false 555 + } 556 + BgCacheState::WaitImage => { 557 + match self.epub_recv_image_result(k) { 558 + Ok(Some(_)) => self.bg_cache = BgCacheState::CacheImage, 559 + Ok(None) => {} 560 + Err(e) => { 561 + log::warn!("bg: image recv error: {}", e); 562 + self.bg_cache = BgCacheState::CacheImage; 563 + } 564 + } 565 + false 566 + } 567 + BgCacheState::Idle => false, 568 + } 569 + } 570 + }
+698
src/apps/reader/images.rs
··· 1 + // image decode, cache, and dispatch 2 + // 3 + // scan_chapter_for_image is the shared core: reads chapter data in 4 + // chunks, finds IMG_REF markers, resolves paths, checks cache, and 5 + // either decodes inline (large images) or dispatches to the worker 6 + // (small images). both epub_find_and_dispatch_image (background scan) 7 + // and dispatch_one_image_in_chapter (nearby prefetch) call through it. 8 + 9 + extern crate alloc; 10 + 11 + use alloc::vec::Vec; 12 + use core::cell::RefCell; 13 + 14 + use smol_epub::DecodedImage; 15 + use smol_epub::cache; 16 + use smol_epub::epub; 17 + use smol_epub::html_strip::{IMG_REF, MARKER}; 18 + use smol_epub::zip::{self, ZipIndex}; 19 + 20 + use crate::kernel::KernelHandle; 21 + use crate::kernel::work_queue; 22 + 23 + use super::{ 24 + IMAGE_DISPLAY_H, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, ReaderApp, TEXT_AREA_H, TEXT_W, 25 + }; 26 + 27 + // result of scanning a chapter for the next uncached image 28 + enum ScanResult { 29 + // small image dispatched to background worker 30 + Dispatched { resume_offset: u32 }, 31 + // large image decoded inline via streaming SD reads 32 + DecodedInline { resume_offset: u32 }, 33 + // no uncached images found from the given offset 34 + NoneFound, 35 + } 36 + 37 + impl ReaderApp { 38 + // decode the image on the current page (if any) for display 39 + pub(super) fn decode_page_images(&mut self, k: &mut KernelHandle<'_>) { 40 + self.page_img = None; 41 + self.fullscreen_img = false; 42 + 43 + if !self.is_epub || self.spine.is_empty() { 44 + return; 45 + } 46 + 47 + { 48 + let mut has_img = false; 49 + let mut has_text = false; 50 + for i in 0..self.line_count { 51 + if self.lines[i].is_image() { 52 + if self.lines[i].is_image_origin() { 53 + has_img = true; 54 + } 55 + } else if self.lines[i].len > 0 { 56 + has_text = true; 57 + } 58 + } 59 + self.fullscreen_img = has_img && !has_text; 60 + } 61 + 62 + // copy src path to a local buf to avoid borrowing self.buf below 63 + let mut src_buf = [0u8; 128]; 64 + let mut src_len = 0usize; 65 + for i in 0..self.line_count { 66 + if self.lines[i].is_image_origin() { 67 + let start = self.lines[i].start as usize; 68 + let len = self.lines[i].len as usize; 69 + if start + len <= self.buf_len { 70 + let n = len.min(src_buf.len()); 71 + src_buf[..n].copy_from_slice(&self.buf[start..start + n]); 72 + src_len = n; 73 + } 74 + break; 75 + } 76 + } 77 + 78 + if src_len == 0 { 79 + return; 80 + } 81 + 82 + let src_str = match core::str::from_utf8(&src_buf[..src_len]) { 83 + Ok(s) => s, 84 + Err(_) => return, 85 + }; 86 + 87 + log::info!("reader: decoding image: {}", src_str); 88 + 89 + let ch_zip_idx = self.spine.items[self.chapter as usize] as usize; 90 + let ch_path = self.zip.entry_name(ch_zip_idx); 91 + let ch_dir = ch_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 92 + 93 + let mut path_buf = [0u8; 512]; 94 + let path_len = epub::resolve_path(ch_dir, src_str, &mut path_buf); 95 + let full_path = match core::str::from_utf8(&path_buf[..path_len]) { 96 + Ok(s) => s, 97 + Err(_) => return, 98 + }; 99 + 100 + let dir_buf = self.cache_dir; 101 + let dir = cache::dir_name_str(&dir_buf); 102 + let img_name = img_cache_name(cache::fnv1a(full_path.as_bytes())); 103 + let img_file = img_cache_str(&img_name); 104 + 105 + if let Ok(img) = load_cached_image(k, dir, img_file) { 106 + log::info!( 107 + "reader: image cache hit {} ({}x{})", 108 + img_file, 109 + img.width, 110 + img.height 111 + ); 112 + self.page_img = Some(img); 113 + return; 114 + } 115 + 116 + let zip_idx = match self 117 + .zip 118 + .find(full_path) 119 + .or_else(|| self.zip.find_icase(full_path)) 120 + { 121 + Some(idx) => idx, 122 + None => { 123 + log::warn!("reader: image not in ZIP: {}", full_path); 124 + return; 125 + } 126 + }; 127 + 128 + let entry = *self.zip.entry(zip_idx); 129 + let (nb, nl) = self.name_copy(); 130 + let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 131 + 132 + let data_offset = { 133 + let mut hdr = [0u8; 30]; 134 + if k.sync_read_chunk(epub_name, entry.local_offset, &mut hdr) 135 + .is_err() 136 + { 137 + log::warn!("reader: failed to read ZIP local header"); 138 + return; 139 + } 140 + match ZipIndex::local_header_data_skip(&hdr) { 141 + Ok(skip) => entry.local_offset + skip, 142 + Err(e) => { 143 + log::warn!("reader: {}", e); 144 + return; 145 + } 146 + } 147 + }; 148 + 149 + let ext_jpeg = full_path.ends_with(".jpg") 150 + || full_path.ends_with(".jpeg") 151 + || full_path.ends_with(".JPG") 152 + || full_path.ends_with(".JPEG"); 153 + let ext_png = full_path.ends_with(".png") || full_path.ends_with(".PNG"); 154 + 155 + let (is_jpeg, is_png) = if ext_jpeg || ext_png { 156 + (ext_jpeg, ext_png) 157 + } else if entry.method == zip::METHOD_STORED { 158 + let mut magic = [0u8; 8]; 159 + let n = k 160 + .sync_read_chunk(epub_name, data_offset, &mut magic) 161 + .unwrap_or(0); 162 + ( 163 + n >= 2 && magic[0] == 0xFF && magic[1] == 0xD8, 164 + n >= 8 && magic[..8] == [137, 80, 78, 71, 13, 10, 26, 10], 165 + ) 166 + } else { 167 + (false, false) 168 + }; 169 + 170 + if !is_jpeg && !is_png { 171 + log::warn!("reader: unsupported image format: {}", full_path); 172 + return; 173 + } 174 + 175 + let img_max_h = if self.fullscreen_img { 176 + TEXT_AREA_H 177 + } else { 178 + IMAGE_DISPLAY_H 179 + }; 180 + 181 + let do_decode = |k_ref: &mut KernelHandle<'_>| -> Result<DecodedImage, &'static str> { 182 + let k_cell = RefCell::new(k_ref); 183 + if is_jpeg && entry.method == zip::METHOD_STORED { 184 + smol_epub::jpeg::decode_jpeg_sd( 185 + |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf), 186 + data_offset, 187 + entry.uncomp_size, 188 + TEXT_W as u16, 189 + img_max_h, 190 + ) 191 + } else if is_jpeg { 192 + smol_epub::jpeg::decode_jpeg_deflate_sd( 193 + |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf), 194 + data_offset, 195 + entry.comp_size, 196 + entry.uncomp_size, 197 + TEXT_W as u16, 198 + img_max_h, 199 + ) 200 + } else if entry.method == zip::METHOD_STORED { 201 + smol_epub::png::decode_png_sd( 202 + |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf), 203 + data_offset, 204 + entry.uncomp_size, 205 + TEXT_W as u16, 206 + img_max_h, 207 + ) 208 + } else { 209 + smol_epub::png::decode_png_deflate_sd( 210 + |off, buf| k_cell.borrow_mut().sync_read_chunk(epub_name, off, buf), 211 + data_offset, 212 + entry.comp_size, 213 + TEXT_W as u16, 214 + img_max_h, 215 + ) 216 + } 217 + }; 218 + 219 + let result = do_decode(k); 220 + 221 + // OOM fallback: release chapter cache and retry 222 + let result = match result { 223 + Ok(img) => Ok(img), 224 + Err(e) if !self.ch_cache.is_empty() => { 225 + log::info!( 226 + "reader: decode failed ({}), releasing {} KB chapter cache and retrying", 227 + e, 228 + self.ch_cache.len() / 1024, 229 + ); 230 + self.ch_cache = Vec::new(); 231 + do_decode(k) 232 + } 233 + Err(e) => Err(e), 234 + }; 235 + 236 + match result { 237 + Ok(img) => { 238 + log::info!( 239 + "reader: decoded {}x{} image ({} bytes 1-bit)", 240 + img.width, 241 + img.height, 242 + img.data.len() 243 + ); 244 + if let Err(e) = save_cached_image(k, dir, img_file, &img) { 245 + log::warn!("reader: image cache write failed: {}", e); 246 + } else { 247 + log::info!("reader: cached image as {}", img_file); 248 + } 249 + self.page_img = Some(img); 250 + } 251 + Err(e) => { 252 + log::warn!("reader: image decode failed: {}", e); 253 + } 254 + } 255 + } 256 + 257 + // scan one chapter from start_offset for the first uncached image. 258 + // reads chapter data in chunks via self.prefetch, finds IMG_REF 259 + // markers, resolves paths against the ZIP, checks the SD cache, 260 + // and either decodes inline (large) or dispatches to worker (small). 261 + fn scan_chapter_for_image( 262 + &mut self, 263 + k: &mut KernelHandle<'_>, 264 + ch: usize, 265 + start_offset: usize, 266 + ) -> Result<ScanResult, &'static str> { 267 + if ch >= cache::MAX_CACHE_CHAPTERS || !self.ch_cached[ch] { 268 + return Ok(ScanResult::NoneFound); 269 + } 270 + let ch_size = self.chapter_sizes[ch] as usize; 271 + if ch_size == 0 { 272 + return Ok(ScanResult::NoneFound); 273 + } 274 + 275 + self.prefetch_page = NO_PREFETCH; 276 + 277 + let dir_buf = self.cache_dir; 278 + let dir = cache::dir_name_str(&dir_buf); 279 + let (nb, nl) = self.name_copy(); 280 + let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 281 + 282 + let ch_file = cache::chapter_file_name(ch as u16); 283 + let ch_str = cache::chapter_file_str(&ch_file); 284 + 285 + let mut offset = start_offset; 286 + while offset < ch_size { 287 + let read_len = PAGE_BUF.min(ch_size - offset); 288 + let n = k.sync_read_app_subdir_chunk( 289 + dir, 290 + ch_str, 291 + offset as u32, 292 + &mut self.prefetch[..read_len], 293 + )?; 294 + if n == 0 { 295 + break; 296 + } 297 + 298 + let mut i = 0; 299 + while i + 2 < n { 300 + if self.prefetch[i] != MARKER || self.prefetch[i + 1] != IMG_REF { 301 + i += 1; 302 + continue; 303 + } 304 + 305 + let path_len = self.prefetch[i + 2] as usize; 306 + let path_start = i + 3; 307 + if path_len == 0 || path_start + path_len > n { 308 + i += 1; 309 + continue; 310 + } 311 + 312 + let mut src_buf = [0u8; 128]; 313 + let src_n = path_len.min(src_buf.len()); 314 + src_buf[..src_n].copy_from_slice(&self.prefetch[path_start..path_start + src_n]); 315 + let src_str = match core::str::from_utf8(&src_buf[..src_n]) { 316 + Ok(s) if !s.is_empty() => s, 317 + _ => { 318 + i = path_start + path_len; 319 + continue; 320 + } 321 + }; 322 + 323 + let mut path_buf = [0u8; 512]; 324 + let plen = { 325 + let ch_zip_idx = self.spine.items[ch] as usize; 326 + let ch_path = self.zip.entry_name(ch_zip_idx); 327 + let ch_dir = ch_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 328 + epub::resolve_path(ch_dir, src_str, &mut path_buf) 329 + }; 330 + let full_path = match core::str::from_utf8(&path_buf[..plen]) { 331 + Ok(s) => s, 332 + Err(_) => { 333 + i = path_start + path_len; 334 + continue; 335 + } 336 + }; 337 + 338 + let path_hash = cache::fnv1a(full_path.as_bytes()); 339 + let img_name = img_cache_name(path_hash); 340 + let img_file = img_cache_str(&img_name); 341 + let resume = (offset + path_start + path_len) as u32; 342 + 343 + // already cached or skip-marked 344 + if k.sync_file_size_app_subdir(dir, img_file).is_ok() { 345 + i = path_start + path_len; 346 + continue; 347 + } 348 + 349 + let is_jpeg = is_image_ext_jpeg(full_path); 350 + let is_png = is_image_ext_png(full_path); 351 + 352 + if !is_jpeg && !is_png { 353 + log::info!("precache: skip unsupported: {}", full_path); 354 + let _ = k.sync_write_app_subdir(dir, img_file, &[]); 355 + i = path_start + path_len; 356 + continue; 357 + } 358 + 359 + let zip_idx = match self 360 + .zip 361 + .find(full_path) 362 + .or_else(|| self.zip.find_icase(full_path)) 363 + { 364 + Some(idx) => idx, 365 + None => { 366 + log::warn!("precache: {} not in ZIP", full_path); 367 + i = path_start + path_len; 368 + continue; 369 + } 370 + }; 371 + 372 + let entry = *self.zip.entry(zip_idx); 373 + 374 + // large images: decode via streaming SD reads on main loop 375 + if entry.uncomp_size > PRECACHE_IMG_MAX { 376 + log::info!( 377 + "precache: streaming {} ({} bytes)", 378 + full_path, 379 + entry.uncomp_size, 380 + ); 381 + match decode_image_streaming( 382 + k, 383 + epub_name, 384 + &entry, 385 + is_jpeg, 386 + TEXT_W as u16, 387 + TEXT_AREA_H, 388 + ) { 389 + Ok(img) => { 390 + log::info!( 391 + "precache: decoded {}x{} ({}B)", 392 + img.width, 393 + img.height, 394 + img.data.len(), 395 + ); 396 + let _ = save_cached_image(k, dir, img_file, &img); 397 + } 398 + Err(e) => { 399 + log::warn!("precache: streaming failed: {}", e); 400 + let _ = k.sync_write_app_subdir(dir, img_file, &[]); 401 + } 402 + } 403 + return Ok(ScanResult::DecodedInline { 404 + resume_offset: resume, 405 + }); 406 + } 407 + 408 + // small images: extract to memory for worker dispatch 409 + let data = match super::extract_zip_entry(k, epub_name, &self.zip, zip_idx) { 410 + Ok(d) => d, 411 + Err(e) => { 412 + log::warn!("precache: extract failed: {}", e); 413 + let _ = k.sync_write_app_subdir(dir, img_file, &[]); 414 + i = path_start + path_len; 415 + continue; 416 + } 417 + }; 418 + 419 + log::info!("precache: dispatch {} ({} bytes)", full_path, data.len(),); 420 + 421 + let task = work_queue::WorkTask::DecodeImage { 422 + path_hash, 423 + data, 424 + is_jpeg, 425 + max_w: TEXT_W as u16, 426 + max_h: TEXT_AREA_H, 427 + }; 428 + if work_queue::submit(self.work_gen, task) { 429 + return Ok(ScanResult::Dispatched { 430 + resume_offset: resume, 431 + }); 432 + } 433 + return Err("cache: worker channel full"); 434 + } 435 + 436 + // advance with overlap so markers at chunk boundaries are not missed 437 + if offset + n >= ch_size { 438 + break; 439 + } 440 + offset += n.saturating_sub(128).max(1); 441 + } 442 + 443 + Ok(ScanResult::NoneFound) 444 + } 445 + 446 + // background image scanner: iterates across all chapters starting 447 + // from self.img_cache_ch / self.img_cache_offset, wrapping around 448 + // to cover chapters before the reading position 449 + pub(super) fn epub_find_and_dispatch_image( 450 + &mut self, 451 + k: &mut KernelHandle<'_>, 452 + ) -> Result<bool, &'static str> { 453 + let spine_len = self.spine.len(); 454 + 455 + while (self.img_cache_ch as usize) < spine_len { 456 + if self.img_scan_wrapped && self.img_cache_ch >= self.chapter { 457 + break; 458 + } 459 + 460 + let ch = self.img_cache_ch as usize; 461 + let start = self.img_cache_offset as usize; 462 + 463 + match self.scan_chapter_for_image(k, ch, start)? { 464 + ScanResult::Dispatched { resume_offset } 465 + | ScanResult::DecodedInline { resume_offset } => { 466 + self.img_cache_offset = resume_offset; 467 + return Ok(true); 468 + } 469 + ScanResult::NoneFound => { 470 + self.img_cache_ch += 1; 471 + self.img_cache_offset = 0; 472 + } 473 + } 474 + } 475 + 476 + // wrap around: if we started mid-book, scan chapters before the start 477 + if !self.img_scan_wrapped && self.chapter > 0 { 478 + log::info!( 479 + "precache: wrapping image scan to ch0 (started at ch{})", 480 + self.chapter, 481 + ); 482 + self.img_cache_ch = 0; 483 + self.img_cache_offset = 0; 484 + self.img_scan_wrapped = true; 485 + return Ok(true); 486 + } 487 + 488 + log::info!("precache: all images scanned"); 489 + Ok(false) 490 + } 491 + 492 + // poll worker for a completed image-decode result 493 + pub(super) fn epub_recv_image_result( 494 + &mut self, 495 + k: &mut KernelHandle<'_>, 496 + ) -> Result<Option<bool>, &'static str> { 497 + let result = match work_queue::try_recv() { 498 + Some(r) if r.is_current() => r, 499 + Some(_) => return Ok(None), // stale generation -- discard 500 + None => return Ok(None), 501 + }; 502 + 503 + match result.outcome { 504 + work_queue::WorkOutcome::ImageReady { path_hash, image } => { 505 + let dir_buf = self.cache_dir; 506 + let dir = cache::dir_name_str(&dir_buf); 507 + let img_name = img_cache_name(path_hash); 508 + let img_file = img_cache_str(&img_name); 509 + 510 + log::info!( 511 + "precache: decoded {}x{} ({}B 1-bit)", 512 + image.width, 513 + image.height, 514 + image.data.len() 515 + ); 516 + 517 + if let Err(e) = save_cached_image(k, dir, img_file, &image) { 518 + log::warn!("precache: save failed: {}", e); 519 + } 520 + 521 + Ok(Some(true)) 522 + } 523 + work_queue::WorkOutcome::ImageFailed { path_hash, error } => { 524 + log::warn!("precache: image {:#010X} failed: {}", path_hash, error); 525 + Ok(Some(true)) 526 + } 527 + _ => { 528 + log::warn!("precache: unexpected result while waiting for image decode"); 529 + Ok(None) 530 + } 531 + } 532 + } 533 + 534 + // scan one chapter for the first uncached image, dispatch to worker. 535 + // returns true if dispatched, false if nothing found or decoded inline. 536 + pub(super) fn dispatch_one_image_in_chapter( 537 + &mut self, 538 + k: &mut KernelHandle<'_>, 539 + ch: usize, 540 + ) -> bool { 541 + matches!( 542 + self.scan_chapter_for_image(k, ch, 0), 543 + Ok(ScanResult::Dispatched { .. }) 544 + ) 545 + } 546 + 547 + // dispatch one uncached image from chapters near the current position 548 + pub(super) fn try_dispatch_nearby_image(&mut self, k: &mut KernelHandle<'_>) -> bool { 549 + let r = self.chapter as usize; 550 + let spine_len = self.spine.len(); 551 + for &ch in &[r, r + 1, r.saturating_sub(1), r + 2, r.saturating_sub(2)] { 552 + if ch < spine_len && self.ch_cached[ch] { 553 + if self.dispatch_one_image_in_chapter(k, ch) { 554 + return true; 555 + } 556 + } 557 + } 558 + false 559 + } 560 + } 561 + 562 + pub(super) fn img_cache_name(hash: u32) -> [u8; 12] { 563 + let mut n = *b"00000000.BIN"; 564 + for i in 0..8 { 565 + let nibble = ((hash >> (28 - i * 4)) & 0xF) as u8; 566 + n[i] = if nibble < 10 { 567 + b'0' + nibble 568 + } else { 569 + b'A' + nibble - 10 570 + }; 571 + } 572 + n 573 + } 574 + 575 + #[inline] 576 + pub(super) fn img_cache_str(buf: &[u8; 12]) -> &str { 577 + core::str::from_utf8(buf).unwrap_or("00000000.BIN") 578 + } 579 + 580 + fn path_ext_eq(path: &str, ext: &[u8]) -> bool { 581 + let p = path.as_bytes(); 582 + let need = ext.len() + 1; // dot + ext 583 + p.len() >= need 584 + && p[p.len() - need] == b'.' 585 + && p[p.len() - ext.len()..].eq_ignore_ascii_case(ext) 586 + } 587 + 588 + pub(super) fn is_image_ext_jpeg(path: &str) -> bool { 589 + path_ext_eq(path, b"jpg") || path_ext_eq(path, b"jpeg") 590 + } 591 + 592 + pub(super) fn is_image_ext_png(path: &str) -> bool { 593 + path_ext_eq(path, b"png") 594 + } 595 + 596 + // decode image directly from EPUB ZIP via streaming 4 KB SD reads; 597 + // large-image path -- worker can't stream from SD, so main loop does it 598 + pub(super) fn decode_image_streaming( 599 + k: &mut KernelHandle<'_>, 600 + epub_name: &str, 601 + entry: &smol_epub::zip::ZipEntry, 602 + is_jpeg: bool, 603 + max_w: u16, 604 + max_h: u16, 605 + ) -> Result<DecodedImage, &'static str> { 606 + let mut hdr = [0u8; 30]; 607 + k.sync_read_chunk(epub_name, entry.local_offset, &mut hdr) 608 + .map_err(|_| "read local header failed")?; 609 + let skip = ZipIndex::local_header_data_skip(&hdr)?; 610 + let data_offset = entry.local_offset + skip; 611 + 612 + if is_jpeg && entry.method == zip::METHOD_STORED { 613 + smol_epub::jpeg::decode_jpeg_sd( 614 + |off, buf| k.sync_read_chunk(epub_name, off, buf), 615 + data_offset, 616 + entry.uncomp_size, 617 + max_w, 618 + max_h, 619 + ) 620 + } else if is_jpeg { 621 + smol_epub::jpeg::decode_jpeg_deflate_sd( 622 + |off, buf| k.sync_read_chunk(epub_name, off, buf), 623 + data_offset, 624 + entry.comp_size, 625 + entry.uncomp_size, 626 + max_w, 627 + max_h, 628 + ) 629 + } else if entry.method == zip::METHOD_STORED { 630 + smol_epub::png::decode_png_sd( 631 + |off, buf| k.sync_read_chunk(epub_name, off, buf), 632 + data_offset, 633 + entry.uncomp_size, 634 + max_w, 635 + max_h, 636 + ) 637 + } else { 638 + smol_epub::png::decode_png_deflate_sd( 639 + |off, buf| k.sync_read_chunk(epub_name, off, buf), 640 + data_offset, 641 + entry.comp_size, 642 + max_w, 643 + max_h, 644 + ) 645 + } 646 + } 647 + 648 + pub(super) fn load_cached_image( 649 + k: &mut KernelHandle<'_>, 650 + dir: &str, 651 + name: &str, 652 + ) -> Result<DecodedImage, &'static str> { 653 + let size = k 654 + .sync_file_size_app_subdir(dir, name) 655 + .map_err(|_| "no cache file")?; 656 + if size < 5 { 657 + return Err("cache file too small"); 658 + } 659 + let mut header = [0u8; 4]; 660 + k.sync_read_app_subdir_chunk(dir, name, 0, &mut header) 661 + .map_err(|_| "read header failed")?; 662 + let width = u16::from_le_bytes([header[0], header[1]]); 663 + let height = u16::from_le_bytes([header[2], header[3]]); 664 + if width == 0 || height == 0 { 665 + return Err("zero dimensions in cache"); 666 + } 667 + let stride = (width as usize).div_ceil(8); 668 + let data_len = stride * height as usize; 669 + if size as usize != 4 + data_len { 670 + return Err("cache size mismatch"); 671 + } 672 + let mut data = Vec::new(); 673 + data.try_reserve_exact(data_len) 674 + .map_err(|_| "OOM for cached image")?; 675 + data.resize(data_len, 0); 676 + k.sync_read_app_subdir_chunk(dir, name, 4, &mut data) 677 + .map_err(|_| "read data failed")?; 678 + Ok(DecodedImage { 679 + width, 680 + height, 681 + data, 682 + stride, 683 + }) 684 + } 685 + 686 + pub(super) fn save_cached_image( 687 + k: &mut KernelHandle<'_>, 688 + dir: &str, 689 + name: &str, 690 + img: &DecodedImage, 691 + ) -> Result<(), &'static str> { 692 + let mut header = [0u8; 4]; 693 + header[0..2].copy_from_slice(&img.width.to_le_bytes()); 694 + header[2..4].copy_from_slice(&img.height.to_le_bytes()); 695 + k.sync_write_app_subdir(dir, name, &header)?; 696 + k.sync_append_app_subdir(dir, name, &img.data)?; 697 + Ok(()) 698 + }
+1385
src/apps/reader/mod.rs
··· 1 + mod epub_pipeline; 2 + mod images; 3 + mod paging; 4 + 5 + extern crate alloc; 6 + 7 + use paging::decode_utf8_char; 8 + 9 + use crate::apps::PendingSetting; 10 + use crate::fonts::bitmap::{self, BitmapFont}; 11 + 12 + use alloc::vec::Vec; 13 + use core::fmt::Write; 14 + 15 + use embedded_graphics::mono_font::MonoTextStyle; 16 + use embedded_graphics::mono_font::ascii::FONT_6X13; 17 + use embedded_graphics::pixelcolor::BinaryColor; 18 + use embedded_graphics::prelude::*; 19 + use embedded_graphics::primitives::{PrimitiveStyle, Rectangle}; 20 + use embedded_graphics::text::Text; 21 + 22 + use crate::apps::{App, AppContext, AppId, RECENT_FILE, Transition}; 23 + use crate::board::action::{Action, ActionEvent}; 24 + use crate::board::{SCREEN_H, SCREEN_W}; 25 + use crate::drivers::strip::StripBuffer; 26 + use crate::fonts; 27 + use crate::kernel::KernelHandle; 28 + use crate::kernel::QuickAction; 29 + use crate::kernel::bookmarks; 30 + use crate::kernel::work_queue; 31 + use crate::ui::{Alignment, BUTTON_BAR_H, CONTENT_TOP, Region, StackFmt}; 32 + use smol_epub::DecodedImage; 33 + use smol_epub::cache; 34 + use smol_epub::epub::{self, EpubMeta, EpubSpine, EpubToc, TocSource}; 35 + use smol_epub::html_strip::{ 36 + BOLD_OFF, BOLD_ON, HEADING_OFF, HEADING_ON, ITALIC_OFF, ITALIC_ON, MARKER, 37 + }; 38 + use smol_epub::zip::{self, ZipIndex}; 39 + 40 + pub(super) const MARGIN: u16 = 8; 41 + pub(super) const HEADER_Y: u16 = CONTENT_TOP + 2; 42 + pub(super) const HEADER_H: u16 = 16; 43 + pub(super) const TEXT_Y: u16 = HEADER_Y + HEADER_H + 2; 44 + pub(super) const LINE_H: u16 = 13; 45 + pub(super) const CHARS_PER_LINE: usize = 66; 46 + pub(super) const LINES_PER_PAGE: usize = 58; 47 + pub(super) const PAGE_BUF: usize = 8192; 48 + pub(super) const MAX_PAGES: usize = 1024; 49 + 50 + pub(super) const HEADER_REGION: Region = Region::new(MARGIN, HEADER_Y, 300, HEADER_H); 51 + pub(super) const STATUS_REGION: Region = Region::new(308, HEADER_Y, 164, HEADER_H); 52 + 53 + pub(super) const PAGE_REGION: Region = Region::new(0, HEADER_Y, SCREEN_W, SCREEN_H - HEADER_Y); 54 + 55 + pub(super) const NO_PREFETCH: usize = usize::MAX; 56 + 57 + pub(super) const TEXT_W: u32 = (SCREEN_W - 2 * MARGIN) as u32; 58 + pub(super) const TEXT_AREA_H: u16 = SCREEN_H - TEXT_Y - BUTTON_BAR_H; 59 + pub(super) const EOCD_TAIL: usize = 512; 60 + pub(super) const INDENT_PX: u32 = 24; 61 + pub(super) const IMAGE_DISPLAY_H: u16 = 200; 62 + pub(super) const CHAPTER_CACHE_MAX: usize = 98304; 63 + 64 + // images <= this size are dispatched to the async worker for decoding; 65 + // images > this size are decoded on the main loop via streaming SD reads 66 + pub(super) const PRECACHE_IMG_MAX: u32 = 30 * 1024; 67 + 68 + pub(super) const PROGRESS_H: u16 = 2; 69 + pub(super) const PROGRESS_Y: u16 = SCREEN_H - PROGRESS_H - 1; 70 + pub(super) const PROGRESS_W: u16 = SCREEN_W - 2 * MARGIN; 71 + 72 + pub(super) const POSITION_OVERLAY_W: u16 = 280; 73 + pub(super) const POSITION_OVERLAY_H: u16 = 40; 74 + pub(super) const POSITION_OVERLAY: Region = Region::new( 75 + (SCREEN_W - POSITION_OVERLAY_W) / 2, 76 + (SCREEN_H - POSITION_OVERLAY_H) / 2, 77 + POSITION_OVERLAY_W, 78 + POSITION_OVERLAY_H, 79 + ); 80 + 81 + pub(super) const LOADING_REGION: Region = Region::new(MARGIN, TEXT_Y, 464, 20); 82 + 83 + pub const QA_FONT_SIZE: u8 = 1; 84 + pub(super) const QA_PREV_CHAPTER: u8 = 3; 85 + pub(super) const QA_NEXT_CHAPTER: u8 = 4; 86 + pub(super) const QA_TOC: u8 = 5; 87 + 88 + pub(super) const QA_MAX: usize = 4; 89 + 90 + #[derive(Clone, Copy, PartialEq)] 91 + pub(super) enum State { 92 + NeedBookmark, 93 + NeedInit, 94 + NeedOpf, 95 + NeedToc, 96 + NeedCache, 97 + NeedIndex, 98 + NeedPage, 99 + Ready, 100 + ShowToc, 101 + Error, 102 + } 103 + 104 + // background caching progress, runs independently of the reading 105 + // state so the user can read while chapters/images are cached 106 + #[derive(Clone, Copy, PartialEq)] 107 + pub(super) enum BgCacheState { 108 + // nothing to do 109 + Idle, 110 + CacheChapter, 111 + WaitChapter, 112 + WaitNearbyImage, 113 + CacheImage, 114 + WaitImage, 115 + } 116 + 117 + #[derive(Clone, Copy)] 118 + pub(super) struct LineSpan { 119 + pub(super) start: u16, 120 + pub(super) len: u16, 121 + pub(super) flags: u8, 122 + pub(super) indent: u8, 123 + } 124 + 125 + impl LineSpan { 126 + pub(super) const EMPTY: Self = Self { 127 + start: 0, 128 + len: 0, 129 + flags: 0, 130 + indent: 0, 131 + }; 132 + 133 + pub(super) const FLAG_BOLD: u8 = 1 << 0; 134 + pub(super) const FLAG_ITALIC: u8 = 1 << 1; 135 + pub(super) const FLAG_HEADING: u8 = 1 << 2; 136 + pub(super) const FLAG_IMAGE: u8 = 1 << 3; 137 + 138 + #[inline] 139 + pub(super) fn is_image(&self) -> bool { 140 + self.flags & Self::FLAG_IMAGE != 0 141 + } 142 + 143 + #[inline] 144 + pub(super) fn is_image_origin(&self) -> bool { 145 + self.is_image() && self.len > 0 146 + } 147 + 148 + pub(super) fn style(&self) -> fonts::Style { 149 + if self.flags & Self::FLAG_HEADING != 0 { 150 + fonts::Style::Heading 151 + } else if self.flags & Self::FLAG_BOLD != 0 { 152 + fonts::Style::Bold 153 + } else if self.flags & Self::FLAG_ITALIC != 0 { 154 + fonts::Style::Italic 155 + } else { 156 + fonts::Style::Regular 157 + } 158 + } 159 + 160 + pub(super) fn pack_flags(bold: bool, italic: bool, heading: bool) -> u8 { 161 + (bold as u8) | ((italic as u8) << 1) | ((heading as u8) << 2) 162 + } 163 + } 164 + 165 + impl Default for ReaderApp { 166 + fn default() -> Self { 167 + Self::new() 168 + } 169 + } 170 + 171 + pub struct ReaderApp { 172 + pub(super) filename: [u8; 32], 173 + pub(super) filename_len: usize, 174 + pub(super) title: [u8; 96], 175 + pub(super) title_len: usize, 176 + pub(super) file_size: u32, 177 + 178 + pub(super) offsets: [u32; MAX_PAGES], 179 + pub(super) total_pages: usize, 180 + pub(super) fully_indexed: bool, 181 + 182 + pub(super) page: usize, 183 + pub(super) buf: [u8; PAGE_BUF], 184 + pub(super) buf_len: usize, 185 + pub(super) lines: [LineSpan; LINES_PER_PAGE], 186 + pub(super) line_count: usize, 187 + 188 + pub(super) prefetch: [u8; PAGE_BUF], 189 + pub(super) prefetch_len: usize, 190 + pub(super) prefetch_page: usize, 191 + 192 + pub(super) state: State, 193 + pub(super) error: Option<&'static str>, 194 + pub(super) show_position: bool, 195 + 196 + pub(super) is_epub: bool, 197 + pub(super) zip: ZipIndex, 198 + pub(super) meta: EpubMeta, 199 + pub(super) spine: EpubSpine, 200 + pub(super) chapter: u16, 201 + pub(super) goto_last_page: bool, 202 + pub(super) restore_offset: Option<u32>, 203 + 204 + pub(super) cache_dir: [u8; 8], 205 + pub(super) epub_name_hash: u32, 206 + pub(super) epub_file_size: u32, 207 + pub(super) chapter_sizes: [u32; cache::MAX_CACHE_CHAPTERS], 208 + pub(super) chapters_cached: bool, 209 + pub(super) cache_chapter: u16, 210 + pub(super) img_cache_ch: u16, 211 + pub(super) img_cache_offset: u32, 212 + pub(super) img_scan_wrapped: bool, 213 + 214 + pub(super) bg_cache: BgCacheState, 215 + pub(super) ch_cached: [bool; cache::MAX_CACHE_CHAPTERS], 216 + pub(super) work_gen: u16, 217 + 218 + pub(super) ch_cache: Vec<u8>, 219 + pub(super) page_img: Option<DecodedImage>, 220 + pub(super) fullscreen_img: bool, 221 + pub(super) toc: EpubToc, 222 + pub(super) toc_source: Option<TocSource>, 223 + pub(super) toc_selected: usize, 224 + pub(super) toc_scroll: usize, 225 + 226 + pub(super) fonts: Option<fonts::FontSet>, 227 + pub(super) font_line_h: u16, 228 + pub(super) font_ascent: u16, 229 + pub(super) max_lines: usize, 230 + 231 + pub(super) book_font_size_idx: u8, 232 + pub(super) applied_font_idx: u8, 233 + 234 + pub(super) chrome_font: Option<&'static BitmapFont>, 235 + pub(super) qa_buf: [QuickAction; QA_MAX], 236 + pub(super) qa_count: usize, 237 + } 238 + 239 + impl ReaderApp { 240 + pub const fn new() -> Self { 241 + Self { 242 + filename: [0u8; 32], 243 + filename_len: 0, 244 + title: [0u8; 96], 245 + title_len: 0, 246 + file_size: 0, 247 + 248 + offsets: [0u32; MAX_PAGES], 249 + total_pages: 0, 250 + fully_indexed: false, 251 + 252 + page: 0, 253 + buf: [0u8; PAGE_BUF], 254 + buf_len: 0, 255 + lines: [LineSpan::EMPTY; LINES_PER_PAGE], 256 + line_count: 0, 257 + 258 + prefetch: [0u8; PAGE_BUF], 259 + prefetch_len: 0, 260 + prefetch_page: NO_PREFETCH, 261 + 262 + state: State::NeedPage, 263 + error: None, 264 + show_position: false, 265 + 266 + is_epub: false, 267 + zip: ZipIndex::new(), 268 + meta: EpubMeta::new(), 269 + spine: EpubSpine::new(), 270 + chapter: 0, 271 + goto_last_page: false, 272 + restore_offset: None, 273 + 274 + cache_dir: [0u8; 8], 275 + epub_name_hash: 0, 276 + epub_file_size: 0, 277 + chapter_sizes: [0u32; cache::MAX_CACHE_CHAPTERS], 278 + chapters_cached: false, 279 + cache_chapter: 0, 280 + img_cache_ch: 0, 281 + img_cache_offset: 0, 282 + img_scan_wrapped: false, 283 + 284 + bg_cache: BgCacheState::Idle, 285 + ch_cached: [false; cache::MAX_CACHE_CHAPTERS], 286 + work_gen: 0, 287 + 288 + ch_cache: Vec::new(), 289 + 290 + page_img: None, 291 + fullscreen_img: false, 292 + 293 + toc: EpubToc::new(), 294 + toc_source: None, 295 + toc_selected: 0, 296 + toc_scroll: 0, 297 + 298 + fonts: None, 299 + font_line_h: LINE_H, 300 + font_ascent: LINE_H, 301 + max_lines: LINES_PER_PAGE, 302 + 303 + book_font_size_idx: 0, 304 + applied_font_idx: 0, 305 + 306 + chrome_font: None, 307 + 308 + qa_buf: [QuickAction::trigger(0, "", ""); QA_MAX], 309 + qa_count: 0, 310 + } 311 + } 312 + 313 + // 0 = XSmall, 1 = Small, 2 = Medium, 3 = Large, 4 = XLarge 314 + pub fn set_book_font_size(&mut self, idx: u8) { 315 + self.book_font_size_idx = idx; 316 + self.apply_font_metrics(); 317 + self.rebuild_quick_actions(); 318 + } 319 + 320 + pub fn set_chrome_font(&mut self, font: &'static BitmapFont) { 321 + self.chrome_font = Some(font); 322 + } 323 + 324 + pub fn has_bg_work(&self) -> bool { 325 + self.is_epub && self.bg_cache != BgCacheState::Idle 326 + } 327 + 328 + // run one step of background caching while suspended 329 + pub fn bg_work_tick(&mut self, k: &mut KernelHandle<'_>) { 330 + if self.bg_cache != BgCacheState::Idle { 331 + self.bg_cache_step(k); 332 + } 333 + } 334 + 335 + fn rebuild_quick_actions(&mut self) { 336 + let mut n = 0usize; 337 + 338 + self.qa_buf[n] = QuickAction::cycle( 339 + QA_FONT_SIZE, 340 + "Book Font", 341 + self.book_font_size_idx, 342 + fonts::FONT_SIZE_NAMES, 343 + ); 344 + n += 1; 345 + 346 + if self.is_epub && self.spine.len() > 1 { 347 + self.qa_buf[n] = QuickAction::trigger(QA_PREV_CHAPTER, "Prev Ch", "<<<"); 348 + n += 1; 349 + self.qa_buf[n] = QuickAction::trigger(QA_NEXT_CHAPTER, "Next Ch", ">>>"); 350 + n += 1; 351 + } 352 + 353 + if self.is_epub && !self.toc.is_empty() { 354 + self.qa_buf[n] = QuickAction::trigger(QA_TOC, "Contents", "Open"); 355 + n += 1; 356 + } 357 + 358 + self.qa_count = n; 359 + } 360 + 361 + fn apply_font_metrics(&mut self) { 362 + self.fonts = None; 363 + self.font_line_h = LINE_H; 364 + self.font_ascent = LINE_H; 365 + self.max_lines = LINES_PER_PAGE; 366 + 367 + if fonts::font_data::HAS_REGULAR { 368 + let fs = fonts::FontSet::for_size(self.book_font_size_idx); 369 + self.font_line_h = fs.line_height(fonts::Style::Regular).max(1); 370 + self.font_ascent = fs.ascent(fonts::Style::Regular); 371 + self.max_lines = ((TEXT_AREA_H / self.font_line_h) as usize).min(LINES_PER_PAGE); 372 + log::info!( 373 + "font: size_idx={} line_h={} ascent={} max_lines={}", 374 + self.book_font_size_idx, 375 + self.font_line_h, 376 + self.font_ascent, 377 + self.max_lines 378 + ); 379 + self.fonts = Some(fs); 380 + } 381 + self.applied_font_idx = self.book_font_size_idx; 382 + } 383 + 384 + fn name(&self) -> &str { 385 + core::str::from_utf8(&self.filename[..self.filename_len]).unwrap_or("???") 386 + } 387 + 388 + fn name_copy(&self) -> ([u8; 32], usize) { 389 + let mut buf = [0u8; 32]; 390 + buf[..self.filename_len].copy_from_slice(&self.filename[..self.filename_len]); 391 + (buf, self.filename_len) 392 + } 393 + 394 + pub fn save_position(&self, bm: &mut bookmarks::BookmarkCache) { 395 + if self.state == State::Ready { 396 + bm.save( 397 + &self.filename[..self.filename_len], 398 + self.offsets[self.page], 399 + self.chapter, 400 + ); 401 + } 402 + } 403 + 404 + fn bookmark_load(&mut self, bm: &bookmarks::BookmarkCache) -> bool { 405 + if let Some(slot) = bm.find(&self.filename[..self.filename_len]) { 406 + log::info!( 407 + "bookmark: restoring off={} ch={} for {}", 408 + slot.byte_offset, 409 + slot.chapter, 410 + slot.filename_str(), 411 + ); 412 + self.chapter = slot.chapter; 413 + self.restore_offset = if slot.byte_offset > 0 { 414 + Some(slot.byte_offset) 415 + } else { 416 + None 417 + }; 418 + true 419 + } else { 420 + false 421 + } 422 + } 423 + 424 + fn display_name(&self) -> &str { 425 + if self.title_len > 0 { 426 + core::str::from_utf8(&self.title[..self.title_len]).unwrap_or(self.name()) 427 + } else { 428 + self.name() 429 + } 430 + } 431 + 432 + fn progress_pct(&self) -> u8 { 433 + if self.is_epub && !self.spine.is_empty() { 434 + let spine_len = self.spine.len() as u64; 435 + let ch = self.chapter as u64; 436 + 437 + if ch + 1 >= spine_len && self.fully_indexed && self.page + 1 >= self.total_pages { 438 + return 100; 439 + } 440 + 441 + let in_ch = if self.file_size == 0 { 442 + 0u64 443 + } else { 444 + let pos = self.offsets[self.page] as u64; 445 + let size = self.file_size as u64; 446 + ((pos * 100) / size).min(100) 447 + }; 448 + 449 + let overall = (ch * 100 + in_ch) / spine_len; 450 + return overall.min(100) as u8; 451 + } 452 + 453 + if self.file_size == 0 { 454 + return 100; 455 + } 456 + if self.fully_indexed && self.page + 1 >= self.total_pages { 457 + return 100; 458 + } 459 + let pos = self.offsets[self.page] as u64; 460 + let size = self.file_size as u64; 461 + ((pos * 100) / size).min(100) as u8 462 + } 463 + } 464 + 465 + // read_full: read exactly buf.len() bytes from name at offset 466 + pub(super) fn read_full( 467 + k: &mut KernelHandle<'_>, 468 + name: &str, 469 + offset: u32, 470 + buf: &mut [u8], 471 + ) -> Result<(), &'static str> { 472 + let mut total = 0usize; 473 + while total < buf.len() { 474 + let n = k.sync_read_chunk(name, offset + total as u32, &mut buf[total..])?; 475 + if n == 0 { 476 + return Err("epub: unexpected EOF"); 477 + } 478 + total += n; 479 + } 480 + Ok(()) 481 + } 482 + 483 + // extract_zip_entry: decompress or copy one ZIP entry to a Vec 484 + pub(super) fn extract_zip_entry( 485 + k: &mut KernelHandle<'_>, 486 + name: &str, 487 + zip_index: &ZipIndex, 488 + entry_idx: usize, 489 + ) -> Result<alloc::vec::Vec<u8>, &'static str> { 490 + use core::cell::RefCell; 491 + let entry = zip_index.entry(entry_idx); 492 + let k = RefCell::new(k); 493 + zip::extract_entry(entry, entry.local_offset, |offset, buf| { 494 + k.borrow_mut().sync_read_chunk(name, offset, buf) 495 + }) 496 + } 497 + 498 + fn draw_chrome_text( 499 + strip: &mut StripBuffer, 500 + region: Region, 501 + text: &str, 502 + align: Alignment, 503 + font: Option<&'static BitmapFont>, 504 + ) { 505 + region 506 + .to_rect() 507 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off)) 508 + .draw(strip) 509 + .unwrap(); 510 + if text.is_empty() { 511 + return; 512 + } 513 + if let Some(f) = font { 514 + f.draw_aligned(strip, region, text, align, BinaryColor::On); 515 + } else { 516 + let tw = text.len() as u32 * 6; 517 + let pos = align.position(region, embedded_graphics::geometry::Size::new(tw, 13)); 518 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 519 + Text::new(text, Point::new(pos.x, pos.y + 13), style) 520 + .draw(strip) 521 + .unwrap(); 522 + } 523 + } 524 + 525 + impl App<AppId> for ReaderApp { 526 + async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 527 + let msg = ctx.message(); 528 + let len = msg.len().min(32); 529 + self.filename[..len].copy_from_slice(&msg[..len]); 530 + self.filename_len = len; 531 + 532 + let n = self.filename_len.min(self.title.len()); 533 + self.title[..n].copy_from_slice(&self.filename[..n]); 534 + self.title_len = n; 535 + 536 + // Bump to a new work-queue generation and drain stale work 537 + // from any previous book (covers the case where on_enter is 538 + // called without a preceding on_exit, e.g. Replace transition). 539 + self.work_gen = work_queue::reset(); 540 + self.bg_cache = BgCacheState::Idle; 541 + self.ch_cached = [false; cache::MAX_CACHE_CHAPTERS]; 542 + self.img_scan_wrapped = false; 543 + 544 + self.is_epub = epub::is_epub_filename(self.name()); 545 + self.rebuild_quick_actions(); 546 + self.reset_paging(); 547 + self.ch_cache = Vec::new(); 548 + self.file_size = 0; 549 + self.chapter = 0; 550 + self.error = None; 551 + self.show_position = false; 552 + self.goto_last_page = false; 553 + self.restore_offset = None; 554 + 555 + self.apply_font_metrics(); 556 + 557 + self.state = State::NeedBookmark; 558 + 559 + log::info!("reader: opening {}", self.name()); 560 + 561 + ctx.mark_dirty(PAGE_REGION); 562 + } 563 + 564 + fn on_exit(&mut self) { 565 + // Cancel any in-flight background cache work so the worker 566 + // doesn't write stale results after we switch books. 567 + if self.is_epub { 568 + work_queue::reset(); 569 + self.bg_cache = BgCacheState::Idle; 570 + } 571 + 572 + self.line_count = 0; 573 + self.buf_len = 0; 574 + self.prefetch_page = NO_PREFETCH; 575 + self.prefetch_len = 0; 576 + self.restore_offset = None; 577 + self.show_position = false; 578 + self.ch_cache = Vec::new(); 579 + self.page_img = None; 580 + 581 + if self.is_epub { 582 + self.toc.clear(); 583 + self.toc_source = None; 584 + } 585 + } 586 + 587 + fn on_suspend(&mut self) { 588 + // background caching continues while suspended -- the worker 589 + // task runs independently and our work_gen stays valid 590 + } 591 + 592 + async fn on_resume(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 593 + // Restore our generation so the worker considers in-flight 594 + // results current again (another app may have submitted work 595 + // under a different generation while we were suspended). 596 + if self.work_gen != 0 { 597 + work_queue::set_active_generation(self.work_gen); 598 + } 599 + 600 + let font_changed = self.book_font_size_idx != self.applied_font_idx; 601 + self.apply_font_metrics(); 602 + if font_changed { 603 + self.reset_paging(); 604 + if self.is_epub && self.chapters_cached { 605 + self.state = State::NeedIndex; 606 + } else { 607 + self.state = State::NeedPage; 608 + } 609 + } 610 + ctx.mark_dirty(PAGE_REGION); 611 + } 612 + 613 + async fn background(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) { 614 + loop { 615 + match self.state { 616 + State::NeedBookmark => { 617 + self.bookmark_load(k.bookmark_cache()); 618 + 619 + let _ = k.sync_write_app_data(RECENT_FILE, &self.filename[..self.filename_len]); 620 + 621 + if self.is_epub { 622 + self.zip.clear(); 623 + self.meta = EpubMeta::new(); 624 + self.spine = EpubSpine::new(); 625 + self.chapters_cached = false; 626 + self.goto_last_page = false; 627 + self.state = State::NeedInit; 628 + } else { 629 + self.state = State::NeedPage; 630 + } 631 + continue; 632 + } 633 + 634 + State::NeedInit => match self.epub_init_zip(k) { 635 + Ok(()) => { 636 + self.state = State::NeedOpf; // yield; CD heap freed 637 + } 638 + Err(e) => { 639 + log::info!("reader: epub init (zip) failed: {}", e); 640 + self.error = Some(e); 641 + self.state = State::Error; 642 + ctx.mark_dirty(PAGE_REGION); 643 + } 644 + }, 645 + 646 + State::NeedOpf => match self.epub_init_opf(k) { 647 + Ok(()) => { 648 + self.state = State::NeedToc; // yield; OPF heap freed 649 + } 650 + Err(e) => { 651 + log::info!("reader: epub init (opf) failed: {}", e); 652 + self.error = Some(e); 653 + self.state = State::Error; 654 + ctx.mark_dirty(PAGE_REGION); 655 + } 656 + }, 657 + 658 + State::NeedToc => { 659 + if let Some(source) = self.toc_source.take() { 660 + let (nb, nl) = self.name_copy(); 661 + let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 662 + let toc_idx = source.zip_index(); 663 + 664 + let mut toc_dir_buf = [0u8; 256]; 665 + let toc_dir_len = { 666 + let toc_path = self.zip.entry_name(toc_idx); 667 + let dir = toc_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 668 + let n = dir.len().min(toc_dir_buf.len()); 669 + toc_dir_buf[..n].copy_from_slice(dir.as_bytes()); 670 + n 671 + }; 672 + let toc_dir = 673 + core::str::from_utf8(&toc_dir_buf[..toc_dir_len]).unwrap_or(""); 674 + 675 + match extract_zip_entry(k, name, &self.zip, toc_idx) { 676 + Ok(toc_data) => { 677 + epub::parse_toc( 678 + source, 679 + &toc_data, 680 + toc_dir, 681 + &self.spine, 682 + &self.zip, 683 + &mut self.toc, 684 + ); 685 + log::info!("epub: TOC has {} entries", self.toc.len()); 686 + } 687 + Err(e) => { 688 + log::warn!("epub: failed to read TOC: {}", e); 689 + } 690 + } 691 + } 692 + self.rebuild_quick_actions(); 693 + self.state = State::NeedCache; 694 + continue; 695 + } 696 + 697 + State::NeedCache => match self.epub_check_cache(k) { 698 + Ok(true) => { 699 + self.state = State::NeedIndex; 700 + continue; 701 + } 702 + Ok(false) => { 703 + // Cache only the current chapter synchronously 704 + // so the user can start reading immediately. 705 + let ch = self.chapter as usize; 706 + match self.epub_cache_single_chapter(k, ch) { 707 + Ok(()) => { 708 + self.chapters_cached = true; 709 + self.cache_chapter = 0; 710 + 711 + // Eagerly dispatch nearby images to 712 + // the worker so they decode while the 713 + // user reads the first page. The 714 + // worker is idle at this point so the 715 + // dispatch is immediate. 716 + if self.try_dispatch_nearby_image(k) { 717 + self.bg_cache = BgCacheState::WaitNearbyImage; 718 + } else { 719 + self.bg_cache = BgCacheState::CacheChapter; 720 + } 721 + 722 + self.state = State::NeedIndex; 723 + continue; 724 + } 725 + Err(e) => { 726 + log::info!("reader: sync cache ch{} failed: {}", ch, e); 727 + self.error = Some(e); 728 + self.state = State::Error; 729 + ctx.mark_dirty(PAGE_REGION); 730 + } 731 + } 732 + } 733 + Err(e) => { 734 + log::info!("reader: cache check failed: {}", e); 735 + self.error = Some(e); 736 + self.state = State::Error; 737 + ctx.mark_dirty(PAGE_REGION); 738 + } 739 + }, 740 + 741 + State::NeedIndex => { 742 + // Ensure the target chapter is cached before 743 + // indexing (it may not be if background caching 744 + // hasn't reached it yet). 745 + if self.is_epub 746 + && self.chapters_cached 747 + && !self.ch_cached[self.chapter as usize] 748 + { 749 + if let Err(e) = self.epub_cache_single_chapter(k, self.chapter as usize) { 750 + self.error = Some(e); 751 + self.state = State::Error; 752 + ctx.mark_dirty(PAGE_REGION); 753 + break; 754 + } 755 + } 756 + 757 + let want_last = self.goto_last_page; 758 + self.goto_last_page = false; 759 + 760 + self.epub_index_chapter(); 761 + 762 + if self.try_cache_chapter(k) { 763 + self.preindex_all_pages(); 764 + } 765 + 766 + if want_last { 767 + match self.scan_to_last_page(k) { 768 + Ok(()) => { 769 + self.state = State::Ready; 770 + ctx.mark_dirty(PAGE_REGION); 771 + } 772 + Err(e) => { 773 + self.error = Some(e); 774 + self.state = State::Error; 775 + ctx.mark_dirty(PAGE_REGION); 776 + } 777 + } 778 + } else { 779 + self.state = State::NeedPage; 780 + continue; 781 + } 782 + } 783 + 784 + State::NeedPage => { 785 + if let Some(target_off) = self.restore_offset.take() { 786 + self.page = 0; 787 + loop { 788 + match self.load_and_prefetch(k) { 789 + Ok(()) => {} 790 + Err(e) => { 791 + self.error = Some(e); 792 + self.state = State::Error; 793 + ctx.mark_dirty(PAGE_REGION); 794 + break; 795 + } 796 + } 797 + if self.page + 1 >= self.total_pages { 798 + break; 799 + } 800 + if self.offsets[self.page + 1] > target_off { 801 + break; 802 + } 803 + self.page += 1; 804 + } 805 + if self.state != State::Error { 806 + self.state = State::Ready; 807 + ctx.mark_dirty(PAGE_REGION); 808 + } 809 + } else { 810 + match self.load_and_prefetch(k) { 811 + Ok(()) => { 812 + self.state = State::Ready; 813 + ctx.mark_dirty(PAGE_REGION); 814 + } 815 + Err(e) => { 816 + log::info!("reader: load failed: {}", e); 817 + self.error = Some(e); 818 + self.state = State::Error; 819 + ctx.mark_dirty(PAGE_REGION); 820 + } 821 + } 822 + } 823 + } 824 + 825 + _ => {} 826 + } 827 + break; 828 + } 829 + 830 + // background caching (runs while the user reads) 831 + // runs in any stable state -- page turns momentarily leave 832 + // Ready, but background work resumes on the next tick 833 + if matches!(self.state, State::Ready | State::ShowToc) 834 + && self.bg_cache != BgCacheState::Idle 835 + { 836 + self.bg_cache_step(k); 837 + } 838 + } 839 + 840 + fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition { 841 + if self.state == State::ShowToc { 842 + match event { 843 + ActionEvent::Press(Action::Back) => { 844 + self.state = State::Ready; 845 + ctx.mark_dirty(PAGE_REGION); 846 + return Transition::None; 847 + } 848 + ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => { 849 + let len = self.toc.len(); 850 + if len > 0 { 851 + if self.toc_selected + 1 < len { 852 + self.toc_selected += 1; 853 + } else { 854 + self.toc_selected = 0; 855 + self.toc_scroll = 0; 856 + } 857 + let vis = (TEXT_AREA_H / self.font_line_h) as usize; 858 + if self.toc_selected >= self.toc_scroll + vis { 859 + self.toc_scroll = self.toc_selected + 1 - vis; 860 + } 861 + ctx.mark_dirty(PAGE_REGION); 862 + } 863 + return Transition::None; 864 + } 865 + ActionEvent::Press(Action::Prev) | ActionEvent::Repeat(Action::Prev) => { 866 + let len = self.toc.len(); 867 + if len > 0 { 868 + if self.toc_selected > 0 { 869 + self.toc_selected -= 1; 870 + } else { 871 + self.toc_selected = len - 1; 872 + let vis = (TEXT_AREA_H / self.font_line_h) as usize; 873 + if self.toc_selected >= vis { 874 + self.toc_scroll = self.toc_selected + 1 - vis; 875 + } 876 + } 877 + if self.toc_selected < self.toc_scroll { 878 + self.toc_scroll = self.toc_selected; 879 + } 880 + ctx.mark_dirty(PAGE_REGION); 881 + } 882 + return Transition::None; 883 + } 884 + ActionEvent::Press(Action::Select) | ActionEvent::Press(Action::NextJump) => { 885 + let entry = &self.toc.entries[self.toc_selected]; 886 + if entry.spine_idx != 0xFFFF { 887 + log::info!( 888 + "toc: jumping to \"{}\" -> spine {}", 889 + entry.title_str(), 890 + entry.spine_idx 891 + ); 892 + self.chapter = entry.spine_idx; 893 + self.page = 0; 894 + self.goto_last_page = false; 895 + self.state = State::NeedIndex; 896 + ctx.mark_dirty(PAGE_REGION); 897 + } else { 898 + log::warn!( 899 + "toc: entry \"{}\" unresolved (spine_idx=0xFFFF), ignoring", 900 + entry.title_str() 901 + ); 902 + self.state = State::Ready; 903 + ctx.mark_dirty(PAGE_REGION); 904 + } 905 + return Transition::None; 906 + } 907 + _ => return Transition::None, 908 + } 909 + } 910 + 911 + match event { 912 + ActionEvent::Press(Action::Back) => Transition::Pop, 913 + ActionEvent::LongPress(Action::Back) => Transition::Home, 914 + 915 + ActionEvent::LongPress(Action::Next) => { 916 + if self.state == State::Ready { 917 + self.show_position = true; 918 + } 919 + if self.page_forward() { 920 + ctx.mark_dirty(PAGE_REGION); 921 + } 922 + Transition::None 923 + } 924 + ActionEvent::LongPress(Action::Prev) => { 925 + if self.state == State::Ready { 926 + self.show_position = true; 927 + } 928 + if self.page_backward() { 929 + ctx.mark_dirty(PAGE_REGION); 930 + } 931 + Transition::None 932 + } 933 + 934 + ActionEvent::Release(Action::Next) | ActionEvent::Release(Action::Prev) => { 935 + if self.show_position { 936 + self.show_position = false; 937 + ctx.mark_dirty(POSITION_OVERLAY); 938 + } 939 + Transition::None 940 + } 941 + 942 + ActionEvent::Press(Action::Next) | ActionEvent::Repeat(Action::Next) => { 943 + if self.page_forward() { 944 + ctx.mark_dirty(PAGE_REGION); 945 + } 946 + Transition::None 947 + } 948 + 949 + ActionEvent::Press(Action::Prev) | ActionEvent::Repeat(Action::Prev) => { 950 + if self.page_backward() { 951 + ctx.mark_dirty(PAGE_REGION); 952 + } 953 + Transition::None 954 + } 955 + 956 + ActionEvent::Press(Action::NextJump) | ActionEvent::Repeat(Action::NextJump) => { 957 + if self.jump_forward() { 958 + ctx.mark_dirty(PAGE_REGION); 959 + } 960 + Transition::None 961 + } 962 + 963 + ActionEvent::Press(Action::PrevJump) | ActionEvent::Repeat(Action::PrevJump) => { 964 + if self.jump_backward() { 965 + ctx.mark_dirty(PAGE_REGION); 966 + } 967 + Transition::None 968 + } 969 + 970 + _ => Transition::None, 971 + } 972 + } 973 + 974 + fn quick_actions(&self) -> &[QuickAction] { 975 + &self.qa_buf[..self.qa_count] 976 + } 977 + 978 + fn on_quick_trigger(&mut self, id: u8, ctx: &mut AppContext) { 979 + match id { 980 + QA_PREV_CHAPTER => { 981 + if self.is_epub && self.chapter > 0 { 982 + self.chapter -= 1; 983 + self.goto_last_page = false; 984 + self.state = State::NeedIndex; 985 + } 986 + } 987 + QA_NEXT_CHAPTER => { 988 + if self.is_epub && (self.chapter as usize + 1) < self.spine.len() { 989 + self.chapter += 1; 990 + self.goto_last_page = false; 991 + self.state = State::NeedIndex; 992 + } 993 + } 994 + QA_TOC => { 995 + if self.is_epub && !self.toc.is_empty() { 996 + log::info!("toc: opening ({} entries)", self.toc.len()); 997 + self.toc_selected = 0; 998 + self.toc_scroll = 0; 999 + for i in 0..self.toc.len() { 1000 + if self.toc.entries[i].spine_idx == self.chapter { 1001 + self.toc_selected = i; 1002 + let vis = (TEXT_AREA_H / self.font_line_h) as usize; 1003 + if self.toc_selected >= vis { 1004 + self.toc_scroll = self.toc_selected + 1 - vis; 1005 + } 1006 + break; 1007 + } 1008 + } 1009 + self.state = State::ShowToc; 1010 + ctx.mark_dirty(PAGE_REGION); 1011 + } 1012 + } 1013 + _ => {} 1014 + } 1015 + } 1016 + 1017 + fn on_quick_cycle_update(&mut self, id: u8, value: u8, _ctx: &mut AppContext) { 1018 + if id == QA_FONT_SIZE { 1019 + self.book_font_size_idx = value; 1020 + self.apply_font_metrics(); 1021 + if self.state == State::Ready { 1022 + if self.is_epub && self.chapters_cached { 1023 + self.state = State::NeedIndex; 1024 + } else { 1025 + self.state = State::NeedPage; 1026 + } 1027 + } 1028 + self.rebuild_quick_actions(); 1029 + } 1030 + } 1031 + 1032 + fn pending_setting(&self) -> Option<PendingSetting> { 1033 + Some(PendingSetting::BookFontSize(self.book_font_size_idx)) 1034 + } 1035 + 1036 + fn save_state(&self, bm: &mut bookmarks::BookmarkCache) { 1037 + self.save_position(bm); 1038 + } 1039 + 1040 + fn has_background_when_suspended(&self) -> bool { 1041 + self.has_bg_work() 1042 + } 1043 + 1044 + fn background_suspended(&mut self, k: &mut KernelHandle<'_>) { 1045 + self.bg_work_tick(k); 1046 + } 1047 + 1048 + fn draw(&self, strip: &mut StripBuffer) { 1049 + let cf = self.chrome_font; 1050 + 1051 + draw_chrome_text( 1052 + strip, 1053 + HEADER_REGION, 1054 + self.display_name(), 1055 + Alignment::CenterLeft, 1056 + cf, 1057 + ); 1058 + 1059 + if self.state == State::ShowToc { 1060 + draw_chrome_text(strip, STATUS_REGION, "Contents", Alignment::CenterRight, cf); 1061 + } else if self.is_epub && !self.spine.is_empty() { 1062 + let mut sbuf = StackFmt::<32>::new(); 1063 + if self.spine.len() > 1 { 1064 + if self.fully_indexed { 1065 + let _ = write!( 1066 + sbuf, 1067 + "Ch{}/{} {}/{}", 1068 + self.chapter + 1, 1069 + self.spine.len(), 1070 + self.page + 1, 1071 + self.total_pages 1072 + ); 1073 + } else { 1074 + let _ = write!( 1075 + sbuf, 1076 + "Ch{}/{} p{}", 1077 + self.chapter + 1, 1078 + self.spine.len(), 1079 + self.page + 1 1080 + ); 1081 + } 1082 + } else if self.fully_indexed { 1083 + let _ = write!(sbuf, "{}/{}", self.page + 1, self.total_pages); 1084 + } else { 1085 + let _ = write!(sbuf, "p{}", self.page + 1); 1086 + } 1087 + if self.bg_cache != BgCacheState::Idle { 1088 + let _ = write!(sbuf, " *"); 1089 + } 1090 + draw_chrome_text( 1091 + strip, 1092 + STATUS_REGION, 1093 + sbuf.as_str(), 1094 + Alignment::CenterRight, 1095 + cf, 1096 + ); 1097 + } else if self.file_size > 0 { 1098 + let mut sbuf = StackFmt::<24>::new(); 1099 + if self.fully_indexed { 1100 + let _ = write!(sbuf, "{}/{}", self.page + 1, self.total_pages); 1101 + } else { 1102 + let _ = write!(sbuf, "{} | {}%", self.page + 1, self.progress_pct()); 1103 + } 1104 + draw_chrome_text( 1105 + strip, 1106 + STATUS_REGION, 1107 + sbuf.as_str(), 1108 + Alignment::CenterRight, 1109 + cf, 1110 + ); 1111 + } 1112 + 1113 + if let Some(msg) = self.error { 1114 + draw_chrome_text(strip, LOADING_REGION, msg, Alignment::CenterLeft, cf); 1115 + return; 1116 + } 1117 + 1118 + if self.state != State::Ready && self.state != State::Error && self.state != State::ShowToc 1119 + { 1120 + let mut lbuf = StackFmt::<48>::new(); 1121 + match self.state { 1122 + State::NeedCache => { 1123 + let _ = write!(lbuf, "Preparing..."); 1124 + } 1125 + State::NeedIndex => { 1126 + let _ = write!(lbuf, "Indexing..."); 1127 + } 1128 + State::NeedPage => { 1129 + let _ = write!(lbuf, "Loading..."); 1130 + } 1131 + _ => { 1132 + let _ = write!(lbuf, "Loading..."); 1133 + } 1134 + } 1135 + draw_chrome_text( 1136 + strip, 1137 + LOADING_REGION, 1138 + lbuf.as_str(), 1139 + Alignment::CenterLeft, 1140 + cf, 1141 + ); 1142 + return; 1143 + } 1144 + 1145 + if self.state == State::ShowToc { 1146 + let toc_len = self.toc.len(); 1147 + if self.fonts.is_some() { 1148 + let font = fonts::body_font(self.book_font_size_idx); 1149 + let line_h = font.line_height as i32; 1150 + let ascent = font.ascent as i32; 1151 + let vis_max = (TEXT_AREA_H / font.line_height) as usize; 1152 + let visible = vis_max.min(toc_len.saturating_sub(self.toc_scroll)); 1153 + for i in 0..visible { 1154 + let idx = self.toc_scroll + i; 1155 + let entry = &self.toc.entries[idx]; 1156 + let y_top = TEXT_Y as i32 + i as i32 * line_h; 1157 + let baseline = y_top + ascent; 1158 + let selected = idx == self.toc_selected; 1159 + 1160 + if selected { 1161 + Rectangle::new( 1162 + Point::new(0, y_top), 1163 + Size::new(SCREEN_W as u32, line_h as u32), 1164 + ) 1165 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 1166 + .draw(strip) 1167 + .unwrap(); 1168 + } 1169 + 1170 + let fg = if selected { 1171 + BinaryColor::Off 1172 + } else { 1173 + BinaryColor::On 1174 + }; 1175 + let mut cx = MARGIN as i32; 1176 + if entry.spine_idx != 0xFFFF && entry.spine_idx == self.chapter { 1177 + cx += font.draw_char_fg(strip, '>', fg, cx, baseline) as i32; 1178 + cx += font.draw_char_fg(strip, ' ', fg, cx, baseline) as i32; 1179 + } 1180 + font.draw_str_fg(strip, entry.title_str(), fg, cx, baseline); 1181 + } 1182 + } else { 1183 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 1184 + let vis_max = (TEXT_AREA_H / LINE_H) as usize; 1185 + let visible = vis_max.min(toc_len.saturating_sub(self.toc_scroll)); 1186 + for i in 0..visible { 1187 + let idx = self.toc_scroll + i; 1188 + let entry = &self.toc.entries[idx]; 1189 + let y = TEXT_Y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 1190 + let marker = if idx == self.toc_selected { "> " } else { " " }; 1191 + Text::new(marker, Point::new(0, y), style) 1192 + .draw(strip) 1193 + .unwrap(); 1194 + Text::new(entry.title_str(), Point::new(MARGIN as i32, y), style) 1195 + .draw(strip) 1196 + .unwrap(); 1197 + } 1198 + } 1199 + return; 1200 + } 1201 + 1202 + if let Some(ref fs) = self.fonts { 1203 + let line_h = self.font_line_h as i32; 1204 + let ascent = self.font_ascent as i32; 1205 + 1206 + // fullscreen image: centre in text area, skip normal line layout 1207 + if self.fullscreen_img { 1208 + if let Some(ref img) = self.page_img { 1209 + let img_x = MARGIN as i32 + ((TEXT_W as i32 - img.width as i32) / 2).max(0); 1210 + let img_y = 1211 + TEXT_Y as i32 + ((TEXT_AREA_H as i32 - img.height as i32) / 2).max(0); 1212 + strip.blit_1bpp( 1213 + &img.data, 1214 + 0, 1215 + img.width as usize, 1216 + img.height as usize, 1217 + img.stride, 1218 + img_x, 1219 + img_y, 1220 + true, 1221 + ); 1222 + } 1223 + } else { 1224 + let mut img_rendered = false; 1225 + for i in 0..self.line_count { 1226 + let span = self.lines[i]; 1227 + 1228 + if span.is_image() { 1229 + if span.is_image_origin() && !img_rendered { 1230 + let y_top = TEXT_Y as i32 + i as i32 * line_h; 1231 + if let Some(ref img) = self.page_img { 1232 + let img_x = 1233 + MARGIN as i32 + ((TEXT_W as i32 - img.width as i32) / 2).max(0); 1234 + let blit_h = (img.height as usize).min(IMAGE_DISPLAY_H as usize); 1235 + strip.blit_1bpp( 1236 + &img.data, 1237 + 0, 1238 + img.width as usize, 1239 + blit_h, 1240 + img.stride, 1241 + img_x, 1242 + y_top, 1243 + true, 1244 + ); 1245 + img_rendered = true; 1246 + } else { 1247 + let baseline = y_top + ascent; 1248 + fs.draw_str( 1249 + strip, 1250 + "[image]", 1251 + fonts::Style::Italic, 1252 + MARGIN as i32, 1253 + baseline, 1254 + ); 1255 + } 1256 + } 1257 + continue; 1258 + } 1259 + 1260 + let start = span.start as usize; 1261 + let end = start + span.len as usize; 1262 + let baseline = TEXT_Y as i32 + i as i32 * line_h + ascent; 1263 + let x_indent = INDENT_PX as i32 * span.indent as i32; 1264 + 1265 + let line = &self.buf[start..end]; 1266 + let mut cx = MARGIN as i32 + x_indent; 1267 + let mut sty = span.style(); 1268 + let mut j = 0usize; 1269 + while j < line.len() { 1270 + let b = line[j]; 1271 + if b == MARKER && j + 1 < line.len() { 1272 + sty = match line[j + 1] { 1273 + BOLD_ON => fonts::Style::Bold, 1274 + ITALIC_ON => fonts::Style::Italic, 1275 + HEADING_ON => fonts::Style::Heading, 1276 + BOLD_OFF | ITALIC_OFF | HEADING_OFF => fonts::Style::Regular, 1277 + _ => sty, 1278 + }; 1279 + j += 2; 1280 + continue; 1281 + } 1282 + if b >= 0xC0 { 1283 + let (ch, seq_len) = decode_utf8_char(line, j); 1284 + cx += fs.draw_char(strip, ch, sty, cx, baseline) as i32; 1285 + j += seq_len; 1286 + continue; 1287 + } 1288 + if b >= 0x80 { 1289 + // continuation byte mid-stream (already consumed 1290 + // by a lead byte above, or stray), skip 1291 + j += 1; 1292 + continue; 1293 + } 1294 + if b < bitmap::FIRST_CHAR { 1295 + j += 1; 1296 + continue; // control char 1297 + } 1298 + cx += fs.draw_char(strip, b as char, sty, cx, baseline) as i32; 1299 + j += 1; 1300 + } 1301 + } 1302 + } 1303 + } else { 1304 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 1305 + for i in 0..self.line_count { 1306 + let span = self.lines[i]; 1307 + let start = span.start as usize; 1308 + let end = start + span.len as usize; 1309 + let text = core::str::from_utf8(&self.buf[start..end]).unwrap_or(""); 1310 + let y = TEXT_Y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 1311 + Text::new(text, Point::new(MARGIN as i32, y), style) 1312 + .draw(strip) 1313 + .unwrap(); 1314 + } 1315 + } 1316 + 1317 + if self.state == State::Ready && (self.file_size > 0 || self.is_epub) { 1318 + let pct = self.progress_pct() as u32; 1319 + let filled_w = (PROGRESS_W as u32 * pct / 100).min(PROGRESS_W as u32); 1320 + if filled_w > 0 { 1321 + Rectangle::new( 1322 + Point::new(MARGIN as i32, PROGRESS_Y as i32), 1323 + Size::new(filled_w, PROGRESS_H as u32), 1324 + ) 1325 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 1326 + .draw(strip) 1327 + .unwrap(); 1328 + } 1329 + } 1330 + 1331 + if self.show_position 1332 + && self.state == State::Ready 1333 + && POSITION_OVERLAY.intersects(strip.logical_window()) 1334 + { 1335 + let mut pbuf = StackFmt::<48>::new(); 1336 + if self.is_epub && self.spine.len() > 1 { 1337 + if self.fully_indexed { 1338 + let _ = write!( 1339 + pbuf, 1340 + "Ch {}/{} Page {}/{}", 1341 + self.chapter + 1, 1342 + self.spine.len(), 1343 + self.page + 1, 1344 + self.total_pages 1345 + ); 1346 + } else { 1347 + let _ = write!( 1348 + pbuf, 1349 + "Ch {}/{} Page {}", 1350 + self.chapter + 1, 1351 + self.spine.len(), 1352 + self.page + 1 1353 + ); 1354 + } 1355 + } else if self.fully_indexed { 1356 + let _ = write!(pbuf, "Page {}/{}", self.page + 1, self.total_pages); 1357 + } else { 1358 + let _ = write!(pbuf, "Page {} ({}%)", self.page + 1, self.progress_pct()); 1359 + } 1360 + 1361 + POSITION_OVERLAY 1362 + .to_rect() 1363 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 1364 + .draw(strip) 1365 + .unwrap(); 1366 + let text = pbuf.as_str(); 1367 + if let Some(f) = cf { 1368 + f.draw_aligned( 1369 + strip, 1370 + POSITION_OVERLAY, 1371 + text, 1372 + Alignment::Center, 1373 + BinaryColor::Off, 1374 + ); 1375 + } else { 1376 + let tw = text.len() as u32 * 6; 1377 + let pos = Alignment::Center.position(POSITION_OVERLAY, Size::new(tw, 13)); 1378 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::Off); 1379 + Text::new(text, Point::new(pos.x, pos.y + 13), style) 1380 + .draw(strip) 1381 + .unwrap(); 1382 + } 1383 + } 1384 + } 1385 + }
+642
src/apps/reader/paging.rs
··· 1 + // text wrapping, page navigation, and load/prefetch 2 + 3 + use smol_epub::cache; 4 + use smol_epub::html_strip::{ 5 + BOLD_OFF, BOLD_ON, HEADING_OFF, HEADING_ON, IMG_REF, ITALIC_OFF, ITALIC_ON, MARKER, QUOTE_OFF, 6 + QUOTE_ON, 7 + }; 8 + 9 + use crate::fonts; 10 + use crate::kernel::KernelHandle; 11 + 12 + use super::{ 13 + IMAGE_DISPLAY_H, INDENT_PX, LINES_PER_PAGE, LineSpan, MAX_PAGES, NO_PREFETCH, PAGE_BUF, 14 + ReaderApp, State, TEXT_W, 15 + }; 16 + 17 + impl ReaderApp { 18 + pub(super) fn wrap_lines_counted(&mut self, n: usize) -> usize { 19 + let fonts_copy = self.fonts; 20 + 21 + if let Some(fs) = fonts_copy { 22 + let (c, count) = 23 + wrap_proportional(&self.buf, n, &fs, &mut self.lines, self.max_lines, TEXT_W); 24 + self.line_count = count; 25 + c 26 + } else { 27 + self.wrap_monospace(n) 28 + } 29 + } 30 + 31 + pub(super) fn wrap_monospace(&mut self, n: usize) -> usize { 32 + use super::CHARS_PER_LINE; 33 + 34 + let max = self.max_lines; 35 + self.line_count = 0; 36 + let mut col: usize = 0; 37 + let mut line_start: usize = 0; 38 + 39 + for i in 0..n { 40 + let b = self.buf[i]; 41 + match b { 42 + b'\r' => {} 43 + b'\n' => { 44 + let end = trim_trailing_cr(&self.buf, line_start, i); 45 + self.push_line(line_start, end); 46 + line_start = i + 1; 47 + col = 0; 48 + if self.line_count >= max { 49 + return line_start; 50 + } 51 + } 52 + _ => { 53 + col += 1; 54 + if col >= CHARS_PER_LINE { 55 + self.push_line(line_start, i + 1); 56 + line_start = i + 1; 57 + col = 0; 58 + if self.line_count >= max { 59 + return line_start; 60 + } 61 + } 62 + } 63 + } 64 + } 65 + 66 + if line_start < n && self.line_count < max { 67 + let end = trim_trailing_cr(&self.buf, line_start, n); 68 + self.push_line(line_start, end); 69 + } 70 + 71 + n 72 + } 73 + 74 + pub(super) fn push_line(&mut self, start: usize, end: usize) { 75 + if self.line_count < LINES_PER_PAGE { 76 + self.lines[self.line_count] = LineSpan { 77 + start: start as u16, 78 + len: (end - start) as u16, 79 + flags: 0, 80 + indent: 0, 81 + }; 82 + self.line_count += 1; 83 + } 84 + } 85 + 86 + pub(super) fn reset_paging(&mut self) { 87 + self.page = 0; 88 + self.offsets[0] = 0; 89 + self.total_pages = 1; 90 + self.fully_indexed = false; 91 + self.buf_len = 0; 92 + self.line_count = 0; 93 + self.prefetch_page = NO_PREFETCH; 94 + self.prefetch_len = 0; 95 + self.page_img = None; 96 + self.fullscreen_img = false; 97 + } 98 + 99 + pub(super) fn load_and_prefetch( 100 + &mut self, 101 + k: &mut KernelHandle<'_>, 102 + ) -> Result<(), &'static str> { 103 + if !self.ch_cache.is_empty() { 104 + let start = (self.offsets[self.page] as usize).min(self.ch_cache.len()); 105 + let end = (start + PAGE_BUF).min(self.ch_cache.len()); 106 + let n = end - start; 107 + if n > 0 { 108 + self.buf[..n].copy_from_slice(&self.ch_cache[start..end]); 109 + } 110 + self.buf_len = n; 111 + self.prefetch_page = NO_PREFETCH; 112 + self.prefetch_len = 0; 113 + self.wrap_lines_counted(n); 114 + self.decode_page_images(k); 115 + return Ok(()); 116 + } 117 + 118 + let (nb, nl) = self.name_copy(); 119 + let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 120 + 121 + if self.prefetch_page == self.page { 122 + core::mem::swap(&mut self.buf, &mut self.prefetch); 123 + self.buf_len = self.prefetch_len; 124 + self.prefetch_page = NO_PREFETCH; 125 + self.prefetch_len = 0; 126 + } else if self.is_epub && self.chapters_cached { 127 + let dir_buf = self.cache_dir; 128 + let dir = cache::dir_name_str(&dir_buf); 129 + let ch_file = cache::chapter_file_name(self.chapter); 130 + let ch_str = cache::chapter_file_str(&ch_file); 131 + let n = 132 + k.sync_read_app_subdir_chunk(dir, ch_str, self.offsets[self.page], &mut self.buf)?; 133 + self.buf_len = n; 134 + } else if self.file_size == 0 { 135 + let (size, n) = k.sync_read_file_start(name, &mut self.buf)?; 136 + self.file_size = size; 137 + self.buf_len = n; 138 + log::info!("reader: opened {} ({} bytes)", name, size); 139 + 140 + if size == 0 { 141 + self.fully_indexed = true; 142 + self.line_count = 0; 143 + return Ok(()); 144 + } 145 + } else { 146 + let n = k.sync_read_chunk(name, self.offsets[self.page], &mut self.buf)?; 147 + self.buf_len = n; 148 + } 149 + 150 + let consumed = self.wrap_lines_counted(self.buf_len); 151 + let next_offset = self.offsets[self.page] + consumed as u32; 152 + 153 + if self.page + 1 >= self.total_pages && !self.fully_indexed { 154 + if self.line_count >= self.max_lines && next_offset < self.file_size { 155 + if self.total_pages < MAX_PAGES { 156 + self.offsets[self.total_pages] = next_offset; 157 + self.total_pages += 1; 158 + } else { 159 + self.fully_indexed = true; 160 + } 161 + } else { 162 + self.fully_indexed = true; 163 + } 164 + } 165 + 166 + if self.page + 1 < self.total_pages { 167 + let pf_offset = self.offsets[self.page + 1]; 168 + let pf_result = if self.is_epub && self.chapters_cached { 169 + let dir_buf = self.cache_dir; 170 + let dir = cache::dir_name_str(&dir_buf); 171 + let ch_file = cache::chapter_file_name(self.chapter); 172 + let ch_str = cache::chapter_file_str(&ch_file); 173 + k.sync_read_app_subdir_chunk(dir, ch_str, pf_offset, &mut self.prefetch) 174 + } else { 175 + k.sync_read_chunk(name, pf_offset, &mut self.prefetch) 176 + }; 177 + match pf_result { 178 + Ok(n) => { 179 + self.prefetch_len = n; 180 + self.prefetch_page = self.page + 1; 181 + } 182 + Err(_) => { 183 + self.prefetch_page = NO_PREFETCH; 184 + self.prefetch_len = 0; 185 + } 186 + } 187 + } else { 188 + self.prefetch_page = NO_PREFETCH; 189 + self.prefetch_len = 0; 190 + } 191 + 192 + self.decode_page_images(k); 193 + Ok(()) 194 + } 195 + 196 + pub(super) fn preindex_all_pages(&mut self) { 197 + if self.ch_cache.is_empty() { 198 + return; 199 + } 200 + 201 + let total = self.ch_cache.len(); 202 + self.offsets[0] = 0; 203 + self.total_pages = 1; 204 + 205 + let mut offset = 0usize; 206 + while offset < total && self.total_pages < MAX_PAGES { 207 + let end = (offset + PAGE_BUF).min(total); 208 + let n = end - offset; 209 + self.buf[..n].copy_from_slice(&self.ch_cache[offset..end]); 210 + self.buf_len = n; 211 + 212 + let consumed = self.wrap_lines_counted(n); 213 + let next_offset = offset + consumed; 214 + 215 + if self.line_count >= self.max_lines && next_offset < total { 216 + self.offsets[self.total_pages] = next_offset as u32; 217 + self.total_pages += 1; 218 + offset = next_offset; 219 + } else { 220 + break; 221 + } 222 + } 223 + 224 + self.fully_indexed = true; 225 + log::info!("chapter pre-indexed: {} pages", self.total_pages); 226 + } 227 + 228 + pub(super) fn scan_to_last_page( 229 + &mut self, 230 + k: &mut KernelHandle<'_>, 231 + ) -> Result<(), &'static str> { 232 + while !self.fully_indexed && self.total_pages < MAX_PAGES { 233 + self.page = self.total_pages - 1; 234 + self.load_and_prefetch(k)?; 235 + if self.page + 1 < self.total_pages { 236 + self.page += 1; 237 + } else { 238 + break; 239 + } 240 + } 241 + if self.total_pages > 0 { 242 + self.page = self.total_pages - 1; 243 + } 244 + self.prefetch_page = NO_PREFETCH; 245 + self.load_and_prefetch(k) 246 + } 247 + 248 + pub(super) fn page_forward(&mut self) -> bool { 249 + if self.state != State::Ready { 250 + return false; 251 + } 252 + 253 + if self.page + 1 < self.total_pages { 254 + self.page += 1; 255 + self.state = State::NeedPage; 256 + return true; 257 + } 258 + 259 + if self.is_epub && self.fully_indexed && (self.chapter as usize + 1) < self.spine.len() { 260 + self.chapter += 1; 261 + self.goto_last_page = false; 262 + self.state = State::NeedIndex; 263 + return true; 264 + } 265 + 266 + false 267 + } 268 + 269 + pub(super) fn page_backward(&mut self) -> bool { 270 + if self.state != State::Ready { 271 + return false; 272 + } 273 + 274 + if self.page > 0 { 275 + self.page -= 1; 276 + self.state = State::NeedPage; 277 + return true; 278 + } 279 + 280 + if self.is_epub && self.chapter > 0 { 281 + self.chapter -= 1; 282 + self.goto_last_page = true; 283 + self.state = State::NeedIndex; 284 + return true; 285 + } 286 + 287 + false 288 + } 289 + 290 + // next chapter (EPUB) or +10 pages (TXT) 291 + pub(super) fn jump_forward(&mut self) -> bool { 292 + if self.state != State::Ready { 293 + return false; 294 + } 295 + if self.is_epub { 296 + if (self.chapter as usize + 1) < self.spine.len() { 297 + self.chapter += 1; 298 + self.goto_last_page = false; 299 + self.state = State::NeedIndex; 300 + return true; 301 + } 302 + } else { 303 + let last = if self.total_pages > 0 { 304 + self.total_pages - 1 305 + } else { 306 + 0 307 + }; 308 + let target = (self.page + 10).min(last); 309 + if target != self.page { 310 + self.page = target; 311 + self.state = State::NeedPage; 312 + return true; 313 + } 314 + } 315 + false 316 + } 317 + 318 + // prev chapter (EPUB) or -10 pages (TXT) 319 + pub(super) fn jump_backward(&mut self) -> bool { 320 + if self.state != State::Ready { 321 + return false; 322 + } 323 + if self.is_epub { 324 + if self.chapter > 0 { 325 + self.chapter -= 1; 326 + self.goto_last_page = false; 327 + self.state = State::NeedIndex; 328 + return true; 329 + } 330 + } else { 331 + let target = self.page.saturating_sub(10); 332 + if target != self.page { 333 + self.page = target; 334 + self.state = State::NeedPage; 335 + return true; 336 + } 337 + } 338 + false 339 + } 340 + } 341 + 342 + // decode one utf-8 character starting at buf[pos] 343 + // returns (char, byte_length); malformed input yields ('\u{FFFD}', consumed) 344 + pub(super) fn decode_utf8_char(buf: &[u8], pos: usize) -> (char, usize) { 345 + let b0 = buf[pos]; 346 + let (mut cp, expected) = if b0 < 0xE0 { 347 + ((b0 as u32) & 0x1F, 2) 348 + } else if b0 < 0xF0 { 349 + ((b0 as u32) & 0x0F, 3) 350 + } else { 351 + ((b0 as u32) & 0x07, 4) 352 + }; 353 + let len = buf.len(); 354 + if pos + expected > len { 355 + return ('\u{FFFD}', len - pos); 356 + } 357 + for i in 1..expected { 358 + let cont = buf[pos + i]; 359 + if cont & 0xC0 != 0x80 { 360 + return ('\u{FFFD}', i); 361 + } 362 + cp = (cp << 6) | (cont as u32 & 0x3F); 363 + } 364 + let ch = char::from_u32(cp).unwrap_or('\u{FFFD}'); 365 + (ch, expected) 366 + } 367 + 368 + pub(super) fn trim_trailing_cr(buf: &[u8], start: usize, end: usize) -> usize { 369 + if end > start && buf[end - 1] == b'\r' { 370 + end - 1 371 + } else { 372 + end 373 + } 374 + } 375 + 376 + // true if ch is a word-separator for line-wrapping (space, NBSP, etc) 377 + #[inline] 378 + fn is_wrap_space(ch: char) -> bool { 379 + matches!(ch, ' ' | '\u{00A0}') 380 + } 381 + 382 + pub(super) fn wrap_proportional( 383 + buf: &[u8], 384 + n: usize, 385 + fonts: &fonts::FontSet, 386 + lines: &mut [LineSpan], 387 + max_lines: usize, 388 + max_width_px: u32, 389 + ) -> (usize, usize) { 390 + let max_l = max_lines.min(lines.len()); 391 + let base_max_w = max_width_px; 392 + let mut lc: usize = 0; 393 + let mut ls: usize = 0; 394 + let mut px: u32 = 0; 395 + let mut sp: usize = 0; 396 + let mut sp_px: u32 = 0; 397 + 398 + let mut bold = false; 399 + let mut italic = false; 400 + let mut heading = false; 401 + let mut indent: u8 = 0; 402 + let mut max_w = base_max_w; 403 + 404 + #[inline] 405 + fn current_style(bold: bool, italic: bool, heading: bool) -> fonts::Style { 406 + if heading { 407 + fonts::Style::Heading 408 + } else if bold { 409 + fonts::Style::Bold 410 + } else if italic { 411 + fonts::Style::Italic 412 + } else { 413 + fonts::Style::Regular 414 + } 415 + } 416 + 417 + macro_rules! emit { 418 + ($start:expr, $end:expr) => { 419 + if lc < max_l { 420 + let e = trim_trailing_cr(buf, $start, $end); 421 + lines[lc] = LineSpan { 422 + start: ($start) as u16, 423 + len: (e - ($start)) as u16, 424 + flags: LineSpan::pack_flags(bold, italic, heading), 425 + indent, 426 + }; 427 + lc += 1; 428 + } 429 + }; 430 + } 431 + 432 + let mut i = 0; 433 + while i < n { 434 + let b = buf[i]; 435 + 436 + if b == MARKER && i + 1 < n { 437 + if buf[i + 1] == IMG_REF && i + 2 < n { 438 + let path_len = buf[i + 2] as usize; 439 + let path_start = i + 3; 440 + if path_start + path_len <= n && path_len > 0 { 441 + if ls < i { 442 + emit!(ls, i); 443 + if lc >= max_l { 444 + return (i, lc); 445 + } 446 + } 447 + 448 + let line_h = fonts.line_height(fonts::Style::Regular); 449 + let img_lines = (IMAGE_DISPLAY_H / line_h).max(1) as usize; 450 + 451 + if lc < max_l { 452 + lines[lc] = LineSpan { 453 + start: path_start as u16, 454 + len: path_len as u16, 455 + flags: LineSpan::FLAG_IMAGE, 456 + indent: 0, 457 + }; 458 + lc += 1; 459 + } 460 + 461 + for _ in 1..img_lines { 462 + if lc >= max_l { 463 + break; 464 + } 465 + lines[lc] = LineSpan { 466 + start: 0, 467 + len: 0, 468 + flags: LineSpan::FLAG_IMAGE, 469 + indent: 0, 470 + }; 471 + lc += 1; 472 + } 473 + 474 + i = path_start + path_len; 475 + ls = i; 476 + px = 0; 477 + sp = ls; 478 + sp_px = 0; 479 + if lc >= max_l { 480 + return (ls, lc); 481 + } 482 + continue; 483 + } 484 + } 485 + 486 + match buf[i + 1] { 487 + BOLD_ON => bold = true, 488 + BOLD_OFF => bold = false, 489 + ITALIC_ON => italic = true, 490 + ITALIC_OFF => italic = false, 491 + HEADING_ON => heading = true, 492 + HEADING_OFF => heading = false, 493 + QUOTE_ON => { 494 + indent = indent.saturating_add(1); 495 + max_w = base_max_w.saturating_sub(INDENT_PX * indent as u32); 496 + } 497 + QUOTE_OFF => { 498 + indent = indent.saturating_sub(1); 499 + max_w = base_max_w.saturating_sub(INDENT_PX * indent as u32); 500 + } 501 + _ => {} 502 + } 503 + i += 2; 504 + continue; 505 + } 506 + 507 + if b == b'\r' { 508 + i += 1; 509 + continue; 510 + } 511 + 512 + if b == b'\n' { 513 + emit!(ls, i); 514 + ls = i + 1; 515 + px = 0; 516 + sp = ls; 517 + sp_px = 0; 518 + if lc >= max_l { 519 + return (ls, lc); 520 + } 521 + i += 1; 522 + continue; 523 + } 524 + 525 + // UTF-8 multi-byte: decode the full character and measure it 526 + // using the font's extended glyph tables 527 + if b >= 0xC0 { 528 + let (ch, seq_len) = decode_utf8_char(buf, i); 529 + 530 + // soft hyphen (U+00AD): zero-width break opportunity 531 + if ch == '\u{00AD}' { 532 + sp = i + seq_len; 533 + sp_px = px; 534 + i += seq_len; 535 + continue; 536 + } 537 + 538 + // NBSP and regular spaces: word-break opportunity 539 + if is_wrap_space(ch) { 540 + let sty = current_style(bold, italic, heading); 541 + px += fonts.advance(' ', sty) as u32; 542 + sp = i + seq_len; 543 + sp_px = px; 544 + if px > max_w { 545 + emit!(ls, i); 546 + ls = i + seq_len; 547 + px = 0; 548 + sp = ls; 549 + sp_px = 0; 550 + if lc >= max_l { 551 + return (ls, lc); 552 + } 553 + } 554 + i += seq_len; 555 + continue; 556 + } 557 + 558 + let sty = current_style(bold, italic, heading); 559 + let adv = fonts.advance(ch, sty) as u32; 560 + px += adv; 561 + if px > max_w { 562 + if sp > ls { 563 + emit!(ls, sp); 564 + px -= sp_px; 565 + ls = sp; 566 + } else { 567 + emit!(ls, i); 568 + ls = i; 569 + px = adv; 570 + } 571 + sp = ls; 572 + sp_px = 0; 573 + if lc >= max_l { 574 + return (ls, lc); 575 + } 576 + } 577 + i += seq_len; 578 + continue; 579 + } 580 + if b >= 0x80 { 581 + // stray continuation byte 582 + i += 1; 583 + continue; 584 + } 585 + 586 + let sty = current_style(bold, italic, heading); 587 + let adv = fonts.advance_byte(b, sty) as u32; 588 + 589 + if b == b' ' { 590 + px += adv; 591 + sp = i + 1; 592 + sp_px = px; 593 + if px > max_w { 594 + emit!(ls, i); 595 + ls = i + 1; 596 + px = 0; 597 + sp = ls; 598 + sp_px = 0; 599 + if lc >= max_l { 600 + return (ls, lc); 601 + } 602 + } 603 + i += 1; 604 + continue; 605 + } 606 + 607 + px += adv; 608 + if px > max_w { 609 + if sp > ls { 610 + emit!(ls, sp); 611 + px -= sp_px; 612 + ls = sp; 613 + } else { 614 + emit!(ls, i); 615 + ls = i; 616 + px = adv; 617 + } 618 + sp = ls; 619 + sp_px = 0; 620 + if lc >= max_l { 621 + return (ls, lc); 622 + } 623 + } 624 + 625 + i += 1; 626 + } 627 + 628 + if ls < n && lc < max_l { 629 + let e = trim_trailing_cr(buf, ls, n); 630 + if e > ls { 631 + lines[lc] = LineSpan { 632 + start: ls as u16, 633 + len: (e - ls) as u16, 634 + flags: LineSpan::pack_flags(bold, italic, heading), 635 + indent, 636 + }; 637 + lc += 1; 638 + } 639 + } 640 + 641 + (n, lc) 642 + }
+30 -34
src/apps/recent.rs kernel/src/kernel/bookmarks.rs
··· 1 - // Bookmark cache: 16 slots, RAM-resident, flushed to SD on dirty. 1 + // bookmark cache: 16 slots, RAM-resident, flushed to SD on dirty 2 2 // 3 - // Record layout (little-endian, 48 bytes per slot): 4 - // [0..4) name_hash u32 5 - // [4..8) byte_offset u32 font-independent file/chapter position 6 - // [8..10) chapter u16 epub chapter; 0 for txt 7 - // [10..12) flags u16 bit 0 = valid 8 - // [12..14) generation u16 LRU counter (higher = more recent) 9 - // [14] name_len u8 10 - // [15] _pad u8 11 - // [16..48) filename [u8;32] 3 + // record layout (little-endian, 48 bytes per slot): 4 + // [0..4) name_hash u32 [8..10) chapter u16 5 + // [4..8) byte_offset u32 [10..12) flags u16 (bit 0 = valid) 6 + // [12..14) generation u16 [14] name_len u8 [15] pad 7 + // [16..48) filename [u8;32] 12 8 13 9 use crate::drivers::sdcard::SdStorage; 14 10 use crate::drivers::storage; 15 - pub use smol_epub::cache::fnv1a; 16 - 17 - fn fnv1a_icase(data: &[u8]) -> u32 { 18 - let mut h: u32 = 0x811c_9dc5; 19 - for &b in data { 20 - h ^= b.to_ascii_lowercase() as u32; 21 - h = h.wrapping_mul(0x0100_0193); 22 - } 23 - h 24 - } 11 + pub use smol_epub::cache::fnv1a_icase; 25 12 26 13 pub const BOOKMARK_FILE: &str = "BKMK.BIN"; 27 14 pub const SLOTS: usize = 16; ··· 121 108 } 122 109 } 123 110 111 + // 16-slot LRU bookmark cache; flushed to _PULP/BKMK.BIN periodically 124 112 pub struct BookmarkCache { 125 113 slots: [BookmarkSlot; SLOTS], 126 114 count: usize, // slots present in file; new saves past this extend count ··· 152 140 self.loaded 153 141 } 154 142 155 - pub fn ensure_loaded<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 143 + pub fn ensure_loaded(&mut self, sd: &SdStorage) { 156 144 if self.loaded { 157 145 return; 158 146 } 159 147 self.force_load(sd); 160 148 } 161 149 162 - pub fn force_load<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 150 + pub fn force_load(&mut self, sd: &SdStorage) { 163 151 let mut buf = [0u8; FILE_LEN]; 164 - let slot_count = match storage::read_pulp_file_start(sd, BOOKMARK_FILE, &mut buf) { 165 - Ok((_, n)) => (n / RECORD_LEN).min(SLOTS), 166 - Err(_) => 0, 167 - }; 152 + let slot_count = 153 + match storage::read_file_start_in_dir(sd, storage::PULP_DIR, BOOKMARK_FILE, &mut buf) { 154 + Ok((_, n)) => (n / RECORD_LEN).min(SLOTS), 155 + Err(_) => 0, 156 + }; 168 157 169 158 for i in 0..slot_count { 170 159 let base = i * RECORD_LEN; ··· 247 236 let mut max_gen: u16 = 0; 248 237 let mut target: Option<usize> = None; 249 238 let mut first_free: Option<usize> = None; 250 - let mut lru_slot: usize = 0; 239 + let mut lru_slot: Option<usize> = None; 251 240 let mut lru_gen: u16 = u16::MAX; 252 241 253 242 for i in 0..self.count { ··· 265 254 } 266 255 if slot.generation < lru_gen { 267 256 lru_gen = slot.generation; 268 - lru_slot = i; 257 + lru_slot = Some(i); 269 258 } 270 259 271 260 if slot.name_hash == key && slot.matches_name(filename) { ··· 274 263 } 275 264 } 276 265 277 - let write_slot = target.or(first_free).unwrap_or(if self.count >= SLOTS { 278 - lru_slot 279 - } else { 280 - self.count 266 + let write_slot = target.or(first_free).unwrap_or_else(|| { 267 + if self.count >= SLOTS { 268 + // evict the least-recently-used valid slot. if no valid 269 + // LRU candidate was found (every slot was invalid), they 270 + // would all have been captured by first_free above, so 271 + // this path is unreachable; fall back to 0 as a safe 272 + // default rather than panicking 273 + lru_slot.unwrap_or(0) 274 + } else { 275 + self.count 276 + } 281 277 }); 282 278 283 279 let generation = max_gen.wrapping_add(1); ··· 311 307 ); 312 308 } 313 309 314 - pub fn flush<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 310 + pub fn flush(&mut self, sd: &SdStorage) { 315 311 if !self.dirty || !self.loaded { 316 312 return; 317 313 } ··· 325 321 buf[base..base + RECORD_LEN].copy_from_slice(&rec); 326 322 } 327 323 328 - match storage::write_pulp_file(sd, BOOKMARK_FILE, &buf[..file_len]) { 324 + match storage::write_file_in_dir(sd, storage::PULP_DIR, BOOKMARK_FILE, &buf[..file_len]) { 329 325 Ok(_) => { 330 326 self.dirty = false; 331 327 log::info!("bookmarks: flushed {} slots to SD", self.count);
+46 -261
src/apps/settings.rs
··· 1 - // System settings; key=value text in _PULP/SETTINGS.TXT. 1 + // settings app UI; configuration types live in kernel::config 2 2 use core::fmt::Write as _; 3 3 4 - use crate::apps::{App, AppContext, Services, Transition}; 4 + use crate::apps::{App, AppContext, AppId, Transition}; 5 5 use crate::board::action::{Action, ActionEvent}; 6 6 use crate::board::{SCREEN_H, SCREEN_W}; 7 7 use crate::drivers::strip::StripBuffer; 8 8 use crate::fonts; 9 - use crate::fonts::bitmap::BitmapFont; 9 + use crate::fonts::max_size_idx; 10 + use crate::kernel::KernelHandle; 11 + use crate::kernel::config::{ 12 + self, SystemSettings, WifiConfig, parse_settings_txt, write_settings_txt, 13 + }; 10 14 use crate::ui::{Alignment, BitmapLabel, CONTENT_TOP, Region, StackFmt, wrap_next, wrap_prev}; 11 15 12 16 const ROW_H: u16 = 40; ··· 17 21 const LABEL_W: u16 = 160; 18 22 const COL_GAP: u16 = 8; 19 23 const VALUE_X: u16 = LABEL_X + LABEL_W + COL_GAP; 20 - const VALUE_W: u16 = 296; // reaches x = 472 24 + const VALUE_W: u16 = 296; 21 25 22 26 const NUM_ITEMS: usize = 4; 23 - const HEADING_ITEMS_GAP: u16 = 8; // gap between heading bottom and first row 24 - 25 - const SETTINGS_FILE: &str = "SETTINGS.TXT"; 26 - #[derive(Clone, Copy)] 27 - pub struct SystemSettings { 28 - pub sleep_timeout: u16, // minutes idle before sleep; 0 = never 29 - pub ghost_clear_every: u8, // partial refreshes before a forced full refresh 30 - pub book_font_size_idx: u8, // 0 = Small, 1 = Medium, 2 = Large 31 - pub ui_font_size_idx: u8, // 0 = Small, 1 = Medium, 2 = Large 32 - } 33 - 34 - impl Default for SystemSettings { 35 - fn default() -> Self { 36 - Self::defaults() 37 - } 38 - } 39 - 40 - impl SystemSettings { 41 - pub const fn defaults() -> Self { 42 - Self { 43 - sleep_timeout: 10, 44 - ghost_clear_every: 10, 45 - book_font_size_idx: 1, 46 - ui_font_size_idx: 1, 47 - } 48 - } 49 - 50 - fn sanitize(&mut self) { 51 - self.sleep_timeout = self.sleep_timeout.min(120); 52 - self.ghost_clear_every = self.ghost_clear_every.clamp(1, 50); 53 - self.book_font_size_idx = self.book_font_size_idx.min(2); 54 - self.ui_font_size_idx = self.ui_font_size_idx.min(2); 55 - } 56 - } 57 - 58 - pub const WIFI_SSID_CAP: usize = 32; 59 - pub const WIFI_PASS_CAP: usize = 63; 60 - 61 - pub struct WifiConfig { 62 - ssid: [u8; WIFI_SSID_CAP], 63 - ssid_len: u8, 64 - pass: [u8; WIFI_PASS_CAP], 65 - pass_len: u8, 66 - } 67 - 68 - impl WifiConfig { 69 - pub const fn empty() -> Self { 70 - Self { 71 - ssid: [0u8; WIFI_SSID_CAP], 72 - ssid_len: 0, 73 - pass: [0u8; WIFI_PASS_CAP], 74 - pass_len: 0, 75 - } 76 - } 77 - 78 - pub fn ssid(&self) -> &str { 79 - core::str::from_utf8(&self.ssid[..self.ssid_len as usize]).unwrap_or("") 80 - } 81 - 82 - pub fn password(&self) -> &str { 83 - core::str::from_utf8(&self.pass[..self.pass_len as usize]).unwrap_or("") 84 - } 85 - 86 - pub fn has_credentials(&self) -> bool { 87 - self.ssid_len > 0 88 - } 89 - 90 - fn set_ssid(&mut self, val: &[u8]) { 91 - let n = val.len().min(WIFI_SSID_CAP); 92 - self.ssid[..n].copy_from_slice(&val[..n]); 93 - self.ssid_len = n as u8; 94 - } 95 - 96 - fn set_pass(&mut self, val: &[u8]) { 97 - let n = val.len().min(WIFI_PASS_CAP); 98 - self.pass[..n].copy_from_slice(&val[..n]); 99 - self.pass_len = n as u8; 100 - } 101 - } 102 - 103 - fn trim(s: &[u8]) -> &[u8] { 104 - let mut start = 0; 105 - let mut end = s.len(); 106 - while start < end && matches!(s[start], b' ' | b'\t' | b'\r') { 107 - start += 1; 108 - } 109 - while end > start && matches!(s[end - 1], b' ' | b'\t' | b'\r') { 110 - end -= 1; 111 - } 112 - &s[start..end] 113 - } 114 - 115 - fn parse_u16(s: &[u8]) -> Option<u16> { 116 - if s.is_empty() { 117 - return None; 118 - } 119 - let mut val: u16 = 0; 120 - for &b in s { 121 - if !b.is_ascii_digit() { 122 - return None; 123 - } 124 - val = val.checked_mul(10)?.checked_add((b - b'0') as u16)?; 125 - } 126 - Some(val) 127 - } 128 - 129 - fn apply_setting(key: &[u8], val: &[u8], s: &mut SystemSettings, w: &mut WifiConfig) { 130 - match key { 131 - b"sleep_timeout" => { 132 - if let Some(v) = parse_u16(val) { 133 - s.sleep_timeout = v; 134 - } 135 - } 136 - b"ghost_clear" => { 137 - if let Some(v) = parse_u16(val) { 138 - s.ghost_clear_every = v as u8; 139 - } 140 - } 141 - b"book_font" => { 142 - if let Some(v) = parse_u16(val) { 143 - s.book_font_size_idx = v as u8; 144 - } 145 - } 146 - b"ui_font" => { 147 - if let Some(v) = parse_u16(val) { 148 - s.ui_font_size_idx = v as u8; 149 - } 150 - } 151 - b"wifi_ssid" => w.set_ssid(val), 152 - b"wifi_pass" => w.set_pass(val), 153 - _ => {} // unknown keys silently ignored for forward compat 154 - } 155 - } 156 - 157 - fn parse_settings_txt(data: &[u8], settings: &mut SystemSettings, wifi: &mut WifiConfig) { 158 - for line in data.split(|&b| b == b'\n') { 159 - let line = trim(line); 160 - if line.is_empty() || line[0] == b'#' { 161 - continue; 162 - } 163 - if let Some(eq) = line.iter().position(|&b| b == b'=') { 164 - let key = trim(&line[..eq]); 165 - let val = trim(&line[eq + 1..]); 166 - apply_setting(key, val, settings, wifi); 167 - } 168 - } 169 - } 170 - 171 - struct TxtWriter<'a> { 172 - buf: &'a mut [u8], 173 - pos: usize, 174 - } 175 - 176 - impl<'a> TxtWriter<'a> { 177 - fn new(buf: &'a mut [u8]) -> Self { 178 - Self { buf, pos: 0 } 179 - } 180 - 181 - fn put(&mut self, data: &[u8]) { 182 - let n = data.len().min(self.buf.len() - self.pos); 183 - self.buf[self.pos..self.pos + n].copy_from_slice(&data[..n]); 184 - self.pos += n; 185 - } 186 - 187 - fn put_u16(&mut self, val: u16) { 188 - if val == 0 { 189 - self.put(b"0"); 190 - return; 191 - } 192 - let mut digits = [0u8; 5]; 193 - let mut i = 5; 194 - let mut v = val; 195 - while v > 0 { 196 - i -= 1; 197 - digits[i] = b'0' + (v % 10) as u8; 198 - v /= 10; 199 - } 200 - self.put(&digits[i..5]); 201 - } 202 - 203 - fn kv_num(&mut self, key: &[u8], val: u16) { 204 - self.put(key); 205 - self.put(b"="); 206 - self.put_u16(val); 207 - self.put(b"\n"); 208 - } 209 - 210 - fn kv_str(&mut self, key: &[u8], val: &[u8]) { 211 - self.put(key); 212 - self.put(b"="); 213 - self.put(val); 214 - self.put(b"\n"); 215 - } 216 - 217 - fn len(&self) -> usize { 218 - self.pos 219 - } 220 - } 221 - 222 - fn write_settings_txt(s: &SystemSettings, w: &WifiConfig, buf: &mut [u8]) -> usize { 223 - let mut wr = TxtWriter::new(buf); 224 - wr.put(b"# pulp-os settings\n"); 225 - wr.put(b"# lines starting with # are ignored\n\n"); 226 - wr.kv_num(b"sleep_timeout", s.sleep_timeout); 227 - wr.kv_num(b"ghost_clear", s.ghost_clear_every as u16); 228 - wr.kv_num(b"book_font", s.book_font_size_idx as u16); 229 - wr.kv_num(b"ui_font", s.ui_font_size_idx as u16); 230 - wr.put(b"\n# wifi credentials for upload mode\n"); 231 - wr.kv_str(b"wifi_ssid", &w.ssid[..w.ssid_len as usize]); 232 - wr.kv_str(b"wifi_pass", &w.pass[..w.pass_len as usize]); 233 - wr.len() 234 - } 27 + const HEADING_ITEMS_GAP: u16 = 8; 235 28 236 29 impl Default for SettingsApp { 237 30 fn default() -> Self { ··· 245 38 selected: usize, 246 39 loaded: bool, 247 40 save_needed: bool, 248 - body_font: &'static BitmapFont, 249 - heading_font: &'static BitmapFont, 41 + ui_fonts: fonts::UiFonts, 250 42 items_top: u16, 251 43 } 252 44 253 45 impl SettingsApp { 254 46 pub fn new() -> Self { 255 - let hf = fonts::heading_font(0); 47 + let uf = fonts::UiFonts::for_size(0); 256 48 Self { 257 49 settings: SystemSettings::defaults(), 258 50 wifi: WifiConfig::empty(), 259 51 selected: 0, 260 52 loaded: false, 261 53 save_needed: false, 262 - body_font: fonts::body_font(0), 263 - heading_font: hf, 264 - items_top: CONTENT_TOP + 4 + hf.line_height + HEADING_ITEMS_GAP, 54 + ui_fonts: uf, 55 + items_top: CONTENT_TOP + 4 + uf.heading.line_height + HEADING_ITEMS_GAP, 265 56 } 266 57 } 267 58 268 59 pub fn set_ui_font_size(&mut self, idx: u8) { 269 - self.body_font = fonts::body_font(idx); 270 - self.heading_font = fonts::heading_font(idx); 271 - self.items_top = CONTENT_TOP + 4 + self.heading_font.line_height + HEADING_ITEMS_GAP; 60 + self.ui_fonts = fonts::UiFonts::for_size(idx); 61 + self.items_top = CONTENT_TOP + 4 + self.ui_fonts.heading.line_height + HEADING_ITEMS_GAP; 272 62 } 273 63 274 64 pub fn system_settings(&self) -> &SystemSettings { ··· 291 81 self.loaded 292 82 } 293 83 294 - pub fn load_eager<SPI: embedded_hal::spi::SpiDevice>( 295 - &mut self, 296 - services: &mut Services<'_, SPI>, 297 - ) { 298 - self.load(services); 84 + pub fn load_eager(&mut self, k: &mut KernelHandle<'_>) { 85 + self.load(k); 299 86 self.set_ui_font_size(self.settings.ui_font_size_idx); 300 87 } 301 88 302 - fn load<SPI: embedded_hal::spi::SpiDevice>(&mut self, services: &mut Services<'_, SPI>) { 89 + fn load(&mut self, k: &mut KernelHandle<'_>) { 303 90 let mut buf = [0u8; 512]; 304 91 305 92 self.settings = SystemSettings::defaults(); 306 93 self.wifi = WifiConfig::empty(); 307 94 308 - match services.read_pulp_start(SETTINGS_FILE, &mut buf) { 95 + match k.sync_read_app_data_start(config::SETTINGS_FILE, &mut buf) { 309 96 Ok((_size, n)) if n > 0 => { 310 97 parse_settings_txt(&buf[..n], &mut self.settings, &mut self.wifi); 311 98 self.settings.sanitize(); 312 - log::info!("settings: loaded from {}", SETTINGS_FILE); 99 + log::info!("settings: loaded from {}", config::SETTINGS_FILE); 313 100 } 314 101 _ => { 315 102 log::info!("settings: no file found, using defaults"); ··· 319 106 self.loaded = true; 320 107 } 321 108 322 - fn save<SPI: embedded_hal::spi::SpiDevice>(&self, services: &Services<'_, SPI>) -> bool { 109 + fn save(&self, k: &mut KernelHandle<'_>) -> bool { 323 110 let mut buf = [0u8; 512]; 324 111 let len = write_settings_txt(&self.settings, &self.wifi, &mut buf); 325 - match services.write_pulp(SETTINGS_FILE, &buf[..len]) { 112 + match k.sync_write_app_data(config::SETTINGS_FILE, &buf[..len]) { 326 113 Ok(_) => { 327 - log::info!("settings: saved to {}", SETTINGS_FILE); 114 + log::info!("settings: saved to {}", config::SETTINGS_FILE); 328 115 true 329 116 } 330 117 Err(e) => { ··· 389 176 self.settings.ghost_clear_every.saturating_add(5).min(50); 390 177 } 391 178 2 => { 392 - if self.settings.book_font_size_idx < 2 { 179 + if self.settings.book_font_size_idx < max_size_idx() { 393 180 self.settings.book_font_size_idx += 1; 394 181 } 395 182 } 396 183 3 => { 397 - if self.settings.ui_font_size_idx < 2 { 184 + if self.settings.ui_font_size_idx < max_size_idx() { 398 185 self.settings.ui_font_size_idx += 1; 399 186 } 400 187 } ··· 461 248 } 462 249 } 463 250 464 - impl App for SettingsApp { 465 - fn on_enter(&mut self, ctx: &mut AppContext) { 251 + impl App<AppId> for SettingsApp { 252 + async fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 466 253 self.selected = 0; 467 254 self.save_needed = false; 468 255 ctx.mark_dirty(Region::new( ··· 514 301 } 515 302 } 516 303 517 - fn needs_work(&self) -> bool { 518 - !self.loaded || self.save_needed 519 - } 520 - 521 - fn on_work<SPI: embedded_hal::spi::SpiDevice>( 522 - &mut self, 523 - services: &mut Services<'_, SPI>, 524 - ctx: &mut AppContext, 525 - ) { 304 + async fn background(&mut self, ctx: &mut AppContext, k: &mut KernelHandle<'_>) { 526 305 if !self.loaded { 527 - self.load(services); 306 + self.load(k); 528 307 ctx.request_full_redraw(); 529 308 return; 530 309 } 531 310 532 - if self.save_needed && self.save(services) { 533 - self.save_needed = false; 311 + if self.save_needed { 312 + if self.save(k) { 313 + self.save_needed = false; 314 + } 534 315 } 535 316 } 536 317 537 318 fn draw(&self, strip: &mut StripBuffer) { 538 - let title_region = Region::new(16, CONTENT_TOP + 4, 448, self.heading_font.line_height); 539 - BitmapLabel::new(title_region, "Settings", self.heading_font) 319 + let title_region = Region::new(16, CONTENT_TOP + 4, 448, self.ui_fonts.heading.line_height); 320 + BitmapLabel::new(title_region, "Settings", self.ui_fonts.heading) 540 321 .alignment(Alignment::CenterLeft) 541 322 .draw(strip) 542 323 .unwrap(); 543 324 544 325 if !self.loaded { 545 326 let r = Region::new(LABEL_X, self.items_top, 200, ROW_H); 546 - BitmapLabel::new(r, "Loading...", self.body_font) 327 + BitmapLabel::new(r, "Loading...", self.ui_fonts.body) 547 328 .alignment(Alignment::CenterLeft) 548 329 .draw(strip) 549 330 .unwrap(); ··· 555 336 for i in 0..NUM_ITEMS { 556 337 let selected = i == self.selected; 557 338 558 - BitmapLabel::new(self.label_region(i), Self::item_label(i), self.body_font) 559 - .alignment(Alignment::CenterLeft) 560 - .inverted(selected) 561 - .draw(strip) 562 - .unwrap(); 339 + BitmapLabel::new( 340 + self.label_region(i), 341 + Self::item_label(i), 342 + self.ui_fonts.body, 343 + ) 344 + .alignment(Alignment::CenterLeft) 345 + .inverted(selected) 346 + .draw(strip) 347 + .unwrap(); 563 348 564 349 self.format_value(i, &mut val_buf); 565 - BitmapLabel::new(self.value_region(i), val_buf.as_str(), self.body_font) 350 + BitmapLabel::new(self.value_region(i), val_buf.as_str(), self.ui_fonts.body) 566 351 .alignment(Alignment::Center) 567 352 .inverted(selected) 568 353 .draw(strip)
+21 -14
src/apps/upload.rs
··· 1 - // WiFi upload server: GET / serves HTML, POST /upload streams to SD, mDNS as pulp.local. 1 + // wifi upload server: HTTP file upload + mDNS (pulp.local) 2 2 3 3 use alloc::string::String; 4 4 use core::fmt::Write as FmtWrite; ··· 13 13 use esp_radio::wifi::{ClientConfig, Config, ModeConfig}; 14 14 use log::info; 15 15 16 - use crate::apps::settings::WifiConfig; 17 16 use crate::board::action::{Action, ActionEvent, ButtonMapper}; 18 17 use crate::board::{Epd, SCREEN_H, SCREEN_W}; 19 18 use crate::drivers::sdcard::SdStorage; ··· 21 20 use crate::drivers::strip::StripBuffer; 22 21 use crate::fonts; 23 22 use crate::fonts::bitmap::BitmapFont; 23 + use crate::kernel::config::WifiConfig; 24 24 use crate::kernel::tasks; 25 25 use crate::ui::{Alignment, BitmapLabel, ButtonFeedback, CONTENT_TOP, Region, stack_fmt}; 26 26 ··· 67 67 DeleteFailed, 68 68 } 69 69 70 - pub async fn run_upload_mode<SPI>( 70 + pub async fn run_upload_mode( 71 71 wifi: esp_hal::peripherals::WIFI<'static>, 72 72 epd: &mut Epd, 73 73 strip: &mut StripBuffer, 74 74 delay: &mut Delay, 75 - sd: &SdStorage<SPI>, 75 + sd: &SdStorage, 76 76 ui_font_size_idx: u8, 77 77 bumps: &ButtonFeedback, 78 78 wifi_cfg: &WifiConfig, 79 - ) where 80 - SPI: embedded_hal::spi::SpiDevice, 81 - { 79 + ) { 82 80 let heading = fonts::heading_font(ui_font_size_idx); 83 81 let body = fonts::chrome_font(); 84 82 ··· 326 324 info!("upload: exiting, tearing down WiFi"); 327 325 } 328 326 329 - async fn serve_one_request<SPI>( 327 + async fn serve_one_request( 330 328 stack: embassy_net::Stack<'_>, 331 329 rx_buf: &mut [u8], 332 330 tx_buf: &mut [u8], 333 - sd: &SdStorage<SPI>, 331 + sd: &SdStorage, 334 332 ) -> ServerEvent 335 333 where 336 - SPI: embedded_hal::spi::SpiDevice, 337 334 { 338 335 let mut socket = TcpSocket::new(stack, rx_buf, tx_buf); 339 336 socket.set_timeout(Some(Duration::from_secs(30))); ··· 546 543 ServerEvent::Nothing 547 544 } 548 545 549 - async fn handle_upload<SPI>( 546 + async fn handle_upload( 550 547 socket: &mut TcpSocket<'_>, 551 - sd: &SdStorage<SPI>, 548 + sd: &SdStorage, 552 549 boundary: &[u8], 553 550 initial_body: &[u8], 554 551 ) -> Result<([u8; 13], u8), &'static str> 555 552 where 556 - SPI: embedded_hal::spi::SpiDevice, 557 553 { 558 554 if boundary.len() > MAX_BOUNDARY_LEN { 559 555 return Err("boundary too long"); ··· 581 577 let (name_buf, name_len) = sanitize_83(raw_name); 582 578 if name_len == 0 { 583 579 return Err("invalid filename"); 580 + } 581 + 582 + // warn if sanitisation changed the name, two different 583 + // original names can map to the same 8.3 name, causing 584 + // the second upload to silently overwrite the first. 585 + if raw_name != &name_buf[..name_len as usize] { 586 + log::warn!( 587 + "upload: sanitised '{}' -> '{}' (may overwrite existing file)", 588 + core::str::from_utf8(raw_name).unwrap_or("?"), 589 + core::str::from_utf8(&name_buf[..name_len as usize]).unwrap_or("?"), 590 + ); 584 591 } 585 592 586 593 let file_start = pos + 4; ··· 851 858 return; 852 859 } 853 860 854 - info!("upload: mDNS query for pulp.local — responding"); 861 + info!("upload: mDNS query for pulp.local -- responding"); 855 862 856 863 let mut resp = [0u8; MDNS_RESPONSE_LEN]; 857 864 let len = build_mdns_response(&mut resp, ip_octets);
+13
src/apps/widgets/mod.rs
··· 1 + // font-dependent UI widgets (app-side) 2 + // 3 + // these widgets depend on BitmapFont from the fontdue pipeline and 4 + // live in the apps layer, not the kernel. the kernel's ui/ module 5 + // holds only font-independent primitives (Region, Alignment, etc.). 6 + 7 + pub mod bitmap_label; 8 + pub mod button_feedback; 9 + pub mod quick_menu; 10 + 11 + pub use bitmap_label::{BitmapDynLabel, BitmapLabel}; 12 + pub use button_feedback::{BUTTON_BAR_H, ButtonFeedback}; 13 + pub use quick_menu::QuickMenu;
+83 -641
src/bin/main.rs
··· 1 - // pulp-os: Embassy event loop, app dispatch, rendering. 1 + // hardware init, construct Kernel + AppManager, boot, run 2 2 3 3 #![no_std] 4 4 #![no_main] ··· 6 6 use esp_backtrace as _; 7 7 use esp_hal::clock::CpuClock; 8 8 use esp_hal::delay::Delay; 9 - use esp_hal::gpio::RtcPinWithResistors; 10 9 use esp_hal::interrupt::software::SoftwareInterruptControl; 11 - use esp_hal::rtc_cntl::Rtc; 12 - use esp_hal::rtc_cntl::sleep::{RtcioWakeupSource, WakeupLevel}; 10 + use esp_hal::ram; 13 11 use esp_hal::timer::timg::TimerGroup; 14 12 use log::info; 15 13 16 - use embassy_futures::select::{Either, select}; 17 - use embassy_time::{Duration, Ticker}; 18 - 14 + use pulp_os::apps::Launcher; 19 15 use pulp_os::apps::files::FilesApp; 20 16 use pulp_os::apps::home::HomeApp; 21 - use pulp_os::apps::reader::{self, ReaderApp}; 17 + use pulp_os::apps::manager::AppManager; 18 + use pulp_os::apps::reader::ReaderApp; 22 19 use pulp_os::apps::settings::SettingsApp; 23 - use pulp_os::apps::{ 24 - App, AppContext, AppId, BookmarkCache, Launcher, Redraw, Services, Transition, 25 - }; 26 - use pulp_os::board::Board; 27 - use pulp_os::board::action::{Action, ActionEvent, ButtonMapper}; 20 + use pulp_os::apps::widgets::{ButtonFeedback, QuickMenu}; 21 + use pulp_os::board::action::ButtonMapper; 22 + use pulp_os::board::{Board, speed_up_spi}; 28 23 use pulp_os::drivers::battery; 29 24 use pulp_os::drivers::input::InputDriver; 30 - use pulp_os::drivers::storage::{self, DirCache}; 25 + use pulp_os::drivers::sdcard::SdStorage; 26 + use pulp_os::drivers::storage; 31 27 use pulp_os::drivers::strip::StripBuffer; 32 - use pulp_os::fonts; 28 + use pulp_os::kernel::BookmarkCache; 29 + use pulp_os::kernel::BootConsole; 30 + use pulp_os::kernel::Kernel; 31 + use pulp_os::kernel::dir_cache::DirCache; 33 32 use pulp_os::kernel::tasks; 34 - use pulp_os::kernel::uptime_secs; 35 - use pulp_os::ui::quick_menu::{MAX_APP_ACTIONS, QuickMenuResult}; 36 - use pulp_os::ui::{ 37 - BAR_HEIGHT, ButtonFeedback, QuickMenu, StatusBar, SystemStatus, free_stack_bytes, paint_stack, 38 - stack_high_water_mark, 39 - }; 33 + use pulp_os::kernel::work_queue; 34 + use pulp_os::ui::paint_stack; 40 35 use static_cell::{ConstStaticCell, StaticCell}; 41 36 42 37 esp_bootloader_esp_idf::esp_app_desc!(); 43 38 44 - const TICK_MS: u64 = 10; 45 - 46 - const DEFAULT_GHOST_CLEAR_EVERY: u32 = 10; 47 - 48 - struct Apps { 49 - home: &'static mut HomeApp, 50 - files: &'static mut FilesApp, 51 - reader: &'static mut ReaderApp, 52 - settings: &'static mut SettingsApp, 53 - } 54 - 55 - impl Apps { 56 - fn propagate_fonts(&mut self, quick_menu: &mut QuickMenu, bumps: &mut ButtonFeedback) { 57 - let ui_idx = self.settings.system_settings().ui_font_size_idx; 58 - let book_idx = self.settings.system_settings().book_font_size_idx; 59 - self.home.set_ui_font_size(ui_idx); 60 - self.files.set_ui_font_size(ui_idx); 61 - self.settings.set_ui_font_size(ui_idx); 62 - self.reader.set_book_font_size(book_idx); 63 - let chrome = fonts::chrome_font(); 64 - self.reader.set_chrome_font(chrome); 65 - quick_menu.set_chrome_font(chrome); 66 - bumps.set_chrome_font(chrome); 67 - } 68 - } 69 - 70 - macro_rules! with_app { 71 - ($id:expr, $apps:expr, |$app:ident| $body:expr) => { 72 - match $id { 73 - AppId::Home => { 74 - let $app = &mut *$apps.home; 75 - $body 76 - } 77 - AppId::Files => { 78 - let $app = &mut *$apps.files; 79 - $body 80 - } 81 - AppId::Reader => { 82 - let $app = &mut *$apps.reader; 83 - $body 84 - } 85 - AppId::Settings => { 86 - let $app = &mut *$apps.settings; 87 - $body 88 - } 89 - AppId::Upload => { 90 - unreachable!("Upload mode is handled outside the app dispatch loop"); 91 - } 92 - } 93 - }; 94 - } 95 - 96 - macro_rules! apply_transition { 97 - ($nav:expr, $launcher:expr, $apps:expr, $bm_cache:expr, 98 - $quick_menu:expr, $bumps:expr) => {{ 99 - let nav = $nav; 100 - info!("app: {:?} -> {:?}", nav.from, nav.to); 101 - 102 - if nav.from == AppId::Reader { 103 - $apps.reader.save_position($bm_cache); 104 - } 105 - 106 - if nav.from != AppId::Upload { 107 - if nav.suspend { 108 - with_app!(nav.from, $apps, |app| { 109 - app.on_suspend(); 110 - }); 111 - } else { 112 - with_app!(nav.from, $apps, |app| { 113 - app.on_exit(); 114 - }); 115 - } 116 - } 117 - 118 - $apps.propagate_fonts($quick_menu, $bumps); 119 - 120 - if nav.to != AppId::Upload { 121 - if nav.resume { 122 - with_app!(nav.to, $apps, |app| { 123 - app.on_resume(&mut $launcher.ctx); 124 - }); 125 - } else { 126 - with_app!(nav.to, $apps, |app| { 127 - app.on_enter(&mut $launcher.ctx); 128 - }); 129 - } 130 - } 131 - }}; 132 - } 133 - 134 - // busy-wait with input: selects on BUSY pin, input channel, work ticker. 135 - // macro because it .awaits inside the main async fn. 136 - macro_rules! busy_wait_with_input { 137 - ($epd:expr, $mapper:expr, 138 - $quick_menu:expr, $launcher:expr, $apps:expr, 139 - $dir_cache:expr, $bm_cache:expr, $sd:expr) => {{ 140 - let mut _deferred: Option<Transition> = None; 141 - let mut _work_ticker = Ticker::every(Duration::from_millis(TICK_MS)); 142 - loop { 143 - if !$epd.is_busy() { 144 - break; 145 - } 146 - 147 - match select( 148 - $epd.busy_pin().wait_for_low(), 149 - select(tasks::INPUT_EVENTS.receive(), _work_ticker.next()), 150 - ) 151 - .await 152 - { 153 - Either::First(_) => break, 154 - 155 - Either::Second(Either::First(hw_event)) => { 156 - let event = $mapper.map_event(hw_event); 157 - 158 - if $quick_menu.open { 159 - continue; 160 - } 161 - 162 - let active = $launcher.active(); 163 - let t = with_app!(active, $apps, |app| app.on_event(event, &mut $launcher.ctx)); 164 - if !matches!(t, Transition::None) && _deferred.is_none() { 165 - _deferred = Some(t); 166 - } 167 - } 168 - 169 - Either::Second(Either::Second(_)) => {} 170 - } 171 - 172 - let active = $launcher.active(); 173 - let needs = with_app!(active, $apps, |app| app.needs_work()); 174 - if needs { 175 - let mut svc = Services::new($dir_cache, $bm_cache, &$sd); 176 - with_app!(active, $apps, |app| app 177 - .on_work(&mut svc, &mut $launcher.ctx)); 178 - } 179 - } 180 - _deferred 181 - }}; 182 - } 183 - 184 - macro_rules! enter_sleep { 185 - ($reason:expr, $bm_cache:expr, $board:expr, $strip:expr, $delay:expr) => {{ 186 - info!("{}: entering sleep...", $reason); 187 - 188 - if $bm_cache.is_dirty() { 189 - $bm_cache.flush(&$board.storage.sd); 190 - } 191 - 192 - $board 193 - .display 194 - .epd 195 - .full_refresh_async($strip, &mut $delay, &|s: &mut StripBuffer| { 196 - use embedded_graphics::mono_font::MonoTextStyle; 197 - use embedded_graphics::mono_font::ascii::FONT_6X13; 198 - use embedded_graphics::pixelcolor::BinaryColor; 199 - use embedded_graphics::prelude::*; 200 - use embedded_graphics::text::Text; 201 - 202 - let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On); 203 - let _ = Text::new("(sleep)", Point::new(210, 400), style).draw(s); 204 - }) 205 - .await; 206 - info!("display: sleep screen rendered"); 207 - 208 - $board.display.epd.enter_deep_sleep(); 209 - info!("display: deep sleep mode 1"); 210 - 211 - let mut rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() }); 212 - let mut gpio3 = unsafe { esp_hal::peripherals::GPIO3::steal() }; 213 - let wakeup_pins: &mut [(&mut dyn RtcPinWithResistors, WakeupLevel)] = 214 - &mut [(&mut gpio3, WakeupLevel::Low)]; 215 - let rtcio = RtcioWakeupSource::new(wakeup_pins); 216 - 217 - info!("mcu: entering deep sleep (power button to wake)"); 218 - rtc.sleep_deep(&[&rtcio]); 219 - }}; 220 - } 221 - 222 - // Build the per-strip draw closure: statusbar, active app, quick-menu overlay, button labels. 223 - // Macro because it captures borrows of different concrete app types (via with_app!). 224 - macro_rules! draw_scene { 225 - ($app:expr, $statusbar:expr, $quick_menu:expr, $bumps:expr, $draw_bar:expr) => { 226 - |s: &mut StripBuffer| { 227 - if $draw_bar { 228 - $statusbar.draw(s).unwrap(); 229 - } 230 - $app.draw(s); 231 - if $quick_menu.open { 232 - $quick_menu.draw(s); 233 - } 234 - $bumps.draw(s); 235 - } 236 - }; 237 - } 238 - 239 - // heavy statics out of the async future; ConstStaticCell for const-fn types, StaticCell otherwise 39 + // heavy statics: kept out of the async future to keep it ~200 B 240 40 241 41 static STRIP: ConstStaticCell<StripBuffer> = ConstStaticCell::new(StripBuffer::new()); 242 - static STATUSBAR: ConstStaticCell<StatusBar> = ConstStaticCell::new(StatusBar::new()); 243 42 static READER: ConstStaticCell<ReaderApp> = ConstStaticCell::new(ReaderApp::new()); 244 43 static LAUNCHER: ConstStaticCell<Launcher> = ConstStaticCell::new(Launcher::new()); 245 44 static QUICK_MENU: ConstStaticCell<QuickMenu> = ConstStaticCell::new(QuickMenu::new()); 246 45 static BUMPS: ConstStaticCell<ButtonFeedback> = ConstStaticCell::new(ButtonFeedback::new()); 247 46 static DIR_CACHE: ConstStaticCell<DirCache> = ConstStaticCell::new(DirCache::new()); 248 47 static BM_CACHE: ConstStaticCell<BookmarkCache> = ConstStaticCell::new(BookmarkCache::new()); 48 + static CONSOLE: ConstStaticCell<BootConsole> = ConstStaticCell::new(BootConsole::new()); 249 49 250 50 static HOME: StaticCell<HomeApp> = StaticCell::new(); 251 51 static FILES: StaticCell<FilesApp> = StaticCell::new(); ··· 256 56 esp_println::logger::init_logger_from_env(); 257 57 let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); 258 58 let peripherals = esp_hal::init(config); 259 - 260 59 paint_stack(); 60 + // 108 KB main DRAM heap; leaves ~56 KB for stack 61 + esp_alloc::heap_allocator!(size: 110_592); 62 + // reclaim ~64 KB from 2nd-stage bootloader; net heap ~172 KB 63 + esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 64_000); 261 64 262 - // 140KB: WiFi radio ~65KB static + stack + esp-radio leaves no room for more 263 - esp_alloc::heap_allocator!(size: 143360); 65 + let console = CONSOLE.take(); 66 + console.push("pulp-os 0.1.0"); 67 + console.push("esp32c3 rv32imc 160mhz"); 68 + console.push("heap: 172K (108K + 64K reclaimed)"); 264 69 265 70 info!("booting..."); 266 71 72 + // Safety: TIMG0 and SW_INTERRUPT are cloned here and consumed by 73 + // esp_rtos::start. They are never used again after this point. 74 + // Board::init (which takes ownership of `peripherals`) does not 75 + // touch TIMG0 or SW_INTERRUPT, see the pin ownership table in 76 + // board/mod.rs for the full split. 267 77 let timg0 = TimerGroup::new(unsafe { peripherals.TIMG0.clone_unchecked() }); 268 78 let sw_ints = 269 79 SoftwareInterruptControl::new(unsafe { peripherals.SW_INTERRUPT.clone_unchecked() }); 270 80 esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0); 271 - info!("esp-rtos scheduler started (TIMG0 + SW_INT0)."); 81 + 82 + // Peripherals move into Board::init, which splits them across 83 + // init_input (ADC pins, GPIO3, IO_MUX) and init_spi_peripherals 84 + // (SPI2, DMA, display + SD GPIOs). each peripheral is used in 85 + // exactly one place, see the ownership table in board/mod.rs. 86 + let board = Board::init(peripherals); 87 + console.push("spi: dma ch0, 4096B tx+rx"); 272 88 273 - let mut board = Board::init(peripherals); 89 + let mut epd = board.display.epd; 274 90 let mut delay = Delay::new(); 275 - board.display.epd.init(&mut delay); 276 - info!("hardware initialized."); 91 + epd.init(&mut delay); 92 + console.push("epd: ssd1677 800x480 init"); 277 93 278 - let strip = STRIP.take(); 94 + speed_up_spi(); 95 + console.push("spi: 400kHz -> 20MHz"); 279 96 280 - let statusbar = STATUSBAR.take(); 281 - let mut sd_ok = board 282 - .storage 283 - .sd 284 - .volume_mgr 285 - .open_volume(embedded_sdmmc::VolumeIdx(0)) 286 - .is_ok(); 287 - 288 - if sd_ok && let Err(e) = storage::ensure_pulp_dir(&board.storage.sd) { 289 - info!("warning: failed to create _PULP dir: {}", e); 290 - } 291 - 292 - let mut input = InputDriver::new(board.input); 293 - let mapper = ButtonMapper::new(); 294 - 295 - let mut apps = Apps { 296 - home: HOME.init(HomeApp::new()), 297 - files: FILES.init(FilesApp::new()), 298 - reader: READER.take(), 299 - settings: SETTINGS.init(SettingsApp::new()), 97 + let sd = match board.storage.sd_card { 98 + Some(card) => { 99 + console.push("sd: card detected"); 100 + SdStorage::mount(card).await 101 + } 102 + None => { 103 + console.push("sd: not found"); 104 + SdStorage::empty() 105 + } 300 106 }; 301 107 302 - let launcher = LAUNCHER.take(); 303 - let quick_menu = QUICK_MENU.take(); 304 - let bumps = BUMPS.take(); 305 - 306 - let dir_cache = DIR_CACHE.take(); 307 - let bm_cache = BM_CACHE.take(); 308 - 309 - bm_cache.ensure_loaded(&board.storage.sd); 310 - 311 - { 312 - let mut svc = Services::new(dir_cache, bm_cache, &board.storage.sd); 313 - apps.settings.load_eager(&mut svc); 314 - apps.propagate_fonts(quick_menu, bumps); 315 - apps.home.load_recent(&mut svc); 108 + let sd_ok = sd.probe_ok(); 109 + if sd_ok { 110 + console.push("sd: fat32 mounted"); 111 + let _ = storage::ensure_pulp_dir_async(&sd).await; 316 112 } 317 113 318 - tasks::set_idle_timeout(apps.settings.system_settings().sleep_timeout); 114 + let mut input = InputDriver::new(board.input); 115 + let battery_mv = battery::adc_to_battery_mv(input.read_battery_mv()); 319 116 320 - let cached_battery_mv_init = battery::adc_to_battery_mv(input.read_battery_mv()); 321 - update_statusbar(statusbar, cached_battery_mv_init, sd_ok); 117 + let mut kernel = Kernel::new( 118 + sd, 119 + epd, 120 + STRIP.take(), 121 + DIR_CACHE.take(), 122 + BM_CACHE.take(), 123 + delay, 124 + sd_ok, 125 + battery_mv, 126 + ); 322 127 323 - apps.home.on_enter(&mut launcher.ctx); 128 + let mut app_mgr = AppManager::new( 129 + LAUNCHER.take(), 130 + HOME.init(HomeApp::new()), 131 + FILES.init(FilesApp::new()), 132 + READER.take(), 133 + SETTINGS.init(SettingsApp::new()), 134 + QUICK_MENU.take(), 135 + BUMPS.take(), 136 + ButtonMapper::new(), 137 + ); 324 138 325 - board 326 - .display 327 - .epd 328 - .full_refresh_async(strip, &mut delay, &|s: &mut StripBuffer| { 329 - statusbar.draw(s).unwrap(); 330 - apps.home.draw(s); 331 - }) 332 - .await; 139 + console.push("kernel: constructed"); 140 + kernel.show_boot_console(console).await; 333 141 334 - let _ = launcher.ctx.take_redraw(); 335 - info!("ui ready."); 142 + kernel.boot(&mut app_mgr).await; 336 143 337 144 spawner.spawn(tasks::input_task(input)).unwrap(); 338 145 spawner.spawn(tasks::housekeeping_task()).unwrap(); 339 146 spawner.spawn(tasks::idle_timeout_task()).unwrap(); 340 - info!("tasks spawned (input_task, housekeeping_task, idle_timeout_task)."); 147 + spawner.spawn(work_queue::worker_task()).unwrap(); 341 148 info!("kernel ready."); 342 149 343 - let mut work_ticker = Ticker::every(Duration::from_millis(TICK_MS)); 344 - 345 - let mut partial_refreshes: u32 = 0; 346 - let mut cached_battery_mv: u16 = cached_battery_mv_init; 347 - let mut red_stale: bool = false; 348 - 349 - loop { 350 - // 0. upload mode intercept: bypasses App trait, runs own async loop 351 - if launcher.active() == AppId::Upload { 352 - let wifi = unsafe { esp_hal::peripherals::WIFI::steal() }; 353 - pulp_os::apps::upload::run_upload_mode( 354 - wifi, 355 - &mut board.display.epd, 356 - strip, 357 - &mut delay, 358 - &board.storage.sd, 359 - apps.settings.system_settings().ui_font_size_idx, 360 - bumps, 361 - apps.settings.wifi_config(), 362 - ) 363 - .await; 364 - 365 - // pop back and re-render 366 - if let Some(nav) = launcher.apply(Transition::Pop) { 367 - apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps); 368 - } 369 - launcher.ctx.request_full_redraw(); 370 - continue; 371 - } 372 - 373 - // 1. wait for input or work tick 374 - let hw_event = match select(tasks::INPUT_EVENTS.receive(), work_ticker.next()).await { 375 - Either::First(ev) => Some(ev), 376 - Either::Second(_) => None, 377 - }; 378 - 379 - // 2. input event 380 - if let Some(hw_event) = hw_event { 381 - // power long-press: intercept before mapping so no app sees it 382 - if hw_event 383 - == pulp_os::drivers::input::Event::LongPress(pulp_os::board::button::Button::Power) 384 - { 385 - enter_sleep!("power held", bm_cache, board, strip, delay); 386 - } 387 - 388 - let event = mapper.map_event(hw_event); 389 - 390 - // quick-menu 391 - if quick_menu.open { 392 - if let ActionEvent::Press(action) | ActionEvent::Repeat(action) = event { 393 - let result = quick_menu.on_action(action); 394 - match result { 395 - QuickMenuResult::Consumed => { 396 - if quick_menu.dirty { 397 - launcher.ctx.mark_dirty(quick_menu.region()); 398 - quick_menu.dirty = false; 399 - } 400 - } 401 - QuickMenuResult::Close => { 402 - let region = quick_menu.region(); 403 - sync_quick_menu( 404 - quick_menu, 405 - launcher.active(), 406 - &mut apps, 407 - &mut launcher.ctx, 408 - ); 409 - launcher.ctx.mark_dirty(region); 410 - } 411 - QuickMenuResult::RefreshScreen => { 412 - sync_quick_menu( 413 - quick_menu, 414 - launcher.active(), 415 - &mut apps, 416 - &mut launcher.ctx, 417 - ); 418 - launcher.ctx.request_full_redraw(); 419 - } 420 - QuickMenuResult::GoHome => { 421 - sync_quick_menu( 422 - quick_menu, 423 - launcher.active(), 424 - &mut apps, 425 - &mut launcher.ctx, 426 - ); 427 - let transition = Transition::Home; 428 - if let Some(nav) = launcher.apply(transition) { 429 - apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps); 430 - } 431 - } 432 - QuickMenuResult::AppTrigger(id) => { 433 - let active = launcher.active(); 434 - let region = quick_menu.region(); 435 - sync_quick_menu(quick_menu, active, &mut apps, &mut launcher.ctx); 436 - with_app!(active, apps, |app| { 437 - app.on_quick_trigger(id, &mut launcher.ctx); 438 - }); 439 - if active == AppId::Reader { 440 - apps.reader.save_position(bm_cache); 441 - } 442 - launcher.ctx.mark_dirty(region); 443 - } 444 - } 445 - } 446 - } 447 - // menu toggle 448 - else if matches!(event, ActionEvent::Press(Action::Menu)) { 449 - let active = launcher.active(); 450 - let actions: &[_] = with_app!(active, apps, |app| app.quick_actions()); 451 - quick_menu.show(actions); 452 - launcher.ctx.mark_dirty(quick_menu.region()); 453 - } 454 - // app dispatch 455 - else { 456 - let active = launcher.active(); 457 - let transition = with_app!(active, apps, |app| { 458 - app.on_event(event, &mut launcher.ctx) 459 - }); 460 - 461 - if let Some(nav) = launcher.apply(transition) { 462 - apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps); 463 - } 464 - } 465 - } 466 - 467 - // if we just landed on Upload, skip to top where intercept lives 468 - if launcher.active() == AppId::Upload { 469 - continue; 470 - } 471 - 472 - // 3. app work: one step per iteration; multi-step ops yield between SD reads 473 - { 474 - let active = launcher.active(); 475 - let needs = with_app!(active, apps, |app| app.needs_work()); 476 - if needs { 477 - let mut svc = Services::new(dir_cache, bm_cache, &board.storage.sd); 478 - with_app!(active, apps, |app| { 479 - app.on_work(&mut svc, &mut launcher.ctx); 480 - }); 481 - } 482 - } 483 - 484 - // 4. housekeeping 485 - 486 - // battery mv (~30s, from input_task) 487 - if let Some(mv) = tasks::BATTERY_MV.try_take() { 488 - cached_battery_mv = mv; 489 - } 490 - 491 - // SD presence check (~30s) 492 - if tasks::SD_CHECK_DUE.try_take().is_some() { 493 - sd_ok = board 494 - .storage 495 - .sd 496 - .volume_mgr 497 - .open_volume(embedded_sdmmc::VolumeIdx(0)) 498 - .is_ok(); 499 - } 500 - 501 - // bookmark flush (~30s) 502 - if tasks::BOOKMARK_FLUSH_DUE.try_take().is_some() && bm_cache.is_dirty() { 503 - bm_cache.flush(&board.storage.sd); 504 - } 505 - 506 - // status bar refresh (~5s) 507 - if tasks::STATUS_DUE.try_take().is_some() { 508 - update_statusbar(statusbar, cached_battery_mv, sd_ok); 509 - 510 - // re-sync idle timeout in case settings changed 511 - if apps.settings.is_loaded() { 512 - tasks::set_idle_timeout(apps.settings.system_settings().sleep_timeout); 513 - } 514 - } 515 - 516 - // idle sleep: flush, sleep screen, deep sleep; wake = full reset 517 - if tasks::IDLE_SLEEP_DUE.try_take().is_some() { 518 - enter_sleep!("idle timeout", bm_cache, board, strip, delay); 519 - } 520 - 521 - // 5. render 522 - if !launcher.ctx.has_redraw() { 523 - continue; 524 - } 525 - 526 - let redraw = launcher.ctx.take_redraw(); 527 - 528 - // try partial; fall through to full on ghost-clear, initial refresh, or explicit Full 529 - 'render: { 530 - if let Redraw::Partial(r) = redraw { 531 - let ghost_clear_every = if apps.settings.is_loaded() { 532 - apps.settings.system_settings().ghost_clear_every as u32 533 - } else { 534 - DEFAULT_GHOST_CLEAR_EVERY 535 - }; 536 - 537 - if partial_refreshes < ghost_clear_every { 538 - let r = r.align8(); 539 - let render_bar_overlaps = r.y < BAR_HEIGHT; 540 - 541 - // phase 1: write BW; if red_stale also write RED=!BW so DU drives all pixels 542 - let active = launcher.active(); 543 - let rs = with_app!(active, apps, |app| { 544 - let draw = 545 - draw_scene!(app, statusbar, quick_menu, bumps, render_bar_overlaps); 546 - if red_stale { 547 - board.display.epd.partial_phase1_bw_inv_red( 548 - strip, r.x, r.y, r.w, r.h, &mut delay, &draw, 549 - ) 550 - } else { 551 - board 552 - .display 553 - .epd 554 - .partial_phase1_bw(strip, r.x, r.y, r.w, r.h, &mut delay, &draw) 555 - } 556 - }); 557 - 558 - if let Some(rs) = rs { 559 - // phase 2: kick DU waveform (~400-600ms) 560 - board.display.epd.partial_start_du(&rs); 561 - 562 - // process input + work while DU runs 563 - let deferred = busy_wait_with_input!( 564 - board.display.epd, 565 - mapper, 566 - quick_menu, 567 - launcher, 568 - apps, 569 - dir_cache, 570 - bm_cache, 571 - board.storage.sd 572 - ); 573 - 574 - // phase 3: sync RED+BW; skip if content changed during DU (rapid nav). 575 - // draw() now produces the next page; writing it to both planes ghosts. 576 - // leave RED stale; next render uses inv-red to correct it. 577 - // merge region so the inv-red pass covers pixels this DU changed. 578 - if launcher.ctx.has_redraw() { 579 - // content changed; skip sync, mark region for inv-red pass 580 - launcher.ctx.mark_dirty(r); 581 - red_stale = true; 582 - partial_refreshes += 1; 583 - } else { 584 - // stable; sync planes, power off 585 - red_stale = false; 586 - let active = launcher.active(); 587 - with_app!(active, apps, |app| { 588 - let draw = draw_scene!( 589 - app, 590 - statusbar, 591 - quick_menu, 592 - bumps, 593 - render_bar_overlaps 594 - ); 595 - board.display.epd.partial_phase3_sync(strip, &rs, &draw); 596 - }); 597 - partial_refreshes += 1; 598 - board.display.epd.power_off_async().await; 599 - } 600 - 601 - // apply deferred transition from busy wait 602 - if let Some(transition) = deferred 603 - && let Some(nav) = launcher.apply(transition) 604 - { 605 - apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps); 606 - } 607 - 608 - break 'render; 609 - } 610 - 611 - if !board.display.epd.needs_initial_refresh() { 612 - break 'render; // degenerate zero-size region 613 - } 614 - // fall through to full GC 615 - info!("display: partial failed (initial refresh), promoting to full"); 616 - } else { 617 - info!("display: promoted partial to full (ghosting clear)"); 618 - } 619 - // fall through to full GC 620 - } 621 - 622 - // full GC refresh: explicit Full, ghost-clear, or initial-refresh fallback 623 - if matches!(redraw, Redraw::Full | Redraw::Partial(_)) { 624 - // ensure analog off; no-op normally, required after skipped power-off 625 - board.display.epd.power_off_async().await; 626 - 627 - update_statusbar(statusbar, cached_battery_mv, sd_ok); 628 - 629 - let active = launcher.active(); 630 - with_app!(active, apps, |app| { 631 - let draw = draw_scene!(app, statusbar, quick_menu, bumps, true); 632 - board.display.epd.write_full_frame(strip, &mut delay, &draw); 633 - }); 634 - 635 - board.display.epd.start_full_update(); 636 - 637 - // process input during ~1.6s GC waveform 638 - let deferred = busy_wait_with_input!( 639 - board.display.epd, 640 - mapper, 641 - quick_menu, 642 - launcher, 643 - apps, 644 - dir_cache, 645 - bm_cache, 646 - board.storage.sd 647 - ); 648 - 649 - board.display.epd.finish_full_update(); 650 - partial_refreshes = 0; 651 - red_stale = false; 652 - 653 - if let Some(transition) = deferred 654 - && let Some(nav) = launcher.apply(transition) 655 - { 656 - apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps); 657 - } 658 - } 659 - } // 'render 660 - } 661 - } 662 - 663 - // helpers 664 - 665 - fn update_statusbar(bar: &mut StatusBar, battery_mv: u16, sd_ok: bool) { 666 - const HEAP_TOTAL: usize = 143360; // matches heap_allocator!(size: ...) above 667 - let stats = esp_alloc::HEAP.stats(); 668 - 669 - let bat_pct = battery::battery_percentage(battery_mv); 670 - 671 - bar.update(&SystemStatus { 672 - uptime_secs: uptime_secs(), 673 - battery_mv, 674 - battery_pct: bat_pct, 675 - heap_used: stats.current_usage, 676 - heap_peak: stats.max_usage, 677 - heap_total: HEAP_TOTAL, 678 - stack_free: free_stack_bytes(), 679 - stack_hwm: stack_high_water_mark(), 680 - sd_ok, 681 - }); 682 - } 683 - 684 - // push quick-menu cycle changes into active app; persist settings-owned values. 685 - // hand-written match (not with_app!) because we also borrow apps.settings below. 686 - fn sync_quick_menu(qm: &QuickMenu, active: AppId, apps: &mut Apps, ctx: &mut AppContext) { 687 - for id in 0..MAX_APP_ACTIONS as u8 { 688 - if let Some(value) = qm.app_cycle_value(id) { 689 - match active { 690 - AppId::Home => apps.home.on_quick_cycle_update(id, value, ctx), 691 - AppId::Files => apps.files.on_quick_cycle_update(id, value, ctx), 692 - AppId::Reader => apps.reader.on_quick_cycle_update(id, value, ctx), 693 - AppId::Settings => apps.settings.on_quick_cycle_update(id, value, ctx), 694 - AppId::Upload => {} 695 - } 696 - } 697 - } 698 - 699 - // persist reader font-size change into settings 700 - if active == AppId::Reader 701 - && let Some(font_idx) = qm.app_cycle_value(reader::QA_FONT_SIZE) 702 - { 703 - let ss = apps.settings.system_settings_mut(); 704 - if ss.book_font_size_idx != font_idx { 705 - ss.book_font_size_idx = font_idx; 706 - apps.settings.mark_save_needed(); 707 - } 708 - } 150 + kernel.run(&mut app_mgr).await 709 151 }
+3 -3
src/board/action.rs kernel/src/board/action.rs
··· 1 - // Semantic actions decoupled from physical buttons. 2 - // Apps match on Action, never on HwButton. ButtonMapper translates 3 - // physical events using the fixed portrait one-handed layout. 1 + // semantic actions decoupled from physical buttons 2 + // apps match on Action, never on HwButton 4 3 5 4 use crate::board::button::Button; 6 5 use crate::drivers::input::Event; ··· 44 43 } 45 44 } 46 45 46 + // fixed portrait one-handed layout 47 47 #[derive(Default)] 48 48 pub struct ButtonMapper; 49 49
+3 -3
src/board/button.rs kernel/src/board/button.rs
··· 1 - // Button definitions and ADC resistance ladder decoding. 2 - // Two ADC ladders (Row1 GPIO1, Row2 GPIO2) plus discrete power button on GPIO3. 3 - // Each ladder encodes buttons as voltage levels. 1 + // button definitions and ADC resistance ladder decoding 2 + // two ADC ladders (Row1 GPIO1, Row2 GPIO2) plus power button (GPIO3) 4 3 5 4 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 6 5 pub enum Button { ··· 35 34 36 35 pub const DEFAULT_TOLERANCE: u16 = 150; 37 36 37 + // (center_mv, tolerance_mv, button) 38 38 pub const ROW1_THRESHOLDS: &[(u16, u16, Button)] = &[ 39 39 (3, 50, Button::Right), 40 40 (1113, DEFAULT_TOLERANCE, Button::Left),
-189
src/board/mod.rs
··· 1 - // XTEink X4 board support (ESP32-C3, SSD1677 800x480, SD over SPI2). 2 - // DMA-backed SPI (GDMA CH0); RefCellDevice arbitrates bus. 3 - 4 - pub mod action; 5 - pub mod button; 6 - pub mod raw_gpio; 7 - 8 - pub use crate::drivers::sdcard::SdStorage; 9 - pub use crate::drivers::ssd1677::{DisplayDriver, HEIGHT, SPI_FREQ_MHZ, WIDTH}; 10 - pub use crate::drivers::strip::StripBuffer; 11 - pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 12 - 13 - pub const SCREEN_W: u16 = HEIGHT; // 480 14 - pub const SCREEN_H: u16 = WIDTH; // 800 15 - 16 - use core::cell::RefCell; 17 - 18 - use critical_section::Mutex; 19 - use embedded_hal_bus::spi::RefCellDevice; 20 - use esp_hal::{ 21 - Blocking, 22 - analog::adc::{Adc, AdcCalCurve, AdcConfig, AdcPin, Attenuation}, 23 - delay::Delay, 24 - dma::{DmaRxBuf, DmaTxBuf}, 25 - gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull}, 26 - peripherals::{ADC1, GPIO0, GPIO1, GPIO2, Peripherals}, 27 - spi, 28 - time::Rate, 29 - }; 30 - use log::info; 31 - use static_cell::StaticCell; 32 - 33 - pub type SpiBus = spi::master::SpiDmaBus<'static, Blocking>; 34 - pub type SharedSpiDevice = RefCellDevice<'static, SpiBus, Output<'static>, Delay>; 35 - pub type SdSpiDevice = RefCellDevice<'static, SpiBus, raw_gpio::RawOutputPin, Delay>; 36 - pub type Epd = DisplayDriver<SharedSpiDevice, Output<'static>, Output<'static>, Input<'static>>; 37 - 38 - static SPI_BUS: StaticCell<RefCell<SpiBus>> = StaticCell::new(); 39 - 40 - // ISR clears interrupt flag; any interrupt wakes the Embassy executor 41 - static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None)); 42 - 43 - #[esp_hal::handler] 44 - fn gpio_handler() { 45 - critical_section::with(|cs| { 46 - if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut() 47 - && btn.is_interrupt_set() 48 - { 49 - btn.clear_interrupt(); 50 - // any interrupt wakes the Embassy executor; no explicit signal needed 51 - } 52 - }); 53 - } 54 - 55 - pub fn power_button_is_low() -> bool { 56 - critical_section::with(|cs| { 57 - POWER_BTN 58 - .borrow_ref_mut(cs) 59 - .as_mut() 60 - .map(|btn| btn.is_low()) 61 - .unwrap_or(false) 62 - }) 63 - } 64 - 65 - pub struct InputHw { 66 - pub adc: Adc<'static, ADC1<'static>, Blocking>, 67 - pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 68 - pub row2: AdcPin<GPIO2<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 69 - pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 70 - } 71 - 72 - pub struct DisplayHw { 73 - pub epd: Epd, 74 - } 75 - 76 - pub struct StorageHw { 77 - pub sd: SdStorage<SdSpiDevice>, 78 - } 79 - 80 - pub struct Board { 81 - pub input: InputHw, 82 - pub display: DisplayHw, 83 - pub storage: StorageHw, 84 - } 85 - 86 - impl Board { 87 - pub fn init(p: Peripherals) -> Self { 88 - let input = Self::init_input(&p); 89 - let (display, storage) = Self::init_spi_peripherals(p); 90 - Board { 91 - input, 92 - display, 93 - storage, 94 - } 95 - } 96 - 97 - // Takes &Peripherals so init_spi_peripherals can consume them by value. 98 - // Safety: each clone_unchecked targets a distinct GPIO/ADC peripheral; 99 - // no pin is used by both init_input and init_spi_peripherals. 100 - fn init_input(p: &Peripherals) -> InputHw { 101 - let mut adc_cfg = AdcConfig::new(); 102 - 103 - let row1 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 104 - unsafe { p.GPIO1.clone_unchecked() }, 105 - Attenuation::_11dB, 106 - ); 107 - 108 - let row2 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 109 - unsafe { p.GPIO2.clone_unchecked() }, 110 - Attenuation::_11dB, 111 - ); 112 - 113 - let battery = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 114 - unsafe { p.GPIO0.clone_unchecked() }, 115 - Attenuation::_11dB, 116 - ); 117 - 118 - let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg); 119 - 120 - let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() }); 121 - io.set_interrupt_handler(gpio_handler); 122 - 123 - let mut power = Input::new( 124 - unsafe { p.GPIO3.clone_unchecked() }, 125 - InputConfig::default().with_pull(Pull::Up), 126 - ); 127 - power.listen(Event::FallingEdge); 128 - 129 - critical_section::with(|cs| { 130 - POWER_BTN.borrow_ref_mut(cs).replace(power); 131 - }); 132 - info!("power button: GPIO3 interrupt armed (FallingEdge)"); 133 - 134 - InputHw { 135 - adc, 136 - row1, 137 - row2, 138 - battery, 139 - } 140 - } 141 - 142 - // 400kHz for SD probe, then 20MHz; DMA-backed 143 - fn init_spi_peripherals(p: Peripherals) -> (DisplayHw, StorageHw) { 144 - let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default()); 145 - let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default()); 146 - let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default()); 147 - 148 - // no pre-armed interrupt; esp-hal async Wait manages GPIO6 149 - let busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None)); 150 - 151 - // GPIO12 free in DIO mode; no esp-hal type, use raw registers 152 - let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) }; 153 - 154 - let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400)); 155 - 156 - let mut spi_raw = spi::master::Spi::new(p.SPI2, slow_cfg) 157 - .unwrap() 158 - .with_sck(p.GPIO8) 159 - .with_mosi(p.GPIO10) 160 - .with_miso(p.GPIO7); 161 - 162 - // 80 clocks with CS high before DMA conversion (SD spec init) 163 - let _ = spi_raw.write(&[0xFF; 10]); 164 - 165 - // 4096B each direction: strip max ~4000B, SD sectors 512B 166 - let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = esp_hal::dma_buffers!(4096); 167 - let dma_rx_buf = DmaRxBuf::new(rx_descriptors, rx_buffer).unwrap(); 168 - let dma_tx_buf = DmaTxBuf::new(tx_descriptors, tx_buffer).unwrap(); 169 - 170 - let spi_dma_bus = spi_raw 171 - .with_dma(p.DMA_CH0) 172 - .with_buffers(dma_rx_buf, dma_tx_buf); 173 - 174 - let spi_ref: &'static RefCell<SpiBus> = SPI_BUS.init(RefCell::new(spi_dma_bus)); 175 - info!("SPI bus: DMA enabled (CH0, 4096B TX+RX)"); 176 - 177 - let sd_spi = RefCellDevice::new(spi_ref, sd_cs, Delay::new()).unwrap(); 178 - let sd = SdStorage::new(sd_spi); 179 - 180 - let fast_cfg = spi::master::Config::default().with_frequency(Rate::from_mhz(SPI_FREQ_MHZ)); 181 - spi_ref.borrow_mut().apply_config(&fast_cfg).unwrap(); 182 - info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ); 183 - 184 - let epd_spi = RefCellDevice::new(spi_ref, epd_cs, Delay::new()).unwrap(); 185 - let epd = DisplayDriver::new(epd_spi, dc, rst, busy); 186 - 187 - (DisplayHw { epd }, StorageHw { sd }) 188 - } 189 - }
+6 -7
src/board/raw_gpio.rs kernel/src/board/raw_gpio.rs
··· 1 - // Direct register GPIO for pins esp-hal does not expose. 2 - // DIO flash mode frees GPIO12/13; esp-hal 1.0 has no peripheral types 3 - // for GPIO12..17 on ESP32-C3. Only OutputPin is implemented. 1 + // direct register GPIO for pins esp-hal does not expose 2 + // DIO flash mode frees GPIO12/13; esp-hal 1.0 has no peripheral 3 + // types for GPIO12..17 on ESP32-C3 4 4 5 - const GPIO_OUT_W1TS: u32 = 0x6000_4008; // set output high 6 - const GPIO_OUT_W1TC: u32 = 0x6000_400C; // set output low 7 - const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // enable output 5 + const GPIO_OUT_W1TS: u32 = 0x6000_4008; 6 + const GPIO_OUT_W1TC: u32 = 0x6000_400C; 7 + const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; 8 8 const IO_MUX_BASE: u32 = 0x6000_9000; 9 9 const IO_MUX_PIN_STRIDE: u32 = 0x04; 10 10 ··· 30 30 out_sel.write_volatile(0x80); 31 31 32 32 (GPIO_ENABLE_W1TS as *mut u32).write_volatile(mask); 33 - 34 33 (GPIO_OUT_W1TS as *mut u32).write_volatile(mask); 35 34 } 36 35
+3 -22
src/drivers/battery.rs kernel/src/drivers/battery.rs
··· 1 - // Li-ion battery voltage estimation. 2 - // GPIO0 reads through 100K/100K divider (2:1); ADC 11dB attenuation gives 3 - // 0..2500mV; multiply by 2 for actual cell voltage. 4 - // Piecewise-linear LUT models the discharge curve. 1 + // battery voltage estimation --- generic over board calibration. 2 + // board-specific divider ratio and discharge curve live in board::battery. 5 3 6 - const DIVIDER_MULT: u32 = 2; 7 - 8 - // (millivolts, percentage); must be sorted descending by mV 9 - const DISCHARGE_CURVE: &[(u32, u8)] = &[ 10 - (4200, 100), 11 - (4060, 90), 12 - (3980, 80), 13 - (3920, 70), 14 - (3870, 60), 15 - (3830, 50), 16 - (3790, 40), 17 - (3750, 30), 18 - (3700, 20), 19 - (3600, 10), 20 - (3400, 5), 21 - (3000, 0), 22 - ]; 4 + use crate::board::battery::{DISCHARGE_CURVE, DIVIDER_MULT}; 23 5 24 6 pub fn adc_to_battery_mv(adc_mv: u16) -> u16 { 25 7 (adc_mv as u32 * DIVIDER_MULT) as u16 ··· 37 19 return DISCHARGE_CURVE[last].1; 38 20 } 39 21 40 - // interpolate between bracketing points 41 22 let mut i = 0; 42 23 while i + 1 < DISCHARGE_CURVE.len() { 43 24 let (mv_hi, pct_hi) = DISCHARGE_CURVE[i];
+6 -8
src/drivers/input.rs kernel/src/drivers/input.rs
··· 1 - // Debounced input from ADC ladders and power button. 2 - // One button at a time (ladder hw limitation). Sources: Row1 (GPIO1), 3 - // Row2 (GPIO2), Power (GPIO3 interrupt). 15ms debounce, 1s long press, 150ms repeat. 4 - // ADC reads oversampled (4 samples averaged) to reject noise from ESP32-C3 5 - // non-linearity and battery sag during SPI traffic; ~40us per channel. 1 + // debounced input from ADC ladders and power button 2 + // one button at a time (ladder hw limitation) 3 + // 15 ms debounce, 1 s long press, 150 ms repeat 4 + // ADC reads oversampled 4x to reject noise (~40 us per channel) 6 5 7 6 use esp_hal::time::{Duration, Instant}; 8 7 9 8 use crate::board::InputHw; 10 9 use crate::board::button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 11 10 12 - // 4-sample average; ~40us per channel, rejects ADC noise from SPI/battery sag 13 11 macro_rules! read_averaged { 14 12 ($adc:expr, $pin:expr) => {{ 15 13 let mut sum: u32 = 0; ··· 34 32 } 35 33 36 34 struct EventQueue { 37 - buf: [Option<Event>; 2], 35 + buf: [Option<Event>; 4], 38 36 } 39 37 40 38 impl EventQueue { 41 39 const fn new() -> Self { 42 - Self { buf: [None; 2] } 40 + Self { buf: [None; 4] } 43 41 } 44 42 45 43 fn push(&mut self, ev: Event) {
-9
src/drivers/mod.rs
··· 1 - // Hardware drivers: chip-level and protocol-level, board-independent. 2 - // Each module is reusable across boards; pin assignments and bus wiring in board/. 3 - 4 - pub mod battery; 5 - pub mod input; 6 - pub mod sdcard; 7 - pub mod ssd1677; 8 - pub mod storage; 9 - pub mod strip;
-45
src/drivers/sdcard.rs
··· 1 - // SD card over SPI with FAT volume manager 2 - // No RTC on board; timestamps are fixed to 2025-01-01. 3 - 4 - use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeManager}; 5 - use log::info; 6 - 7 - #[derive(Default, Clone, Copy)] 8 - pub struct FixedTimestamp; 9 - 10 - impl TimeSource for FixedTimestamp { 11 - fn get_timestamp(&self) -> Timestamp { 12 - Timestamp { 13 - year_since_1970: 55, 14 - zero_indexed_month: 0, 15 - zero_indexed_day: 0, 16 - hours: 0, 17 - minutes: 0, 18 - seconds: 0, 19 - } 20 - } 21 - } 22 - 23 - pub struct SdStorage<SPI> 24 - where 25 - SPI: embedded_hal::spi::SpiDevice, 26 - { 27 - pub volume_mgr: VolumeManager<SdCard<SPI, esp_hal::delay::Delay>, FixedTimestamp>, 28 - } 29 - 30 - impl<SPI> SdStorage<SPI> 31 - where 32 - SPI: embedded_hal::spi::SpiDevice, 33 - { 34 - pub fn new(spi: SPI) -> Self { 35 - let sdcard = SdCard::new(spi, esp_hal::delay::Delay::new()); 36 - 37 - match sdcard.num_bytes() { 38 - Ok(bytes) => info!("SD card: {} bytes ({} MB)", bytes, bytes / 1024 / 1024), 39 - Err(e) => info!("SD card probe failed: {:?}", e), 40 - } 41 - 42 - let volume_mgr = VolumeManager::new(sdcard, FixedTimestamp); 43 - Self { volume_mgr } 44 - } 45 - }
+13 -18
src/drivers/ssd1677.rs kernel/src/drivers/ssd1677.rs
··· 1 - // SSD1677 e-paper driver (board-independent). 2 - // Tested on GDEQ0426T82 (800x480). No framebuffer; pixels streamed 3 - // through a 4KB StripBuffer. 1 + // SSD1677 e-paper driver (board-independent) 2 + // tested on GDEQ0426T82 (800x480), no framebuffer, strip-streamed 4 3 // 5 - // Partial refresh (3-phase): 6 - // phase1_bw -- write new content to BW RAM 7 - // start_du -- kick DU waveform; caller handles input while BUSY 8 - // phase3_sync -- sync RED+BW; skipped on rapid nav (red_stale) 9 - // power_off -- shut down analog 4 + // partial refresh (3-phase): 5 + // phase1_bw -- write new content to BW RAM 6 + // start_du -- kick DU waveform; caller polls input while BUSY 7 + // phase3_sync -- sync RED+BW; skipped on rapid nav (red_stale) 10 8 // 11 - // When phase3 is skipped, use phase1_bw_inv_red next: writes RED=!BW 12 - // so DU drives every pixel to the correct BW target without a full GC. 13 - // Async variants replace blocking busy-wait with .await. 9 + // when phase3 is skipped, phase1_bw_inv_red writes RED=!BW so DU 10 + // drives every pixel to the correct BW target without a full GC 14 11 15 12 use embedded_graphics_core::geometry::{OriginDimensions, Size}; 16 13 use embedded_hal::digital::{InputPin, OutputPin}; ··· 24 21 25 22 pub const SPI_FREQ_MHZ: u32 = 20; 26 23 27 - const POWER_OFF_TIME_MS: u32 = 200; 24 + const POWER_OFF_TIME_MS: u32 = 200; // analog shutdown timeout 28 25 29 26 #[derive(Clone, Copy, Debug, Default, PartialEq)] 30 27 pub enum Rotation { ··· 35 32 Deg270, 36 33 } 37 34 38 - // SSD1677 commands 39 35 #[allow(dead_code)] 40 36 mod cmd { 41 37 pub const DRIVER_OUTPUT_CONTROL: u8 = 0x01; ··· 48 44 pub const MASTER_ACTIVATION: u8 = 0x20; 49 45 pub const DISPLAY_UPDATE_CONTROL_1: u8 = 0x21; 50 46 pub const DISPLAY_UPDATE_CONTROL_2: u8 = 0x22; 51 - pub const WRITE_RAM_BW: u8 = 0x24; // current/new buffer 52 - pub const WRITE_RAM_RED: u8 = 0x26; // previous buffer (differential) 47 + pub const WRITE_RAM_BW: u8 = 0x24; 48 + pub const WRITE_RAM_RED: u8 = 0x26; 53 49 pub const BORDER_WAVEFORM: u8 = 0x3C; 54 50 pub const SET_RAM_X_RANGE: u8 = 0x44; 55 51 pub const SET_RAM_Y_RANGE: u8 = 0x45; ··· 305 301 }) 306 302 } 307 303 308 - // gates wired in reverse; Y flipped, X inc / Y dec per GxEPD2 304 + // gates wired in reverse; Y flipped, X inc / Y dec 309 305 fn set_partial_ram_area(&mut self, x: u16, y: u16, w: u16, h: u16) { 310 306 let y_flipped = HEIGHT - y - h; 311 307 ··· 338 334 ]); 339 335 } 340 336 341 - // WFI between polls; BUSY falling-edge IRQ wakes; timer backstop 342 337 fn wait_busy(&mut self, timeout_ms: u32) { 343 338 use esp_hal::time::{Duration, Instant}; 344 339 ··· 515 510 self.initial_refresh = false; 516 511 } 517 512 518 - // mode 1: image retained, ~3uA; requires hw reset to wake 513 + // mode 1: image retained, ~3 uA; requires hw reset to wake 519 514 pub fn enter_deep_sleep(&mut self) { 520 515 if self.power_is_on { 521 516 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2);
-741
src/drivers/storage.rs
··· 1 - // SD card file operations and directory cache. 2 - 3 - use embedded_sdmmc::{Mode, VolumeIdx}; 4 - 5 - use crate::drivers::sdcard::SdStorage; 6 - 7 - pub const PULP_DIR: &str = "_PULP"; 8 - 9 - // title index: append-only lines of "FILENAME.EXT\tTitle Text\n" 10 - pub const TITLES_FILE: &str = "TITLES.BIN"; 11 - 12 - pub const TITLE_CAP: usize = 48; 13 - 14 - #[derive(Clone, Copy)] 15 - pub struct DirEntry { 16 - pub name: [u8; 13], 17 - pub name_len: u8, 18 - pub is_dir: bool, 19 - pub size: u32, 20 - pub title: [u8; TITLE_CAP], 21 - pub title_len: u8, 22 - } 23 - 24 - impl DirEntry { 25 - pub const EMPTY: Self = Self { 26 - name: [0u8; 13], 27 - name_len: 0, 28 - is_dir: false, 29 - size: 0, 30 - title: [0u8; TITLE_CAP], 31 - title_len: 0, 32 - }; 33 - 34 - pub fn name_str(&self) -> &str { 35 - core::str::from_utf8(&self.name[..self.name_len as usize]).unwrap_or("?") 36 - } 37 - 38 - pub fn display_name(&self) -> &str { 39 - if self.title_len > 0 { 40 - core::str::from_utf8(&self.title[..self.title_len as usize]).unwrap_or(self.name_str()) 41 - } else { 42 - self.name_str() 43 - } 44 - } 45 - 46 - pub fn set_title(&mut self, s: &[u8]) { 47 - let n = s.len().min(TITLE_CAP); 48 - self.title[..n].copy_from_slice(&s[..n]); 49 - self.title_len = n as u8; 50 - } 51 - } 52 - 53 - pub struct DirPage { 54 - pub total: usize, 55 - pub count: usize, 56 - } 57 - 58 - const MAX_DIR_ENTRIES: usize = 128; 59 - 60 - macro_rules! with_dir { 61 - // root only 62 - ($sd:expr, |$dir:ident| $body:expr) => {{ 63 - let volume = $sd 64 - .volume_mgr 65 - .open_volume(VolumeIdx(0)) 66 - .map_err(|_| "open volume failed")?; 67 - let $dir = volume.open_root_dir().map_err(|_| "open root dir failed")?; 68 - $body 69 - }}; 70 - // one subdirectory 71 - ($sd:expr, $d1:expr, |$dir:ident| $body:expr) => {{ 72 - let volume = $sd 73 - .volume_mgr 74 - .open_volume(VolumeIdx(0)) 75 - .map_err(|_| "open volume failed")?; 76 - let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 77 - let $dir = root.open_dir($d1).map_err(|_| "open dir failed")?; 78 - $body 79 - }}; 80 - // two subdirectories 81 - ($sd:expr, $d1:expr, $d2:expr, |$dir:ident| $body:expr) => {{ 82 - let volume = $sd 83 - .volume_mgr 84 - .open_volume(VolumeIdx(0)) 85 - .map_err(|_| "open volume failed")?; 86 - let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 87 - let mid = root.open_dir($d1).map_err(|_| "open dir failed")?; 88 - let $dir = mid.open_dir($d2).map_err(|_| "open dir failed")?; 89 - $body 90 - }}; 91 - } 92 - 93 - macro_rules! read_loop { 94 - ($file:expr, $buf:expr) => {{ 95 - let mut total = 0usize; 96 - while !$file.is_eof() && total < $buf.len() { 97 - let n = $file.read(&mut $buf[total..]).map_err(|_| "read failed")?; 98 - if n == 0 { 99 - break; 100 - } 101 - total += n; 102 - } 103 - total 104 - }}; 105 - } 106 - 107 - macro_rules! write_flush { 108 - ($file:expr, $data:expr) => {{ 109 - if !$data.is_empty() { 110 - $file.write($data).map_err(|_| "write failed")?; 111 - } 112 - $file.flush().map_err(|_| "flush failed")?; 113 - }}; 114 - } 115 - 116 - macro_rules! do_read_chunk { 117 - ($dir:expr, $name:expr, $offset:expr, $buf:expr) => {{ 118 - let file = $dir 119 - .open_file_in_dir($name, Mode::ReadOnly) 120 - .map_err(|_| "open file failed")?; 121 - file.seek_from_start($offset).map_err(|_| "seek failed")?; 122 - Ok(read_loop!(file, $buf)) 123 - }}; 124 - } 125 - 126 - macro_rules! do_read_start { 127 - ($dir:expr, $name:expr, $buf:expr) => {{ 128 - let file = $dir 129 - .open_file_in_dir($name, Mode::ReadOnly) 130 - .map_err(|_| "open file failed")?; 131 - let size = file.length(); 132 - let n = read_loop!(file, $buf); 133 - Ok((size, n)) 134 - }}; 135 - } 136 - 137 - macro_rules! do_write { 138 - ($dir:expr, $name:expr, $data:expr) => {{ 139 - let file = $dir 140 - .open_file_in_dir($name, Mode::ReadWriteCreateOrTruncate) 141 - .map_err(|_| "create file failed")?; 142 - write_flush!(file, $data); 143 - Ok(()) 144 - }}; 145 - } 146 - 147 - macro_rules! do_append { 148 - ($dir:expr, $name:expr, $data:expr) => {{ 149 - let file = $dir 150 - .open_file_in_dir($name, Mode::ReadWriteCreateOrAppend) 151 - .map_err(|_| "open file for append failed")?; 152 - write_flush!(file, $data); 153 - Ok(()) 154 - }}; 155 - } 156 - 157 - macro_rules! do_file_size { 158 - ($dir:expr, $name:expr) => {{ 159 - let file = $dir 160 - .open_file_in_dir($name, Mode::ReadOnly) 161 - .map_err(|_| "open file failed")?; 162 - Ok(file.length()) 163 - }}; 164 - } 165 - 166 - macro_rules! do_delete { 167 - ($dir:expr, $name:expr) => {{ 168 - let _ = $dir.delete_file_in_dir($name); 169 - Ok(()) 170 - }}; 171 - } 172 - 173 - macro_rules! do_ensure_subdir { 174 - ($dir:expr, $name:expr) => {{ 175 - if $dir.open_dir($name).is_err() { 176 - $dir.make_dir_in_dir($name).map_err(|_| "make dir failed")?; 177 - } 178 - Ok(()) 179 - }}; 180 - } 181 - 182 - pub struct DirCache { 183 - entries: [DirEntry; MAX_DIR_ENTRIES], 184 - count: usize, 185 - valid: bool, 186 - } 187 - 188 - impl Default for DirCache { 189 - fn default() -> Self { 190 - Self::new() 191 - } 192 - } 193 - 194 - impl DirCache { 195 - pub const fn new() -> Self { 196 - Self { 197 - entries: [DirEntry::EMPTY; MAX_DIR_ENTRIES], 198 - count: 0, 199 - valid: false, 200 - } 201 - } 202 - 203 - pub fn ensure_loaded<SPI>(&mut self, sd: &SdStorage<SPI>) -> Result<(), &'static str> 204 - where 205 - SPI: embedded_hal::spi::SpiDevice, 206 - { 207 - if self.valid { 208 - return Ok(()); 209 - } 210 - 211 - // volume/root handles must be dropped before load_titles opens its own 212 - with_dir!(sd, |root| { 213 - let mut count = 0usize; 214 - root.iterate_dir(|entry| { 215 - if matches!(entry.name.base_name()[0], b'.' | b'_') { 216 - return; 217 - } 218 - // hide files the device cannot open 219 - if !entry.attributes.is_directory() && !is_supported_ext(entry.name.extension()) { 220 - return; 221 - } 222 - if count < MAX_DIR_ENTRIES { 223 - let mut name_buf = [0u8; 13]; 224 - let name_len = format_83_name(&entry.name, &mut name_buf); 225 - self.entries[count] = DirEntry { 226 - name: name_buf, 227 - name_len: name_len as u8, 228 - is_dir: entry.attributes.is_directory(), 229 - size: entry.size, 230 - title: [0u8; TITLE_CAP], 231 - title_len: 0, 232 - }; 233 - count += 1; 234 - } 235 - }) 236 - .map_err(|_| "iterate dir failed")?; 237 - 238 - self.count = count; 239 - sort_entries(&mut self.entries[..count]); 240 - self.valid = true; 241 - Ok(()) 242 - })?; 243 - 244 - self.load_titles(sd); 245 - 246 - Ok(()) 247 - } 248 - 249 - // read _PULP/TITLES.BIN and apply parsed titles to matching entries; 250 - // append-only so later lines override earlier ones for the same name 251 - fn load_titles<SPI>(&mut self, sd: &SdStorage<SPI>) 252 - where 253 - SPI: embedded_hal::spi::SpiDevice, 254 - { 255 - let mut buf = [0u8; 1024]; 256 - let mut offset: u32 = 0; 257 - let mut leftover = 0usize; 258 - 259 - loop { 260 - let space = buf.len() - leftover; 261 - if space == 0 { 262 - // line longer than buffer; skip to next newline 263 - leftover = 0; 264 - while let Ok(n) = read_pulp_file_chunk(sd, TITLES_FILE, offset, &mut buf) { 265 - if n == 0 { 266 - break; 267 - } 268 - offset += n as u32; 269 - if let Some(nl) = buf[..n].iter().position(|&b| b == b'\n') { 270 - let rest = n - (nl + 1); 271 - if rest > 0 { 272 - buf.copy_within(nl + 1..n, 0); 273 - } 274 - leftover = rest; 275 - break; 276 - } 277 - } 278 - continue; 279 - } 280 - 281 - let n = match read_pulp_file_chunk(sd, TITLES_FILE, offset, &mut buf[leftover..]) { 282 - Ok(n) => n, 283 - Err(_) => break, 284 - }; 285 - if n == 0 { 286 - if leftover > 0 { 287 - self.apply_title_line(&buf[..leftover]); 288 - } 289 - break; 290 - } 291 - 292 - offset += n as u32; 293 - let total = leftover + n; 294 - 295 - let mut start = 0; 296 - for i in 0..total { 297 - if buf[i] == b'\n' { 298 - if i > start { 299 - self.apply_title_line(&buf[start..i]); 300 - } 301 - start = i + 1; 302 - } 303 - } 304 - 305 - if start < total { 306 - buf.copy_within(start..total, 0); 307 - leftover = total - start; 308 - } else { 309 - leftover = 0; 310 - } 311 - } 312 - } 313 - 314 - // parse "FILENAME.EXT\tTitle Text"; last-writer-wins for duplicate names 315 - fn apply_title_line(&mut self, line: &[u8]) { 316 - let tab_pos = match line.iter().position(|&b| b == b'\t') { 317 - Some(p) => p, 318 - None => return, 319 - }; 320 - let name_part = &line[..tab_pos]; 321 - let title_part = &line[tab_pos + 1..]; 322 - if title_part.is_empty() { 323 - return; 324 - } 325 - 326 - for entry in self.entries[..self.count].iter_mut() { 327 - let elen = entry.name_len as usize; 328 - if elen == name_part.len() && entry.name[..elen].eq_ignore_ascii_case(name_part) { 329 - entry.set_title(title_part); 330 - break; 331 - } 332 - } 333 - } 334 - 335 - pub fn page(&self, skip: usize, buf: &mut [DirEntry]) -> DirPage { 336 - let available = self.count.saturating_sub(skip); 337 - let count = available.min(buf.len()); 338 - if count > 0 { 339 - buf[..count].copy_from_slice(&self.entries[skip..skip + count]); 340 - } 341 - DirPage { 342 - total: self.count, 343 - count, 344 - } 345 - } 346 - 347 - pub fn invalidate(&mut self) { 348 - self.valid = false; 349 - } 350 - } 351 - 352 - // insertion sort: dirs first, then filenames case-insensitive 353 - /// Check whether a FAT 8.3 extension (3 bytes, space-padded) belongs to a 354 - /// file type the device can open. 355 - /// Supported: TXT, MD, EPU(B), JPG, JPE(G), PNG. 356 - fn is_supported_ext(ext: &[u8]) -> bool { 357 - if ext.len() < 3 { 358 - // 2-char extension: only "MD" (padded with space) 359 - return ext.len() == 2 360 - && ext[0].to_ascii_uppercase() == b'M' 361 - && ext[1].to_ascii_uppercase() == b'D'; 362 - } 363 - let e = [ 364 - ext[0].to_ascii_uppercase(), 365 - ext[1].to_ascii_uppercase(), 366 - ext[2].to_ascii_uppercase(), 367 - ]; 368 - matches!( 369 - e, 370 - [b'T', b'X', b'T'] 371 - | [b'M', b'D', b' '] 372 - | [b'E', b'P', b'U'] 373 - | [b'J', b'P', b'G'] 374 - | [b'J', b'P', b'E'] 375 - | [b'P', b'N', b'G'] 376 - ) 377 - } 378 - 379 - fn sort_entries(entries: &mut [DirEntry]) { 380 - for i in 1..entries.len() { 381 - let key = entries[i]; 382 - let mut j = i; 383 - while j > 0 && entry_gt(&entries[j - 1], &key) { 384 - entries[j] = entries[j - 1]; 385 - j -= 1; 386 - } 387 - entries[j] = key; 388 - } 389 - } 390 - 391 - fn entry_gt(a: &DirEntry, b: &DirEntry) -> bool { 392 - if a.is_dir != b.is_dir { 393 - return !a.is_dir; 394 - } 395 - let an = &a.name[..a.name_len as usize]; 396 - let bn = &b.name[..b.name_len as usize]; 397 - for (&ab, &bb) in an.iter().zip(bn.iter()) { 398 - let al = ab.to_ascii_lowercase(); 399 - let bl = bb.to_ascii_lowercase(); 400 - match al.cmp(&bl) { 401 - core::cmp::Ordering::Less => return false, 402 - core::cmp::Ordering::Greater => return true, 403 - core::cmp::Ordering::Equal => {} 404 - } 405 - } 406 - an.len() > bn.len() 407 - } 408 - 409 - fn format_83_name(sfn: &embedded_sdmmc::ShortFileName, out: &mut [u8; 13]) -> usize { 410 - let base = sfn.base_name(); 411 - let ext = sfn.extension(); 412 - 413 - let mut pos = 0; 414 - 415 - for &b in base.iter() { 416 - if b == b' ' { 417 - break; 418 - } 419 - out[pos] = b; 420 - pos += 1; 421 - } 422 - 423 - let ext_trimmed: &[u8] = &ext[..ext.iter().position(|&b| b == b' ').unwrap_or(ext.len())]; 424 - if !ext_trimmed.is_empty() { 425 - out[pos] = b'.'; 426 - pos += 1; 427 - for &b in ext_trimmed { 428 - out[pos] = b; 429 - pos += 1; 430 - } 431 - } 432 - 433 - pos 434 - } 435 - 436 - // root file operations 437 - 438 - pub fn file_size<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<u32, &'static str> 439 - where 440 - SPI: embedded_hal::spi::SpiDevice, 441 - { 442 - with_dir!(sd, |root| do_file_size!(root, name)) 443 - } 444 - 445 - pub fn read_file_chunk<SPI>( 446 - sd: &SdStorage<SPI>, 447 - name: &str, 448 - offset: u32, 449 - buf: &mut [u8], 450 - ) -> Result<usize, &'static str> 451 - where 452 - SPI: embedded_hal::spi::SpiDevice, 453 - { 454 - with_dir!(sd, |root| do_read_chunk!(root, name, offset, buf)) 455 - } 456 - 457 - pub fn read_file_start<SPI>( 458 - sd: &SdStorage<SPI>, 459 - name: &str, 460 - buf: &mut [u8], 461 - ) -> Result<(u32, usize), &'static str> 462 - where 463 - SPI: embedded_hal::spi::SpiDevice, 464 - { 465 - with_dir!(sd, |root| do_read_start!(root, name, buf)) 466 - } 467 - 468 - pub fn write_file<SPI>(sd: &SdStorage<SPI>, name: &str, data: &[u8]) -> Result<(), &'static str> 469 - where 470 - SPI: embedded_hal::spi::SpiDevice, 471 - { 472 - with_dir!(sd, |root| do_write!(root, name, data)) 473 - } 474 - 475 - pub fn append_root_file<SPI>( 476 - sd: &SdStorage<SPI>, 477 - name: &str, 478 - data: &[u8], 479 - ) -> Result<(), &'static str> 480 - where 481 - SPI: embedded_hal::spi::SpiDevice, 482 - { 483 - with_dir!(sd, |root| do_append!(root, name, data)) 484 - } 485 - 486 - pub fn delete_file<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<(), &'static str> 487 - where 488 - SPI: embedded_hal::spi::SpiDevice, 489 - { 490 - with_dir!(sd, |root| do_delete!(root, name)) 491 - } 492 - 493 - pub fn list_root_files<SPI>( 494 - sd: &SdStorage<SPI>, 495 - buf: &mut [DirEntry], 496 - ) -> Result<usize, &'static str> 497 - where 498 - SPI: embedded_hal::spi::SpiDevice, 499 - { 500 - with_dir!(sd, |root| { 501 - let mut count = 0usize; 502 - root.iterate_dir(|entry| { 503 - if matches!(entry.name.base_name()[0], b'.' | b'_') { 504 - return; 505 - } 506 - if entry.attributes.is_directory() { 507 - return; 508 - } 509 - if count < buf.len() { 510 - let mut name_buf = [0u8; 13]; 511 - let name_len = format_83_name(&entry.name, &mut name_buf); 512 - buf[count] = DirEntry { 513 - name: name_buf, 514 - name_len: name_len as u8, 515 - is_dir: false, 516 - size: entry.size, 517 - title: [0u8; TITLE_CAP], 518 - title_len: 0, 519 - }; 520 - count += 1; 521 - } 522 - }) 523 - .map_err(|_| "iterate dir failed")?; 524 - Ok(count) 525 - }) 526 - } 527 - 528 - // subdirectory operations 529 - 530 - pub fn ensure_dir<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<(), &'static str> 531 - where 532 - SPI: embedded_hal::spi::SpiDevice, 533 - { 534 - with_dir!(sd, |root| do_ensure_subdir!(root, name)) 535 - } 536 - 537 - pub fn write_file_in_dir<SPI>( 538 - sd: &SdStorage<SPI>, 539 - dir: &str, 540 - name: &str, 541 - data: &[u8], 542 - ) -> Result<(), &'static str> 543 - where 544 - SPI: embedded_hal::spi::SpiDevice, 545 - { 546 - with_dir!(sd, dir, |sub| do_write!(sub, name, data)) 547 - } 548 - 549 - pub fn append_file_in_dir<SPI>( 550 - sd: &SdStorage<SPI>, 551 - dir: &str, 552 - name: &str, 553 - data: &[u8], 554 - ) -> Result<(), &'static str> 555 - where 556 - SPI: embedded_hal::spi::SpiDevice, 557 - { 558 - with_dir!(sd, dir, |sub| do_append!(sub, name, data)) 559 - } 560 - 561 - pub fn read_file_chunk_in_dir<SPI>( 562 - sd: &SdStorage<SPI>, 563 - dir: &str, 564 - name: &str, 565 - offset: u32, 566 - buf: &mut [u8], 567 - ) -> Result<usize, &'static str> 568 - where 569 - SPI: embedded_hal::spi::SpiDevice, 570 - { 571 - with_dir!(sd, dir, |sub| do_read_chunk!(sub, name, offset, buf)) 572 - } 573 - 574 - pub fn read_file_start_in_dir<SPI>( 575 - sd: &SdStorage<SPI>, 576 - dir: &str, 577 - name: &str, 578 - buf: &mut [u8], 579 - ) -> Result<(u32, usize), &'static str> 580 - where 581 - SPI: embedded_hal::spi::SpiDevice, 582 - { 583 - with_dir!(sd, dir, |sub| do_read_start!(sub, name, buf)) 584 - } 585 - 586 - pub fn file_size_in_dir<SPI>( 587 - sd: &SdStorage<SPI>, 588 - dir: &str, 589 - name: &str, 590 - ) -> Result<u32, &'static str> 591 - where 592 - SPI: embedded_hal::spi::SpiDevice, 593 - { 594 - with_dir!(sd, dir, |sub| do_file_size!(sub, name)) 595 - } 596 - 597 - pub fn delete_file_in_dir<SPI>( 598 - sd: &SdStorage<SPI>, 599 - dir: &str, 600 - name: &str, 601 - ) -> Result<(), &'static str> 602 - where 603 - SPI: embedded_hal::spi::SpiDevice, 604 - { 605 - with_dir!(sd, dir, |sub| do_delete!(sub, name)) 606 - } 607 - 608 - // _PULP app-data directory 609 - 610 - pub fn ensure_pulp_dir<SPI>(sd: &SdStorage<SPI>) -> Result<(), &'static str> 611 - where 612 - SPI: embedded_hal::spi::SpiDevice, 613 - { 614 - ensure_dir(sd, PULP_DIR) 615 - } 616 - 617 - pub fn write_pulp_file<SPI>( 618 - sd: &SdStorage<SPI>, 619 - name: &str, 620 - data: &[u8], 621 - ) -> Result<(), &'static str> 622 - where 623 - SPI: embedded_hal::spi::SpiDevice, 624 - { 625 - write_file_in_dir(sd, PULP_DIR, name, data) 626 - } 627 - 628 - pub fn read_pulp_file_chunk<SPI>( 629 - sd: &SdStorage<SPI>, 630 - name: &str, 631 - offset: u32, 632 - buf: &mut [u8], 633 - ) -> Result<usize, &'static str> 634 - where 635 - SPI: embedded_hal::spi::SpiDevice, 636 - { 637 - read_file_chunk_in_dir(sd, PULP_DIR, name, offset, buf) 638 - } 639 - 640 - pub fn read_pulp_file_start<SPI>( 641 - sd: &SdStorage<SPI>, 642 - name: &str, 643 - buf: &mut [u8], 644 - ) -> Result<(u32, usize), &'static str> 645 - where 646 - SPI: embedded_hal::spi::SpiDevice, 647 - { 648 - read_file_start_in_dir(sd, PULP_DIR, name, buf) 649 - } 650 - 651 - // _PULP subdirectory operations 652 - 653 - pub fn ensure_pulp_subdir<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<(), &'static str> 654 - where 655 - SPI: embedded_hal::spi::SpiDevice, 656 - { 657 - with_dir!(sd, PULP_DIR, |pulp| do_ensure_subdir!(pulp, name)) 658 - } 659 - 660 - pub fn write_in_pulp_subdir<SPI>( 661 - sd: &SdStorage<SPI>, 662 - dir: &str, 663 - name: &str, 664 - data: &[u8], 665 - ) -> Result<(), &'static str> 666 - where 667 - SPI: embedded_hal::spi::SpiDevice, 668 - { 669 - with_dir!(sd, PULP_DIR, dir, |sub| do_write!(sub, name, data)) 670 - } 671 - 672 - pub fn append_in_pulp_subdir<SPI>( 673 - sd: &SdStorage<SPI>, 674 - dir: &str, 675 - name: &str, 676 - data: &[u8], 677 - ) -> Result<(), &'static str> 678 - where 679 - SPI: embedded_hal::spi::SpiDevice, 680 - { 681 - with_dir!(sd, PULP_DIR, dir, |sub| do_append!(sub, name, data)) 682 - } 683 - 684 - pub fn read_chunk_in_pulp_subdir<SPI>( 685 - sd: &SdStorage<SPI>, 686 - dir: &str, 687 - name: &str, 688 - offset: u32, 689 - buf: &mut [u8], 690 - ) -> Result<usize, &'static str> 691 - where 692 - SPI: embedded_hal::spi::SpiDevice, 693 - { 694 - with_dir!(sd, PULP_DIR, dir, |sub| do_read_chunk!( 695 - sub, name, offset, buf 696 - )) 697 - } 698 - 699 - pub fn file_size_in_pulp_subdir<SPI>( 700 - sd: &SdStorage<SPI>, 701 - dir: &str, 702 - name: &str, 703 - ) -> Result<u32, &'static str> 704 - where 705 - SPI: embedded_hal::spi::SpiDevice, 706 - { 707 - with_dir!(sd, PULP_DIR, dir, |sub| do_file_size!(sub, name)) 708 - } 709 - 710 - pub fn delete_in_pulp_subdir<SPI>( 711 - sd: &SdStorage<SPI>, 712 - dir: &str, 713 - name: &str, 714 - ) -> Result<(), &'static str> 715 - where 716 - SPI: embedded_hal::spi::SpiDevice, 717 - { 718 - with_dir!(sd, PULP_DIR, dir, |sub| do_delete!(sub, name)) 719 - } 720 - 721 - // append title mapping line to _PULP/TITLES.BIN 722 - pub fn save_title<SPI>(sd: &SdStorage<SPI>, filename: &str, title: &str) -> Result<(), &'static str> 723 - where 724 - SPI: embedded_hal::spi::SpiDevice, 725 - { 726 - let name_bytes = filename.as_bytes(); 727 - let title_bytes = title.as_bytes(); 728 - let title_len = title_bytes.len().min(TITLE_CAP); 729 - let line_len = name_bytes.len() + 1 + title_len + 1; // name + \t + title + \n 730 - if line_len > 128 { 731 - return Err("title line too long"); 732 - } 733 - let mut line = [0u8; 128]; 734 - line[..name_bytes.len()].copy_from_slice(name_bytes); 735 - line[name_bytes.len()] = b'\t'; 736 - line[name_bytes.len() + 1..name_bytes.len() + 1 + title_len] 737 - .copy_from_slice(&title_bytes[..title_len]); 738 - line[name_bytes.len() + 1 + title_len] = b'\n'; 739 - 740 - append_file_in_dir(sd, PULP_DIR, TITLES_FILE, &line[..line_len]) 741 - }
+6 -15
src/drivers/strip.rs kernel/src/drivers/strip.rs
··· 1 - // Strip-based rendering buffer for e-paper. 2 - // 4KB strip instead of 48KB framebuffer; display split into horizontal bands. 3 - // Widgets draw to logical coords, clipped here. 4 - // begin_strip() for full refresh, begin_window() for partial. 1 + // strip-based rendering buffer for e-paper 2 + // 4 KB strip instead of 48 KB framebuffer; display split into horizontal bands 3 + // widgets draw to logical coords, clipped here 5 4 6 5 use embedded_graphics_core::{ 7 6 Pixel, ··· 14 13 use super::ssd1677::{HEIGHT, Rotation, WIDTH}; 15 14 use crate::ui::Region; 16 15 17 - pub const STRIP_ROWS: u16 = 40; // 4000B per strip (800/8 * 40) 16 + pub const STRIP_ROWS: u16 = 40; 18 17 pub const PHYS_BYTES_PER_ROW: usize = (WIDTH as usize) / 8; 19 18 20 - pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; // 4000B 21 - pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; // 12 strips 19 + pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; 20 + pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; 22 21 23 22 pub struct StripBuffer { 24 23 buf: [u8; STRIP_BUF_SIZE], ··· 98 97 (self.win_x, self.win_y, self.win_w, self.win_h) 99 98 } 100 99 101 - // physical window -> logical coords for widget culling 102 100 pub fn logical_window(&self) -> Region { 103 101 match self.rotation { 104 102 Rotation::Deg0 => Region::new(self.win_x, self.win_y, self.win_w, self.win_h), ··· 135 133 (STRIP_BUF_SIZE / rb) as u16 136 134 } 137 135 138 - #[inline] 139 136 fn to_physical(&self, lx: u16, ly: u16) -> (u16, u16) { 140 137 match self.rotation { 141 138 Rotation::Deg0 => (lx, ly), ··· 145 142 } 146 143 } 147 144 148 - #[inline] 149 145 fn set_pixel_physical(&mut self, px: u16, py: u16, black: bool) { 150 146 if px < self.win_x || px >= self.win_x + self.win_w { 151 147 return; ··· 166 162 } 167 163 } 168 164 169 - // direct 1-bit glyph blit; bypasses DrawTarget overhead 170 165 #[allow(clippy::too_many_arguments)] 171 166 pub fn blit_1bpp( 172 167 &mut self, ··· 188 183 } 189 184 } 190 185 191 - // Deg270: (lx,ly) -> (ly, HEIGHT-1-lx); inner loop is row-contiguous 192 186 #[inline(never)] 193 187 #[allow(clippy::too_many_arguments)] 194 188 fn blit_1bpp_270( ··· 248 242 } 249 243 } 250 244 251 - // generic fallback (Deg0/Deg90/Deg180) 252 245 #[allow(clippy::too_many_arguments)] 253 246 fn blit_1bpp_generic( 254 247 &mut self, ··· 285 278 } 286 279 } 287 280 288 - // byte-aligned rect fill in physical coords, clipped to window 289 281 fn fill_physical_rect(&mut self, px0: u16, py0: u16, px1: u16, py1: u16, black: bool) { 290 282 let cx0 = px0.max(self.win_x); 291 283 let cx1 = px1.min(self.win_x + self.win_w); ··· 404 396 where 405 397 I: IntoIterator<Item = Self::Color>, 406 398 { 407 - // contiguous fills rare on 1-bit; fall back to per-pixel 408 399 let w = area.size.width as i32; 409 400 if w == 0 { 410 401 return Ok(());
+176 -33
src/fonts/bitmap.rs
··· 1 - // Pre-rasterised 1-bit bitmap font types. 2 - // Data in flash via &'static refs from build.rs. Packed MSB-first, row-major. 1 + // pre-rasterised 1-bit bitmap font types 2 + // data in flash via &'static refs from build.rs, packed MSB-first, row-major 3 + // 4 + // two glyph tables per font: 5 + // ascii 0x20-0x7E: contiguous direct-indexed (fast, zero-search) 6 + // extended unicode: sorted codepoint array, binary-searched at runtime 7 + // 8 + // characters not found in either table render as '?' (ascii fallback) 3 9 4 10 use embedded_graphics_core::geometry::Size; 5 11 use embedded_graphics_core::pixelcolor::BinaryColor; ··· 9 15 10 16 pub const FIRST_CHAR: u8 = 0x20; 11 17 pub const LAST_CHAR: u8 = 0x7E; 12 - pub const GLYPH_COUNT: usize = (LAST_CHAR - FIRST_CHAR + 1) as usize; // 95 18 + pub const GLYPH_COUNT: usize = (LAST_CHAR - FIRST_CHAR + 1) as usize; 13 19 14 - // map arbitrary byte to printable char; out-of-range becomes '?' 20 + // map a raw byte to a printable ascii char, or '?' if out of range 15 21 #[inline] 16 22 pub fn byte_to_char(b: u8) -> char { 17 23 if (FIRST_CHAR..=LAST_CHAR).contains(&b) { ··· 21 27 } 22 28 } 23 29 30 + // metrics and bitmap location for a single rasterised glyph 24 31 #[derive(Clone, Copy)] 25 32 #[repr(C)] 26 33 pub struct BitmapGlyph { 27 - pub advance: u8, 28 - pub offset_x: i8, 29 - pub offset_y: i8, 30 - pub width: u8, 31 - pub height: u8, 32 - pub bitmap_offset: u16, 34 + pub advance: u8, // horizontal advance width in pixels 35 + pub offset_x: i8, // cursor to glyph left edge 36 + pub offset_y: i8, // baseline to glyph top (negative = above) 37 + pub width: u8, // bitmap width in pixels 38 + pub height: u8, // bitmap height in pixels 39 + pub bitmap_offset: u16, // byte offset into bitmap array 33 40 } 34 41 42 + // pre-rasterised 1-bit bitmap font stored in flash 43 + // 44 + // ascii glyphs are direct-indexed for 0x20-0x7E 45 + // extended unicode glyphs are sorted by codepoint, binary-searched 46 + // generated at build time by build.rs; zero heap, zero parsing 35 47 pub struct BitmapFont { 36 - pub glyphs: &'static [BitmapGlyph; GLYPH_COUNT], 37 - pub bitmaps: &'static [u8], 38 - pub line_height: u16, 39 - pub ascent: u16, 48 + pub glyphs: &'static [BitmapGlyph; GLYPH_COUNT], // ascii, indexed by (ch - FIRST_CHAR) 49 + pub bitmaps: &'static [u8], // packed 1-bit data for ascii 50 + 51 + pub ext_codepoints: &'static [u32], // sorted extended unicode codepoints 52 + pub ext_glyphs: &'static [BitmapGlyph], // parallel to ext_codepoints 53 + pub ext_bitmaps: &'static [u8], // packed 1-bit data for extended 54 + 55 + pub line_height: u16, // ascent + descent + leading 56 + pub ascent: u16, // baseline to top of tallest glyph 57 + } 58 + 59 + // result of a glyph lookup: metrics and which bitmap table to use 60 + #[derive(Clone, Copy)] 61 + pub struct ResolvedGlyph<'a> { 62 + pub glyph: &'a BitmapGlyph, 63 + pub bitmaps: &'a [u8], 40 64 } 41 65 42 66 impl BitmapFont { 67 + // look up a character, return glyph metrics 68 + // ascii: direct array index; extended: binary search 69 + // unknown chars fall back to space glyph (index 0) 43 70 #[inline] 44 71 pub fn glyph(&self, ch: char) -> &BitmapGlyph { 72 + self.resolve(ch).glyph 73 + } 74 + 75 + // look up a character, return glyph + correct bitmap slice 76 + pub fn resolve(&self, ch: char) -> ResolvedGlyph<'_> { 45 77 let code = ch as u32; 46 - if (FIRST_CHAR as u32..=LAST_CHAR as u32).contains(&code) { 47 - &self.glyphs[(code - FIRST_CHAR as u32) as usize] 48 - } else { 49 - &self.glyphs[0] // space 78 + 79 + // fast path: ascii 80 + if code >= FIRST_CHAR as u32 && code <= LAST_CHAR as u32 { 81 + return ResolvedGlyph { 82 + glyph: &self.glyphs[(code - FIRST_CHAR as u32) as usize], 83 + bitmaps: self.bitmaps, 84 + }; 85 + } 86 + 87 + // extended unicode: binary search 88 + if let Ok(idx) = self.ext_codepoints.binary_search(&code) { 89 + return ResolvedGlyph { 90 + glyph: &self.ext_glyphs[idx], 91 + bitmaps: self.ext_bitmaps, 92 + }; 93 + } 94 + 95 + // fallback: '?' from ascii table 96 + let q_idx = (b'?' - FIRST_CHAR) as usize; 97 + ResolvedGlyph { 98 + glyph: &self.glyphs[q_idx], 99 + bitmaps: self.bitmaps, 50 100 } 51 101 } 52 102 103 + // true if this font has a real glyph for ch (not just '?' fallback) 104 + #[inline] 105 + pub fn has_glyph(&self, ch: char) -> bool { 106 + let code = ch as u32; 107 + if code >= FIRST_CHAR as u32 && code <= LAST_CHAR as u32 { 108 + return true; 109 + } 110 + self.ext_codepoints.binary_search(&code).is_ok() 111 + } 112 + 113 + // horizontal advance for a single character 53 114 #[inline] 54 115 pub fn advance(&self, ch: char) -> u8 { 55 116 self.glyph(ch).advance 56 117 } 57 118 58 - // sum of advance widths for every character in text 119 + // total width in pixels of a &str 59 120 #[inline] 60 121 pub fn measure_str(&self, text: &str) -> u16 { 61 122 text.chars().map(|c| self.advance(c) as u16).sum() 62 123 } 63 124 64 - // sum of advance widths for every byte; out-of-range bytes count as '?' 65 - #[inline] 125 + // total width in pixels of a &[u8] slice (decodes utf-8) 66 126 pub fn measure_bytes(&self, text: &[u8]) -> u16 { 67 - text.iter() 68 - .map(|&b| self.advance(byte_to_char(b)) as u16) 69 - .sum() 127 + Utf8Iter::new(text).map(|c| self.advance(c) as u16).sum() 70 128 } 71 129 72 - // draw a single glyph in BinaryColor::On (black); return advance width 130 + // draw a character at (cx, baseline) in black, return advance 73 131 #[inline] 74 132 pub fn draw_char(&self, strip: &mut StripBuffer, ch: char, cx: i32, baseline: i32) -> u8 { 75 133 self.draw_char_fg(strip, ch, BinaryColor::On, cx, baseline) 76 134 } 77 135 78 - // draw a single glyph in the given foreground colour; return advance width 136 + // draw a character with given foreground colour, return advance 79 137 #[inline] 80 138 pub fn draw_char_fg( 81 139 &self, ··· 85 143 cx: i32, 86 144 baseline: i32, 87 145 ) -> u8 { 88 - let g = self.glyph(ch); 146 + let resolved = self.resolve(ch); 147 + let g = resolved.glyph; 89 148 if g.width > 0 && g.height > 0 { 90 - blit_glyph(strip, self.bitmaps, g, fg, cx, baseline); 149 + blit_glyph(strip, resolved.bitmaps, g, fg, cx, baseline); 91 150 } 92 151 g.advance 93 152 } 94 153 95 - // draw text in BinaryColor::On; return x after the last glyph 154 + // draw a &str at (cx, baseline) in black, return final x 96 155 pub fn draw_str(&self, strip: &mut StripBuffer, text: &str, cx: i32, baseline: i32) -> i32 { 97 156 self.draw_str_fg(strip, text, BinaryColor::On, cx, baseline) 98 157 } 99 158 100 - // draw text in the given foreground colour; return x after the last glyph 159 + // draw a &str with given foreground, return final x 101 160 pub fn draw_str_fg( 102 161 &self, 103 162 strip: &mut StripBuffer, ··· 113 172 x 114 173 } 115 174 175 + // draw a &[u8] (decoded as utf-8) at (cx, baseline) in black, return final x 116 176 pub fn draw_bytes(&self, strip: &mut StripBuffer, text: &[u8], cx: i32, baseline: i32) -> i32 { 117 177 let mut x = cx; 118 - for &b in text { 119 - x += self.draw_char(strip, byte_to_char(b), x, baseline) as i32; 178 + for ch in Utf8Iter::new(text) { 179 + x += self.draw_char(strip, ch, x, baseline) as i32; 120 180 } 121 181 x 122 182 } 123 183 124 - // measure, align, and draw text; does not clear background 184 + // draw a &[u8] with given foreground, return final x 185 + pub fn draw_bytes_fg( 186 + &self, 187 + strip: &mut StripBuffer, 188 + text: &[u8], 189 + fg: BinaryColor, 190 + cx: i32, 191 + baseline: i32, 192 + ) -> i32 { 193 + let mut x = cx; 194 + for ch in Utf8Iter::new(text) { 195 + x += self.draw_char_fg(strip, ch, fg, x, baseline) as i32; 196 + } 197 + x 198 + } 199 + 200 + // draw a &str aligned within a region 125 201 pub fn draw_aligned( 126 202 &self, 127 203 strip: &mut StripBuffer, ··· 166 242 fg == BinaryColor::On, 167 243 ); 168 244 } 245 + 246 + // minimal utf-8 byte-slice iterator 247 + // decodes &[u8] one char at a time; invalid sequences replaced with U+FFFD 248 + // (which renders as '?' via the font fallback) 249 + pub struct Utf8Iter<'a> { 250 + data: &'a [u8], 251 + pos: usize, 252 + } 253 + 254 + impl<'a> Utf8Iter<'a> { 255 + #[inline] 256 + pub fn new(data: &'a [u8]) -> Self { 257 + Self { data, pos: 0 } 258 + } 259 + } 260 + 261 + impl Iterator for Utf8Iter<'_> { 262 + type Item = char; 263 + 264 + fn next(&mut self) -> Option<char> { 265 + if self.pos >= self.data.len() { 266 + return None; 267 + } 268 + 269 + let b0 = self.data[self.pos]; 270 + 271 + // single-byte ascii 272 + if b0 < 0x80 { 273 + self.pos += 1; 274 + return Some(b0 as char); 275 + } 276 + 277 + // determine expected sequence length from lead byte 278 + let (mut cp, expected) = if b0 < 0xC0 { 279 + // stray continuation byte 280 + self.pos += 1; 281 + return Some('\u{FFFD}'); 282 + } else if b0 < 0xE0 { 283 + ((b0 as u32) & 0x1F, 2) 284 + } else if b0 < 0xF0 { 285 + ((b0 as u32) & 0x0F, 3) 286 + } else if b0 < 0xF8 { 287 + ((b0 as u32) & 0x07, 4) 288 + } else { 289 + self.pos += 1; 290 + return Some('\u{FFFD}'); 291 + }; 292 + 293 + if self.pos + expected > self.data.len() { 294 + self.pos = self.data.len(); 295 + return Some('\u{FFFD}'); 296 + } 297 + 298 + // decode continuation bytes 299 + for i in 1..expected { 300 + let cont = self.data[self.pos + i]; 301 + if cont & 0xC0 != 0x80 { 302 + self.pos += i; 303 + return Some('\u{FFFD}'); 304 + } 305 + cp = (cp << 6) | (cont as u32 & 0x3F); 306 + } 307 + 308 + self.pos += expected; 309 + Some(char::from_u32(cp).unwrap_or('\u{FFFD}')) 310 + } 311 + }
+65 -14
src/fonts/mod.rs
··· 1 - // Build-time rasterised bitmap fonts for e-ink rendering. 2 - // TTFs rasterised by build.rs via fontdue into 1-bit tables in flash. 3 - // Zero heap, zero parsing at runtime. Three sizes: 0=Small, 1=Medium, 2=Large. 1 + // build-time rasterised bitmap fonts for e-ink rendering 2 + // TTFs rasterised by build.rs via fontdue into 1-bit tables in flash 3 + // zero heap, zero parsing at runtime 4 + // 5 + // five size tiers: 0=XSmall 1=Small 2=Medium 3=Large 4=XLarge 4 6 5 7 pub mod bitmap; 6 8 ··· 12 14 use crate::drivers::strip::StripBuffer; 13 15 use bitmap::BitmapFont; 14 16 15 - // 0 = Small, 1 = Medium, 2 = Large 16 - pub const FONT_SIZE_NAMES: &[&str] = &["Small", "Medium", "Large"]; 17 + pub const FONT_SIZE_COUNT: usize = 5; 18 + 19 + pub const FONT_SIZE_NAMES: &[&str] = &["XSmall", "Small", "Medium", "Large", "XLarge"]; 20 + 21 + // pre-resolved body + heading font pair for a given size index 22 + #[derive(Clone, Copy)] 23 + pub struct UiFonts { 24 + pub body: &'static BitmapFont, 25 + pub heading: &'static BitmapFont, 26 + } 17 27 28 + impl UiFonts { 29 + pub fn for_size(idx: u8) -> Self { 30 + Self { 31 + body: body_font(idx), 32 + heading: heading_font(idx), 33 + } 34 + } 35 + } 36 + 37 + // human-readable name for size index (clamped to valid range) 38 + #[inline] 18 39 pub fn font_size_name(idx: u8) -> &'static str { 19 40 FONT_SIZE_NAMES 20 41 .get(idx as usize) ··· 22 43 .unwrap_or("Small") 23 44 } 24 45 25 - // body font by index: 0 = Small, 1 = Medium, 2 = Large 46 + #[inline] 47 + pub const fn max_size_idx() -> u8 { 48 + (FONT_SIZE_COUNT - 1) as u8 49 + } 50 + 26 51 pub fn body_font(idx: u8) -> &'static BitmapFont { 27 52 match idx { 28 - 1 => &font_data::REGULAR_BODY_MEDIUM, 29 - 2 => &font_data::REGULAR_BODY_LARGE, 53 + 0 => &font_data::REGULAR_BODY_XSMALL, 54 + 1 => &font_data::REGULAR_BODY_SMALL, 55 + 2 => &font_data::REGULAR_BODY_MEDIUM, 56 + 3 => &font_data::REGULAR_BODY_LARGE, 57 + 4 => &font_data::REGULAR_BODY_XLARGE, 30 58 _ => &font_data::REGULAR_BODY_SMALL, 31 59 } 32 60 } 33 61 34 - // chrome font (button labels, quick-menu items, loading text, etc.); 35 - // always returns the small body font regardless of the size setting 62 + // chrome font (button labels, quick-menu items, loading text) 63 + // always the XSmall body font, compact for UI chrome 36 64 pub fn chrome_font() -> &'static BitmapFont { 37 65 body_font(0) 38 66 } 39 67 40 68 pub fn heading_font(idx: u8) -> &'static BitmapFont { 41 69 match idx { 42 - 1 => &font_data::REGULAR_HEADING_MEDIUM, 43 - 2 => &font_data::REGULAR_HEADING_LARGE, 70 + 0 => &font_data::REGULAR_HEADING_XSMALL, 71 + 1 => &font_data::REGULAR_HEADING_SMALL, 72 + 2 => &font_data::REGULAR_HEADING_MEDIUM, 73 + 3 => &font_data::REGULAR_HEADING_LARGE, 74 + 4 => &font_data::REGULAR_HEADING_XLARGE, 44 75 _ => &font_data::REGULAR_HEADING_SMALL, 45 76 } 46 77 } ··· 53 84 Heading, 54 85 } 55 86 87 + // complete set of four style variants at a single size tier 88 + // missing weights fall back to regular automatically 56 89 #[derive(Clone, Copy)] 57 90 pub struct FontSet { 58 91 regular: &'static BitmapFont, ··· 94 127 95 128 pub fn for_size(idx: u8) -> Self { 96 129 match idx { 130 + 0 => Self::from_fonts( 131 + &font_data::REGULAR_BODY_XSMALL, 132 + &font_data::BOLD_BODY_XSMALL, 133 + &font_data::ITALIC_BODY_XSMALL, 134 + &font_data::REGULAR_HEADING_XSMALL, 135 + ), 97 136 1 => Self::from_fonts( 137 + &font_data::REGULAR_BODY_SMALL, 138 + &font_data::BOLD_BODY_SMALL, 139 + &font_data::ITALIC_BODY_SMALL, 140 + &font_data::REGULAR_HEADING_SMALL, 141 + ), 142 + 2 => Self::from_fonts( 98 143 &font_data::REGULAR_BODY_MEDIUM, 99 144 &font_data::BOLD_BODY_MEDIUM, 100 145 &font_data::ITALIC_BODY_MEDIUM, 101 146 &font_data::REGULAR_HEADING_MEDIUM, 102 147 ), 103 - 2 => Self::from_fonts( 148 + 3 => Self::from_fonts( 104 149 &font_data::REGULAR_BODY_LARGE, 105 150 &font_data::BOLD_BODY_LARGE, 106 151 &font_data::ITALIC_BODY_LARGE, 107 152 &font_data::REGULAR_HEADING_LARGE, 153 + ), 154 + 4 => Self::from_fonts( 155 + &font_data::REGULAR_BODY_XLARGE, 156 + &font_data::BOLD_BODY_XLARGE, 157 + &font_data::ITALIC_BODY_XLARGE, 158 + &font_data::REGULAR_HEADING_XLARGE, 108 159 ), 109 160 _ => Self::from_fonts( 110 161 &font_data::REGULAR_BODY_SMALL, ··· 116 167 } 117 168 118 169 pub fn new() -> Self { 119 - Self::for_size(0) 170 + Self::for_size(1) 120 171 } 121 172 122 173 #[inline]
-8
src/kernel/mod.rs
··· 1 - // Kernel: Embassy async runtime wrapper. 2 - // wake: uptime helper (embassy_time) 3 - // tasks: spawned tasks (input polling, housekeeping, idle sleep) 4 - 5 - pub mod tasks; 6 - pub mod wake; 7 - 8 - pub use wake::uptime_secs;
+4 -22
src/kernel/tasks.rs kernel/src/kernel/tasks.rs
··· 1 - // Embassy spawned tasks: input polling, housekeeping, idle sleep. 2 - // input_task: ADC ladder + power button debounce, 10ms poll. 3 - // housekeeping_task: periodic signals (status bar, SD check, bookmark flush). 4 - // idle_timeout_task: fires IDLE_SLEEP_DUE after configured idle minutes. 1 + // embassy spawned tasks: input polling, housekeeping, idle sleep 5 2 6 3 use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; 7 4 use embassy_sync::channel::Channel; ··· 11 8 use crate::drivers::battery; 12 9 use crate::drivers::input::{Event, InputDriver}; 13 10 14 - // debounced events from input_task to main loop 15 11 pub const INPUT_CHANNEL_CAP: usize = 8; 16 12 pub static INPUT_EVENTS: Channel<CriticalSectionRawMutex, Event, INPUT_CHANNEL_CAP> = 17 13 Channel::new(); 18 14 19 - // latest battery mv; Signal overwrites stale values 20 15 pub static BATTERY_MV: Signal<CriticalSectionRawMutex, u16> = Signal::new(); 21 16 22 - // 3000 x 10ms = 30s between battery reads 23 - const BATTERY_INTERVAL_TICKS: u32 = 3000; 17 + const BATTERY_INTERVAL_TICKS: u32 = 3000; // 3000 x 10 ms = 30 s 24 18 25 19 #[embassy_executor::task] 26 20 pub async fn input_task(mut input: InputDriver) -> ! { 27 21 let mut ticker = Ticker::every(Duration::from_millis(10)); 28 22 let mut battery_counter: u32 = 0; 29 23 30 - // initial reading so the status bar has a value before the first 30s 31 24 let raw = input.read_battery_mv(); 32 25 BATTERY_MV.signal(battery::adc_to_battery_mv(raw)); 33 26 ··· 35 28 ticker.next().await; 36 29 37 30 if let Some(ev) = input.poll() { 38 - let _ = INPUT_EVENTS.try_send(ev); // drop on full; main drains faster than events arrive 31 + let _ = INPUT_EVENTS.try_send(ev); 39 32 IDLE_RESET.signal(()); 40 33 } 41 34 ··· 54 47 55 48 #[embassy_executor::task] 56 49 pub async fn housekeeping_task() -> ! { 57 - // let boot rendering finish before first housekeeping cycle 58 50 Timer::after(Duration::from_secs(5)).await; 59 51 60 52 let mut status_ticker = Ticker::every(Duration::from_secs(5)); 61 53 let mut sd_ticker = Ticker::every(Duration::from_secs(30)); 62 54 63 - // stagger bookmark ticker 2s behind SD so they don't hit the card together 64 - Timer::after(Duration::from_secs(2)).await; 55 + Timer::after(Duration::from_secs(2)).await; // stagger behind SD 65 56 let mut bm_ticker = Ticker::every(Duration::from_secs(30)); 66 57 67 58 loop { ··· 75 66 } 76 67 } 77 68 78 - // set by main after loading settings; re-signal on change; 0 = never 79 69 pub static IDLE_TIMEOUT_MINS: Signal<CriticalSectionRawMutex, u16> = Signal::new(); 80 - 81 - // any button activity; Signal collapses rapid presses to one 82 70 pub static IDLE_RESET: Signal<CriticalSectionRawMutex, ()> = Signal::new(); 83 - 84 - // fired when idle timer expires; main loop puts display + MCU to sleep 85 71 pub static IDLE_SLEEP_DUE: Signal<CriticalSectionRawMutex, ()> = Signal::new(); 86 72 87 73 #[inline] ··· 94 80 let mut timeout_mins = IDLE_TIMEOUT_MINS.wait().await; 95 81 96 82 loop { 97 - // park until a non-zero timeout is configured 98 83 if timeout_mins == 0 { 99 84 timeout_mins = IDLE_TIMEOUT_MINS.wait().await; 100 85 continue; ··· 102 87 103 88 let duration = Duration::from_secs(timeout_mins as u64 * 60); 104 89 105 - // drain stale signals before starting countdown 106 90 let _ = IDLE_RESET.try_take(); 107 91 if let Some(new) = IDLE_TIMEOUT_MINS.try_take() { 108 92 timeout_mins = new; ··· 120 104 .await 121 105 { 122 106 Either3::First(()) => { 123 - // activity; restart countdown 124 107 continue; 125 108 } 126 109 Either3::Second(new_mins) => { ··· 130 113 Either3::Third(()) => { 131 114 IDLE_SLEEP_DUE.signal(()); 132 115 133 - // park until main acts; deep sleep is -> ! so this rarely returns 134 116 use embassy_futures::select::{Either, select}; 135 117 match select(IDLE_RESET.wait(), IDLE_TIMEOUT_MINS.wait()).await { 136 118 Either::First(()) => {}
-5
src/kernel/wake.rs
··· 1 - // Uptime helper backed by Embassy's monotonic clock. 2 - pub fn uptime_secs() -> u32 { 3 - let ticks = embassy_time::Instant::now().as_ticks(); 4 - (ticks / embassy_time::TICK_HZ) as u32 // TICK_HZ = 1_000_000 on ESP32-C3 5 - }
+7 -4
src/lib.rs
··· 1 - // pulp-os: operating system for the XTEink X4 (ESP32-C3, e-paper) 1 + // pulp-os -- e-reader firmware for the XTEink X4 2 2 3 3 #![no_std] 4 4 5 5 extern crate alloc; 6 6 7 + // kernel crate re-exports -- keeps crate::board, crate::drivers, 8 + // crate::kernel paths working in app code without import changes 9 + pub use pulp_kernel::board; 10 + pub use pulp_kernel::drivers; 11 + pub use pulp_kernel::kernel; 12 + 7 13 pub mod apps; 8 - pub mod board; 9 - pub mod drivers; 10 14 pub mod fonts; 11 - pub mod kernel; 12 15 pub mod ui;
+1 -1
src/ui/bitmap_label.rs src/apps/widgets/bitmap_label.rs
··· 2 2 3 3 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; 4 4 5 - use super::widget::{Alignment, Region}; 6 5 use crate::drivers::strip::StripBuffer; 7 6 use crate::fonts::bitmap::BitmapFont; 7 + use crate::ui::{Alignment, Region}; 8 8 9 9 pub struct BitmapLabel<'a> { 10 10 region: Region,
+2 -9
src/ui/button_feedback.rs src/apps/widgets/button_feedback.rs
··· 1 1 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; 2 2 3 - use super::widget::{Alignment, Region}; 4 3 use crate::board::action::{Action, ButtonMapper}; 5 4 use crate::board::button::Button; 5 + use crate::board::layout::{CX_BACK, CX_CONFIRM, CX_LEFT, CX_RIGHT, CY_VOL_DOWN, CY_VOL_UP}; 6 6 use crate::board::{SCREEN_H, SCREEN_W}; 7 7 use crate::drivers::strip::StripBuffer; 8 8 use crate::fonts::bitmap::BitmapFont; 9 9 use crate::fonts::font_data; 10 + use crate::ui::{Alignment, Region}; 10 11 11 12 const TAB_W: u16 = 60; 12 13 const TAB_H: u16 = 22; ··· 15 16 16 17 const RIDGE_W: u16 = 22; 17 18 const RIDGE_H: u16 = 36; 18 - 19 - const CX_BACK: u16 = 84; 20 - const CX_CONFIRM: u16 = 194; 21 - const CX_LEFT: u16 = 286; 22 - const CX_RIGHT: u16 = 396; 23 - 24 - const CY_VOL_UP: u16 = 364; 25 - const CY_VOL_DOWN: u16 = 484; 26 19 27 20 const NUM_BUMPS: usize = 6; 28 21
+13 -20
src/ui/mod.rs
··· 1 - // Widget toolkit for 1-bit e-paper displays. 2 - // BitmapLabel/BitmapDynLabel with inverted highlight for selection. 3 - // Region-based layout, strip-buffered rendering. 1 + // ui re-exports: kernel primitives + app-side font-dependent widgets 2 + // 3 + // kernel ui (Region, Alignment, StackFmt, statusbar constants) is 4 + // re-exported from pulp-kernel. font-dependent widgets (BitmapLabel, 5 + // QuickMenu, ButtonFeedback) come from apps::widgets. 4 6 5 - mod bitmap_label; 6 - pub mod button_feedback; 7 - pub mod quick_menu; 8 - pub mod stack_fmt; 9 - pub mod statusbar; 10 - mod widget; 7 + // kernel-side primitives 8 + pub use pulp_kernel::ui::stack_fmt; 9 + pub use pulp_kernel::ui::*; 11 10 12 - pub use bitmap_label::{BitmapDynLabel, BitmapLabel}; 13 - pub use button_feedback::{BUTTON_BAR_H, ButtonFeedback}; 14 - pub use quick_menu::QuickMenu; 15 - pub use stack_fmt::{StackFmt, stack_fmt}; 16 - pub use statusbar::{ 17 - BAR_HEIGHT, CONTENT_TOP, StatusBar, SystemStatus, free_stack_bytes, paint_stack, 18 - stack_high_water_mark, 19 - }; 20 - pub use widget::{Alignment, Region, wrap_next, wrap_prev}; 21 - 22 - pub use crate::board::{SCREEN_H, SCREEN_W}; 11 + // app-side font-dependent widgets 12 + pub use crate::apps::widgets::QuickMenu; 13 + pub use crate::apps::widgets::bitmap_label::{BitmapDynLabel, BitmapLabel}; 14 + pub use crate::apps::widgets::button_feedback::{BUTTON_BAR_H, ButtonFeedback}; 15 + pub use crate::apps::widgets::quick_menu;
+3 -45
src/ui/quick_menu.rs src/apps/widgets/quick_menu.rs
··· 1 1 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; 2 2 3 - use super::stack_fmt::StackFmt; 4 - use super::widget::{Alignment, Region, wrap_next, wrap_prev}; 5 3 use crate::board::SCREEN_W; 6 4 use crate::board::action::Action; 7 5 use crate::drivers::strip::StripBuffer; 8 6 use crate::fonts::bitmap::BitmapFont; 9 7 use crate::fonts::font_data; 8 + pub use crate::kernel::app::{MAX_APP_ACTIONS, QuickAction, QuickActionKind}; 9 + use crate::ui::stack_fmt::StackFmt; 10 + use crate::ui::{Alignment, Region, wrap_next, wrap_prev}; 10 11 11 12 const OVERLAY_W: u16 = 400; 12 13 const OVERLAY_X: u16 = (SCREEN_W - OVERLAY_W) / 2; ··· 22 23 const VALUE_W: u16 = OVERLAY_W - 16 - LABEL_W - 8 - 16; 23 24 const HELP_H: u16 = 20; 24 25 25 - pub const MAX_APP_ACTIONS: usize = 6; 26 - 27 26 const NUM_CORE: usize = 2; // Refresh + Go Home 28 27 const MAX_ITEMS: usize = MAX_APP_ACTIONS + NUM_CORE; 29 - 30 - #[derive(Debug, Clone, Copy)] 31 - pub enum QuickActionKind { 32 - Cycle { 33 - value: u8, 34 - options: &'static [&'static str], 35 - }, 36 - Trigger { 37 - display: &'static str, 38 - }, 39 - } 40 - 41 - #[derive(Debug, Clone, Copy)] 42 - pub struct QuickAction { 43 - pub id: u8, 44 - pub label: &'static str, 45 - pub kind: QuickActionKind, 46 - } 47 - 48 - impl QuickAction { 49 - pub const fn cycle( 50 - id: u8, 51 - label: &'static str, 52 - value: u8, 53 - options: &'static [&'static str], 54 - ) -> Self { 55 - Self { 56 - id, 57 - label, 58 - kind: QuickActionKind::Cycle { value, options }, 59 - } 60 - } 61 - 62 - pub const fn trigger(id: u8, label: &'static str, display: &'static str) -> Self { 63 - Self { 64 - id, 65 - label, 66 - kind: QuickActionKind::Trigger { display }, 67 - } 68 - } 69 - } 70 28 71 29 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 72 30 pub enum QuickMenuResult {
+1 -1
src/ui/stack_fmt.rs kernel/src/ui/stack_fmt.rs
··· 1 - // No-alloc fmt::Write buffers; silently truncate on overflow. 1 + // no-alloc fmt::Write buffers; silently truncate on overflow 2 2 3 3 pub struct StackFmt<const N: usize> { 4 4 buf: [u8; N],
-239
src/ui/statusbar.rs
··· 1 - // Debug status bar; zero height in release builds. 2 - 3 - #[cfg(debug_assertions)] 4 - use core::fmt::Write; 5 - 6 - #[cfg(debug_assertions)] 7 - use embedded_graphics::mono_font::MonoTextStyle; 8 - #[cfg(debug_assertions)] 9 - use embedded_graphics::mono_font::ascii::FONT_6X13; 10 - use embedded_graphics::pixelcolor::BinaryColor; 11 - use embedded_graphics::prelude::*; 12 - 13 - use crate::board::SCREEN_W; 14 - #[cfg(debug_assertions)] 15 - use embedded_graphics::primitives::PrimitiveStyle; 16 - #[cfg(debug_assertions)] 17 - use embedded_graphics::text::Text; 18 - 19 - #[cfg(debug_assertions)] 20 - use super::stack_fmt::BorrowedFmt; 21 - use super::widget::Region; 22 - 23 - #[cfg(debug_assertions)] 24 - pub const BAR_HEIGHT: u16 = 18; 25 - #[cfg(not(debug_assertions))] 26 - pub const BAR_HEIGHT: u16 = 4; 27 - 28 - pub const CONTENT_TOP: u16 = BAR_HEIGHT; 29 - 30 - pub const BAR_REGION: Region = Region::new(0, 0, SCREEN_W, BAR_HEIGHT); 31 - 32 - pub struct SystemStatus { 33 - pub uptime_secs: u32, 34 - pub battery_mv: u16, 35 - pub battery_pct: u8, 36 - pub heap_used: usize, 37 - pub heap_peak: usize, 38 - pub heap_total: usize, 39 - pub stack_free: usize, 40 - pub stack_hwm: usize, 41 - pub sd_ok: bool, 42 - } 43 - 44 - pub struct StatusBar { 45 - #[cfg(debug_assertions)] 46 - buf: [u8; 112], 47 - #[cfg(debug_assertions)] 48 - len: usize, 49 - } 50 - 51 - impl Default for StatusBar { 52 - fn default() -> Self { 53 - Self::new() 54 - } 55 - } 56 - 57 - impl StatusBar { 58 - pub const fn new() -> Self { 59 - Self { 60 - #[cfg(debug_assertions)] 61 - buf: [0u8; 112], 62 - #[cfg(debug_assertions)] 63 - len: 0, 64 - } 65 - } 66 - 67 - pub fn update(&mut self, _s: &SystemStatus) { 68 - #[cfg(debug_assertions)] 69 - { 70 - let s = _s; 71 - self.len = 0; 72 - 73 - let secs = s.uptime_secs % 60; 74 - let mins = (s.uptime_secs / 60) % 60; 75 - let hrs = s.uptime_secs / 3600; 76 - 77 - let mut w = BorrowedFmt::new(&mut self.buf); 78 - 79 - if s.battery_mv > 0 { 80 - let _ = write!( 81 - w, 82 - "BAT {}% {}.{}V", 83 - s.battery_pct, 84 - s.battery_mv / 1000, 85 - (s.battery_mv % 1000) / 100 86 - ); 87 - } else { 88 - let _ = write!(w, "BAT --"); 89 - } 90 - 91 - if hrs > 0 { 92 - let _ = write!(w, " {}:{:02}:{:02}", hrs, mins, secs); 93 - } else { 94 - let _ = write!(w, " {:02}:{:02}", mins, secs); 95 - } 96 - 97 - if s.heap_total > 0 { 98 - let _ = write!( 99 - w, 100 - " H:{}/{}/{}K", 101 - s.heap_used / 1024, 102 - s.heap_peak / 1024, 103 - s.heap_total / 1024 104 - ); 105 - } 106 - 107 - if s.stack_free > 0 { 108 - let _ = write!(w, " S:{}K/{}K", s.stack_free / 1024, s.stack_hwm / 1024); 109 - } 110 - 111 - let _ = write!(w, " SD:{}", if s.sd_ok { "OK" } else { "--" }); 112 - 113 - self.len = w.len(); 114 - } 115 - } 116 - 117 - #[cfg(debug_assertions)] 118 - fn text(&self) -> &str { 119 - core::str::from_utf8(&self.buf[..self.len]).unwrap_or("") 120 - } 121 - 122 - pub fn draw<D>(&self, _display: &mut D) -> Result<(), D::Error> 123 - where 124 - D: DrawTarget<Color = BinaryColor>, 125 - { 126 - #[cfg(debug_assertions)] 127 - { 128 - let display = _display; 129 - BAR_REGION 130 - .to_rect() 131 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 132 - .draw(display)?; 133 - 134 - let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::Off); 135 - Text::new(self.text(), Point::new(4, 14), style).draw(display)?; 136 - } 137 - 138 - Ok(()) 139 - } 140 - 141 - pub fn region(&self) -> Region { 142 - BAR_REGION 143 - } 144 - } 145 - 146 - pub fn free_stack_bytes() -> usize { 147 - let sp: usize; 148 - #[cfg(target_arch = "riscv32")] 149 - unsafe { 150 - core::arch::asm!("mv {}, sp", out(reg) sp); 151 - } 152 - #[cfg(not(target_arch = "riscv32"))] 153 - { 154 - sp = 0; 155 - } 156 - 157 - #[cfg(target_arch = "riscv32")] 158 - { 159 - unsafe extern "C" { 160 - static _stack_end_cpu0: u8; 161 - } 162 - let stack_bottom = (&raw const _stack_end_cpu0) as usize; 163 - sp.saturating_sub(stack_bottom) 164 - } 165 - 166 - #[cfg(not(target_arch = "riscv32"))] 167 - { 168 - sp 169 - } 170 - } 171 - 172 - const STACK_PAINT_WORD: u32 = 0xDEAD_BEEF; 173 - 174 - pub fn paint_stack() { 175 - #[cfg(target_arch = "riscv32")] 176 - { 177 - let sp: usize; 178 - unsafe { 179 - core::arch::asm!("mv {}, sp", out(reg) sp); 180 - } 181 - 182 - unsafe extern "C" { 183 - static _stack_end_cpu0: u8; 184 - } 185 - let bottom = (&raw const _stack_end_cpu0) as usize; 186 - 187 - let guard_skip = 256; // skip esp-hal stack guard word 188 - let paint_bottom = bottom + guard_skip; 189 - 190 - let paint_top = sp.saturating_sub(256); // leave 256B below SP for our frame + ISR 191 - 192 - if paint_top <= paint_bottom { 193 - return; 194 - } 195 - 196 - let start = (paint_bottom + 3) & !3; 197 - 198 - let mut addr = start; 199 - while addr + 4 <= paint_top { 200 - unsafe { 201 - core::ptr::write_volatile(addr as *mut u32, STACK_PAINT_WORD); 202 - } 203 - addr += 4; 204 - } 205 - } 206 - } 207 - 208 - pub fn stack_high_water_mark() -> usize { 209 - #[cfg(target_arch = "riscv32")] 210 - { 211 - unsafe extern "C" { 212 - static _stack_end_cpu0: u8; 213 - static _stack_start_cpu0: u8; 214 - } 215 - let bottom = (&raw const _stack_end_cpu0) as usize; 216 - let top = (&raw const _stack_start_cpu0) as usize; 217 - 218 - let guard_skip = 256; // same guard region as paint_stack 219 - let scan_bottom = bottom + guard_skip; 220 - 221 - let start = (scan_bottom + 3) & !3; 222 - 223 - let mut addr = start; 224 - while addr + 4 <= top { 225 - let val = unsafe { core::ptr::read_volatile(addr as *const u32) }; 226 - if val != STACK_PAINT_WORD { 227 - break; 228 - } 229 - addr += 4; 230 - } 231 - 232 - top.saturating_sub(addr) 233 - } 234 - 235 - #[cfg(not(target_arch = "riscv32"))] 236 - { 237 - 0 238 - } 239 - }
+1 -1
src/ui/widget.rs kernel/src/ui/widget.rs
··· 1 - // Region geometry and alignment helpers; x/w should be 8-aligned for partial refresh. 1 + // region geometry and alignment helpers 2 2 3 3 use embedded_graphics::{prelude::*, primitives::Rectangle}; 4 4