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.

settings rewrite, reading themes, button swap, expanded character set settings app: - rewrite with 6 items: sleep, ghost clear, book font, UI font, reading theme, swap buttons - dynamic visible_items() computed from available screen space - scroll support when items overflow visible area reading themes: - add ReadingTheme struct with margin_h, margin_v, line_spacing_pct - 4 presets: Compact / Default / Relaxed / Spacious - replace hardcoded TEXT_W/TEXT_Y/TEXT_AREA_H/MARGIN in reader text paths with runtime fields (text_margin, text_y, text_w, text_area_h) - theme-aware line spacing in apply_font_metrics() - wire theme propagation through manager.propagate_fonts() - apply_theme_layout() called in on_enter and on_resume button swap: - ButtonMapper swaps Back<->Left, Confirm<->Right (not volume) - ButtonFeedback uses swap flag to show correct physical labels - sync_button_config() in manager updates mapper + bumps on change reader improvements: - remove unused progress bar (READER_PROGRESS_H, PROGRESS_Y, PROGRESS_W) - fix inline image clipping: ceiling division for img_lines reservation, clamp blit_h to remaining vertical space - add LongPress(NextJump) -> end of chapter, LongPress(PrevJump) -> start - images.rs uses runtime text_w/text_area_h instead of constants shared widgets: - add selectable_row.rs (draw_selection, draw_selection_if_visible) - add list.rs (ListSelection scroll helper) - add format.rs (draw_position_indicator, fmt_position, fmt_percent) - refactor home bookmarks and quick_menu to use shared selection widget character support: - add ~70 codepoints to build.rs: currency, math, arrows, symbols, Latin Extended-B (Vietnamese, Romanian) config rewrite: - SystemSettings gains reading_theme (u8) and swap_buttons (bool) - SETTINGS.TXT parse/write updated with section comments

hansmrtn 3f357bff f2f0f813

+938 -198
+93 -8
build.rs
··· 20 20 match kind.as_str() { 21 21 "undefined-symbol" => match what.as_str() { 22 22 what if what.starts_with("_defmt_") => hint( 23 - "`defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`" 23 + "`defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`", 24 24 ), 25 25 "_stack_start" => hint("is the linker script `linkall.x` missing?"), 26 26 what if what.starts_with("esp_rtos_") => hint( 27 - "`esp-radio` has no scheduler enabled. make sure you have initialized `esp-rtos` or provided an external scheduler." 27 + "`esp-radio` has no scheduler enabled. make sure you have initialized `esp-rtos` or provided an external scheduler.", 28 28 ), 29 29 "embedded_test_linker_file_not_added_to_rustflags" => hint( 30 - "`embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests" 30 + "`embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests", 31 31 ), 32 32 "free" 33 33 | "malloc" ··· 37 37 | "realloc_internal" 38 38 | "calloc_internal" 39 39 | "free_internal" => hint( 40 - "did you forget the `esp-alloc` dependency or didn't enable the `compat` feature on it?" 40 + "did you forget the `esp-alloc` dependency or didn't enable the `compat` feature on it?", 41 41 ), 42 42 _ => (), 43 43 }, ··· 169 169 ]; 170 170 cps.extend_from_slice(punctuation); 171 171 172 - // Currency and math 173 - let symbols: &[u32] = &[ 172 + // Currency symbols (common ones for e-books) 173 + let currency: &[u32] = &[ 174 + 0x00A2, // ¢ cent sign (already in Latin-1, but explicit) 175 + 0x00A3, // £ pound sign (already in Latin-1) 176 + 0x00A5, // ¥ yen sign (already in Latin-1) 174 177 0x20AC, // € euro sign 178 + 0x20A3, // ₣ french franc 179 + 0x20A4, // ₤ lira sign 180 + 0x20A7, // ₧ peseta sign 181 + 0x20A9, // ₩ won sign 182 + 0x20B9, // ₹ indian rupee 183 + 0x20BD, // ₽ ruble sign 184 + ]; 185 + cps.extend_from_slice(currency); 186 + 187 + // Math symbols (common in books) 188 + let math: &[u32] = &[ 189 + 0x00B1, // ± plus-minus (already in Latin-1) 190 + 0x00D7, // × multiplication (already in Latin-1) 191 + 0x00F7, // ÷ division (already in Latin-1) 192 + 0x2212, // − minus sign 193 + 0x2260, // ≠ not equal to 194 + 0x2264, // ≤ less than or equal to 195 + 0x2265, // ≥ greater than or equal to 196 + 0x2248, // ≈ almost equal to 197 + 0x221E, // ∞ infinity 198 + 0x221A, // √ square root 199 + 0x03C0, // π pi (Greek letter) 200 + 0x00B0, // ° degree sign (already in Latin-1) 201 + 0x2030, // ‰ per mille sign 202 + 0x2070, // ⁰ superscript 0 203 + 0x00B9, // ¹ superscript 1 (already in Latin-1) 204 + 0x00B2, // ² superscript 2 (already in Latin-1) 205 + 0x00B3, // ³ superscript 3 (already in Latin-1) 206 + 0x2074, // ⁴ superscript 4 207 + 0x2075, // ⁵ superscript 5 208 + 0x2076, // ⁶ superscript 6 209 + 0x2077, // ⁷ superscript 7 210 + 0x2078, // ⁸ superscript 8 211 + 0x2079, // ⁹ superscript 9 212 + ]; 213 + cps.extend_from_slice(math); 214 + 215 + // Arrows (useful for navigation hints, diagrams) 216 + let arrows: &[u32] = &[ 217 + 0x2190, // ← leftwards arrow 218 + 0x2191, // ↑ upwards arrow 219 + 0x2192, // → rightwards arrow 220 + 0x2193, // ↓ downwards arrow 221 + 0x2194, // ↔ left right arrow 222 + 0x2195, // ↕ up down arrow 223 + 0x21D0, // ⇐ leftwards double arrow 224 + 0x21D2, // ⇒ rightwards double arrow 225 + 0x21D4, // ⇔ left right double arrow 226 + ]; 227 + cps.extend_from_slice(arrows); 228 + 229 + // Miscellaneous symbols (common in e-books) 230 + let misc: &[u32] = &[ 175 231 0x2122, // ™ trade mark sign 176 - 0x2212, // − minus sign 232 + 0x00A9, // © copyright (already in Latin-1) 233 + 0x00AE, // ® registered (already in Latin-1) 234 + 0x2020, // † dagger 235 + 0x2021, // ‡ double dagger 236 + 0x2023, // ‣ triangular bullet 237 + 0x25A0, // ■ black square 238 + 0x25A1, // □ white square 239 + 0x25CF, // ● black circle 240 + 0x25CB, // ○ white circle 241 + 0x2605, // ★ black star 242 + 0x2606, // ☆ white star 243 + 0x2713, // ✓ check mark 244 + 0x2717, // ✗ ballot x 245 + 0x00A7, // § section sign (already in Latin-1) 246 + 0x00B6, // ¶ pilcrow / paragraph sign (already in Latin-1) 177 247 ]; 178 - cps.extend_from_slice(symbols); 248 + cps.extend_from_slice(misc); 249 + 250 + // Additional Latin Extended-B for more complete European language support 251 + let latin_ext_b: &[u32] = &[ 252 + 0x0180, // ƀ b with stroke (Croatian) 253 + 0x0192, // ƒ f with hook (Dutch florin) 254 + 0x01A0, 0x01A1, // Ơơ O with horn (Vietnamese) 255 + 0x01AF, 0x01B0, // Ưư U with horn (Vietnamese) 256 + 0x01CD, 0x01CE, // Ǎǎ A with caron 257 + 0x01CF, 0x01D0, // Ǐǐ I with caron 258 + 0x01D1, 0x01D2, // Ǒǒ O with caron 259 + 0x01D3, 0x01D4, // Ǔǔ U with caron 260 + 0x0218, 0x0219, // Șș S with comma below (Romanian) 261 + 0x021A, 0x021B, // Țț T with comma below (Romanian) 262 + ]; 263 + cps.extend_from_slice(latin_ext_b); 179 264 180 265 cps.sort(); 181 266 cps.dedup();
+48 -11
kernel/src/board/action.rs
··· 43 43 } 44 44 } 45 45 46 - // fixed portrait one-handed layout 46 + // portrait one-handed layout with optional button swap 47 + // 48 + // default layout (right-handed): 49 + // bottom row: Back Confirm(=Select) Left(=PrevJump) Right(=NextJump) 50 + // side: VolUp(=Prev) VolDown(=Next) 51 + // 52 + // swapped layout (left-handed): 53 + // bottom row: Left(=PrevJump) Right(=NextJump) Back Confirm(=Select) 54 + // this swaps the *physical roles* of Back<->Left and Confirm<->Right 55 + // so the spatial position of Back/OK moves to the right side of the 56 + // device where the left hand naturally rests. 57 + // volume buttons are NOT swapped (up=prev, down=next always). 47 58 #[derive(Default)] 48 - pub struct ButtonMapper; 59 + pub struct ButtonMapper { 60 + swap_buttons: bool, 61 + } 49 62 50 63 impl ButtonMapper { 51 64 pub const fn new() -> Self { 52 - Self 65 + Self { 66 + swap_buttons: false, 67 + } 68 + } 69 + 70 + pub fn set_swap(&mut self, swap: bool) { 71 + self.swap_buttons = swap; 72 + } 73 + 74 + pub fn is_swapped(&self) -> bool { 75 + self.swap_buttons 53 76 } 54 77 55 78 pub fn map_button(&self, button: Button) -> Action { 56 - match button { 57 - Button::VolDown => Action::Next, 58 - Button::VolUp => Action::Prev, 59 - Button::Right => Action::NextJump, 60 - Button::Left => Action::PrevJump, 61 - Button::Confirm => Action::Select, 62 - Button::Back => Action::Back, 63 - Button::Power => Action::Menu, 79 + if self.swap_buttons { 80 + // swapped: Back<->Left, Confirm<->Right 81 + match button { 82 + Button::VolDown => Action::Next, 83 + Button::VolUp => Action::Prev, 84 + Button::Right => Action::Select, // was NextJump 85 + Button::Left => Action::Back, // was PrevJump 86 + Button::Confirm => Action::NextJump, // was Select 87 + Button::Back => Action::PrevJump, // was Back 88 + Button::Power => Action::Menu, 89 + } 90 + } else { 91 + // default right-handed layout 92 + match button { 93 + Button::VolDown => Action::Next, 94 + Button::VolUp => Action::Prev, 95 + Button::Right => Action::NextJump, 96 + Button::Left => Action::PrevJump, 97 + Button::Confirm => Action::Select, 98 + Button::Back => Action::Back, 99 + Button::Power => Action::Menu, 100 + } 64 101 } 65 102 } 66 103
+1 -1
kernel/src/board/mod.rs
··· 14 14 15 15 // logical screen size (portrait mode via 270-degree rotation of 800x480 panel) 16 16 pub const SCREEN_W: u16 = HEIGHT; // 480 17 - pub const SCREEN_H: u16 = WIDTH; // 800 17 + pub const SCREEN_H: u16 = WIDTH; // 800 18 18 19 19 use core::cell::RefCell; 20 20
+1 -1
kernel/src/drivers/input.rs
··· 4 4 5 5 use esp_hal::time::{Duration, Instant}; 6 6 7 - use crate::board::button::{decode_ladder, Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS}; 8 7 use crate::board::InputHw; 8 + use crate::board::button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 9 9 use crate::kernel::timing; 10 10 11 11 macro_rules! read_averaged {
+3 -3
kernel/src/drivers/ssd1677.rs
··· 147 147 } 148 148 } 149 149 150 - /// write BW RAM with content, RED RAM with inverted content 150 + // write BW RAM with content, RED RAM with inverted content 151 151 #[allow(clippy::too_many_arguments)] 152 152 fn write_region_strips_bw_inv_red<F>( 153 153 &mut self, ··· 355 355 let _ = self.spi.write(data); 356 356 } 357 357 358 - /// Send data with each byte inverted, re-applying edge masks. 359 - /// Uses a small batch buffer to amortize SPI call overhead. 358 + // send data with each byte inverted, re-applying edge masks 359 + // uses a small batch buffer to amortize SPI call overhead 360 360 fn send_data_inverted(&mut self, data: &[u8], left_mask: u8, right_mask: u8, row_bytes: usize) { 361 361 const BATCH_SIZE: usize = 64; 362 362 let mut batch = [0u8; BATCH_SIZE];
+2 -2
kernel/src/drivers/strip.rs
··· 3 3 // widgets draw to logical coords, clipped here 4 4 5 5 use embedded_graphics_core::{ 6 + Pixel, 6 7 draw_target::DrawTarget, 7 8 geometry::{OriginDimensions, Size}, 8 9 pixelcolor::BinaryColor, 9 10 primitives::Rectangle, 10 - Pixel, 11 11 }; 12 12 13 - use super::ssd1677::{Rotation, HEIGHT, WIDTH}; 13 + use super::ssd1677::{HEIGHT, Rotation, WIDTH}; 14 14 use crate::ui::Region; 15 15 16 16 pub const STRIP_ROWS: u16 = 40;
+11 -7
kernel/src/kernel/app.rs
··· 194 194 use super::timing; 195 195 self.request_partial_redraw(region); 196 196 if !self.immediate && self.coalesce_until.is_none() { 197 - self.coalesce_until = 198 - Some(Instant::now() + embassy_time::Duration::from_millis(timing::COALESCE_WINDOW_MS)); 197 + self.coalesce_until = Some( 198 + Instant::now() + embassy_time::Duration::from_millis(timing::COALESCE_WINDOW_MS), 199 + ); 199 200 } 200 201 } 201 202 ··· 361 362 self.stack[index] 362 363 } 363 364 364 - /// Check if an app ID is anywhere in the stack 365 + // check if an app ID is anywhere in the stack 365 366 pub fn contains(&self, id: Id) -> bool { 366 367 self.stack[..self.depth].iter().any(|&i| i == id) 367 368 } 368 369 369 - /// Restore stack from saved session data 370 - /// 371 - /// The `convert` function maps u8 values to Id. 370 + // restore stack from saved session data 371 + // the `convert` function maps u8 values to Id. 372 372 pub fn restore_stack<F>(&mut self, depth: usize, stack: &[u8], convert: F) 373 373 where 374 374 F: Fn(u8) -> Id, ··· 479 479 // collect_session writes app state to the provided RtcSession struct 480 480 // apply_session restores app state from RtcSession, returns true if successful 481 481 fn collect_session(&self, session: &mut super::rtc_session::RtcSession); 482 - fn apply_session(&mut self, session: &super::rtc_session::RtcSession, k: &mut KernelHandle<'_>) -> bool; 482 + fn apply_session( 483 + &mut self, 484 + session: &super::rtc_session::RtcSession, 485 + k: &mut KernelHandle<'_>, 486 + ) -> bool; 483 487 484 488 // true when the active app wants to take over the main loop 485 489 // (e.g. wifi upload mode bypasses the normal event dispatch)
+89 -4
kernel/src/kernel/config.rs
··· 29 29 // default font size index (0=XSmall, 1=Small, 2=Medium, 3=Large, 4=XLarge) 30 30 pub const DEFAULT_FONT_SIZE_IDX: u8 = 2; 31 31 32 + // reading themes: named presets for margins, spacing, and overall feel. 33 + // each theme bundles margin_h, margin_v, line_spacing_pct into one 34 + // user-friendly selection instead of exposing raw pixel values. 35 + // 36 + // theme index is stored as a single u8 in SETTINGS.TXT: 37 + // 0 = Compact – narrow margins, tight spacing, max content 38 + // 1 = Default – balanced for most books 39 + // 2 = Relaxed – wider margins, looser spacing, easier on the eyes 40 + // 3 = Spacious – large margins, generous spacing, paperback feel 41 + 42 + pub const NUM_READING_THEMES: u8 = 4; 43 + pub const DEFAULT_READING_THEME: u8 = 1; 44 + 45 + #[derive(Clone, Copy)] 46 + pub struct ReadingTheme { 47 + pub name: &'static str, 48 + pub margin_h: u16, // horizontal margin in pixels 49 + pub margin_v: u16, // vertical margin (top offset) in pixels 50 + pub line_spacing_pct: u16, // line spacing as percentage (100 = font native) 51 + } 52 + 53 + pub const READING_THEMES: [ReadingTheme; NUM_READING_THEMES as usize] = [ 54 + ReadingTheme { 55 + name: "Compact", 56 + margin_h: 8, 57 + margin_v: 0, 58 + line_spacing_pct: 100, 59 + }, 60 + ReadingTheme { 61 + name: "Default", 62 + margin_h: 16, 63 + margin_v: 4, 64 + line_spacing_pct: 120, 65 + }, 66 + ReadingTheme { 67 + name: "Relaxed", 68 + margin_h: 24, 69 + margin_v: 8, 70 + line_spacing_pct: 140, 71 + }, 72 + ReadingTheme { 73 + name: "Spacious", 74 + margin_h: 40, 75 + margin_v: 12, 76 + line_spacing_pct: 160, 77 + }, 78 + ]; 79 + 80 + // look up the active reading theme by index; falls back to Default 81 + pub fn reading_theme(idx: u8) -> &'static ReadingTheme { 82 + let i = (idx as usize).min(READING_THEMES.len() - 1); 83 + &READING_THEMES[i] 84 + } 85 + 32 86 #[derive(Clone, Copy)] 33 87 pub struct SystemSettings { 34 - pub sleep_timeout: u16, // minutes idle before sleep; 0 = never 35 - pub ghost_clear_every: u8, // partial refreshes before forced full GC 36 - pub book_font_size_idx: u8, // 0 = Small, 1 = Medium, 2 = Large 37 - pub ui_font_size_idx: u8, // 0 = Small, 1 = Medium, 2 = Large 88 + // power settings 89 + pub sleep_timeout: u16, // minutes idle before sleep; 0 = never 90 + pub ghost_clear_every: u8, // partial refreshes before forced full GC 91 + 92 + // font settings 93 + pub book_font_size_idx: u8, // 0 = XSmall, 1 = Small, 2 = Medium, 3 = Large, 4 = XLarge 94 + pub ui_font_size_idx: u8, // 0 = XSmall, 1 = Small, 2 = Medium, 3 = Large, 4 = XLarge 95 + 96 + // reading settings 97 + pub reading_theme: u8, // index into READING_THEMES 98 + 99 + // control settings 100 + pub swap_buttons: bool, // swap Back/Select with Left/Right physical buttons 38 101 } 39 102 40 103 impl Default for SystemSettings { ··· 50 113 ghost_clear_every: DEFAULT_GHOST_CLEAR, 51 114 book_font_size_idx: DEFAULT_FONT_SIZE_IDX, 52 115 ui_font_size_idx: DEFAULT_FONT_SIZE_IDX, 116 + reading_theme: DEFAULT_READING_THEME, 117 + swap_buttons: false, 53 118 } 54 119 } 55 120 ··· 64 129 .clamp(MIN_GHOST_CLEAR, MAX_GHOST_CLEAR); 65 130 self.book_font_size_idx = self.book_font_size_idx.min(max_font); 66 131 self.ui_font_size_idx = self.ui_font_size_idx.min(max_font); 132 + self.reading_theme = self.reading_theme.min(NUM_READING_THEMES - 1); 67 133 } 68 134 69 135 // reasonable default - override via sanitize_with_max_font ··· 162 228 if let Some(v) = parse_u16(val) { 163 229 s.ui_font_size_idx = v as u8; 164 230 } 231 + } 232 + b"reading_theme" => { 233 + if let Some(v) = parse_u16(val) { 234 + s.reading_theme = v as u8; 235 + } 236 + } 237 + b"swap_buttons" => { 238 + s.swap_buttons = val == b"1" || val == b"true"; 165 239 } 166 240 b"wifi_ssid" => w.set_ssid(val), 167 241 b"wifi_pass" => w.set_pass(val), ··· 234 308 let mut wr = TxtWriter::new(buf); 235 309 wr.put(b"# pulp-os settings\n"); 236 310 wr.put(b"# lines starting with # are ignored\n\n"); 311 + 312 + wr.put(b"# power settings\n"); 237 313 wr.kv_num(b"sleep_timeout", s.sleep_timeout); 238 314 wr.kv_num(b"ghost_clear", s.ghost_clear_every as u16); 315 + 316 + wr.put(b"\n# font settings\n"); 239 317 wr.kv_num(b"book_font", s.book_font_size_idx as u16); 240 318 wr.kv_num(b"ui_font", s.ui_font_size_idx as u16); 319 + 320 + wr.put(b"\n# reading settings (0=Compact, 1=Default, 2=Relaxed, 3=Spacious)\n"); 321 + wr.kv_num(b"reading_theme", s.reading_theme as u16); 322 + 323 + wr.put(b"\n# control settings\n"); 324 + wr.kv_num(b"swap_buttons", if s.swap_buttons { 1 } else { 0 }); 325 + 241 326 wr.put(b"\n# wifi credentials for upload mode\n"); 242 327 wr.kv_str(b"wifi_ssid", &w.ssid[..w.ssid_len as usize]); 243 328 wr.kv_str(b"wifi_pass", &w.pass[..w.pass_len as usize]);
+4 -1
kernel/src/kernel/scheduler.rs
··· 31 31 32 32 #[inline] 33 33 fn is_power_event(ev: Event) -> bool { 34 - matches!(ev, Event::Press(Button::Power) | Event::Release(Button::Power)) 34 + matches!( 35 + ev, 36 + Event::Press(Button::Power) | Event::Release(Button::Power) 37 + ) 35 38 } 36 39 37 40 impl super::Kernel {
+3 -3
kernel/src/ui/mod.rs
··· 12 12 pub use layout::{ 13 13 CONTENT_TOP, FULL_CONTENT_W, HEADER_W, LARGE_MARGIN, SECTION_GAP, TITLE_Y, TITLE_Y_OFFSET, 14 14 }; 15 - pub use stack_fmt::{stack_fmt, StackFmt}; 16 - pub use statusbar::{free_stack_bytes, paint_stack, stack_high_water_mark, BAR_HEIGHT}; 15 + pub use stack_fmt::{StackFmt, stack_fmt}; 16 + pub use statusbar::{BAR_HEIGHT, free_stack_bytes, paint_stack, stack_high_water_mark}; 17 17 pub use widget::{ 18 - draw_loading_indicator, draw_progress_bar, wrap_next, wrap_prev, Alignment, Region, 18 + Alignment, Region, draw_loading_indicator, draw_progress_bar, wrap_next, wrap_prev, 19 19 }; 20 20 21 21 pub use crate::board::{SCREEN_H, SCREEN_W};
+3 -11
kernel/src/ui/widget.rs
··· 1 1 // region geometry, alignment helpers, progress bar, loading indicator 2 2 3 3 use embedded_graphics::{ 4 - mono_font::ascii::FONT_9X18, mono_font::MonoTextStyle, pixelcolor::BinaryColor, prelude::*, 4 + mono_font::MonoTextStyle, mono_font::ascii::FONT_9X18, pixelcolor::BinaryColor, prelude::*, 5 5 primitives::PrimitiveStyle, primitives::Rectangle, text::Text, 6 6 }; 7 7 ··· 106 106 if count == 0 { 107 107 return 0; 108 108 } 109 - if current + 1 >= count { 110 - 0 111 - } else { 112 - current + 1 113 - } 109 + if current + 1 >= count { 0 } else { current + 1 } 114 110 } 115 111 116 112 #[inline] ··· 118 114 if count == 0 { 119 115 return 0; 120 116 } 121 - if current == 0 { 122 - count - 1 123 - } else { 124 - current - 1 125 - } 117 + if current == 0 { count - 1 } else { current - 1 } 126 118 } 127 119 128 120 // horizontal progress bar for 1-bit e-paper
+2 -7
src/apps/files.rs
··· 105 105 self.total 106 106 } 107 107 108 - /// Restore files state from RTC session data 109 108 pub fn restore_state(&mut self, scroll: usize, selected: usize, total: usize) { 110 109 self.scroll = scroll; 111 110 self.selected = selected; ··· 344 343 } 345 344 346 345 fn draw(&self, strip: &mut StripBuffer) { 347 - let header_region = Region::new( 348 - LIST_X, 349 - TITLE_Y, 350 - HEADER_W, 351 - self.ui_fonts.heading.line_height, 352 - ); 346 + let header_region = 347 + Region::new(LIST_X, TITLE_Y, HEADER_W, self.ui_fonts.heading.line_height); 353 348 BitmapLabel::new(header_region, "Files", self.ui_fonts.heading) 354 349 .alignment(Alignment::CenterLeft) 355 350 .draw(strip)
+4 -20
src/apps/home.rs
··· 2 2 3 3 use core::fmt::Write as _; 4 4 5 - use embedded_graphics::pixelcolor::BinaryColor; 6 - use embedded_graphics::prelude::*; 7 - use embedded_graphics::primitives::PrimitiveStyle; 8 - 5 + use crate::apps::widgets::selectable_row::draw_selection; 9 6 use crate::apps::{App, AppContext, AppId, RECENT_FILE, Transition}; 10 7 use crate::board::action::{Action, ActionEvent}; 11 8 use crate::board::{SCREEN_H, SCREEN_W}; ··· 126 123 self.bm_scroll 127 124 } 128 125 129 - /// Restore home state from RTC session data 126 + // restore home state from RTC session data 130 127 pub fn restore_state( 131 128 &mut self, 132 129 state_id: u8, ··· 493 490 let baseline = y_top + ascent; 494 491 let selected = idx == self.bm_selected; 495 492 496 - if selected { 497 - embedded_graphics::primitives::Rectangle::new( 498 - Point::new(0, y_top), 499 - Size::new(SCREEN_W as u32, line_h as u32), 500 - ) 501 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 502 - .draw(strip) 503 - .unwrap(); 504 - } 505 - 506 - let fg = if selected { 507 - BinaryColor::Off 508 - } else { 509 - BinaryColor::On 510 - }; 493 + let row_region = Region::new(0, y_top as u16, SCREEN_W, line_h as u16); 494 + let fg = draw_selection(strip, row_region, selected); 511 495 512 496 let mut cx = BM_MARGIN as i32; 513 497
+24 -2
src/apps/manager.rs
··· 160 160 pub fn load_eager_settings(&mut self, k: &mut KernelHandle<'_>) { 161 161 self.settings.load_eager(k); 162 162 self.propagate_fonts(); 163 + self.sync_button_config(); 164 + } 165 + 166 + // sync button mapper and label widget from settings 167 + pub fn sync_button_config(&mut self) { 168 + let swap = self.settings.system_settings().swap_buttons; 169 + self.mapper.set_swap(swap); 170 + if self.bumps.set_swap(swap) { 171 + // labels changed, need to redraw the button bar 172 + self.launcher.ctx.mark_dirty(crate::ui::Region::new( 173 + 0, 174 + crate::board::SCREEN_H - crate::ui::BUTTON_BAR_H, 175 + crate::board::SCREEN_W, 176 + crate::ui::BUTTON_BAR_H, 177 + )); 178 + } 163 179 } 164 180 165 181 pub fn load_home_recent(&mut self, k: &mut KernelHandle<'_>) { ··· 472 488 }); 473 489 } 474 490 } 491 + 492 + // sync button configuration from settings (may have changed) 493 + self.sync_button_config(); 475 494 } 476 495 477 496 pub fn draw(&self, strip: &mut StripBuffer) { ··· 499 518 } 500 519 501 520 pub fn propagate_fonts(&mut self) { 502 - let ui_idx = self.settings.system_settings().ui_font_size_idx; 503 - let book_idx = self.settings.system_settings().book_font_size_idx; 521 + let ss = self.settings.system_settings(); 522 + let ui_idx = ss.ui_font_size_idx; 523 + let book_idx = ss.book_font_size_idx; 524 + let theme_idx = ss.reading_theme; 504 525 505 526 self.home.set_ui_font_size(ui_idx); 506 527 self.files.set_ui_font_size(ui_idx); 507 528 self.settings.set_ui_font_size(ui_idx); 508 529 self.reader.set_book_font_size(book_idx); 530 + self.reader.set_reading_theme(theme_idx); 509 531 510 532 let chrome = fonts::chrome_font(); 511 533 self.reader.set_chrome_font(chrome);
+13 -14
src/apps/reader/images.rs
··· 9 9 use alloc::vec::Vec; 10 10 use core::cell::RefCell; 11 11 12 + use smol_epub::DecodedImage; 12 13 use smol_epub::cache; 13 14 use smol_epub::epub; 14 15 use smol_epub::html_strip::{IMG_REF, MARKER}; 15 16 use smol_epub::zip::{self, ZipIndex}; 16 - use smol_epub::DecodedImage; 17 17 18 18 use crate::error::{Error, ErrorKind}; 19 - use crate::kernel::work_queue; 20 19 use crate::kernel::KernelHandle; 20 + use crate::kernel::work_queue; 21 21 22 - use super::{ 23 - ReaderApp, IMAGE_DISPLAY_H, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, TEXT_AREA_H, TEXT_W, 24 - }; 22 + use super::{IMAGE_DISPLAY_H, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, ReaderApp}; 25 23 26 24 // result of scanning a chapter for the next uncached image 27 25 enum ScanResult { ··· 186 184 } 187 185 188 186 let img_max_h = if self.fullscreen_img { 189 - TEXT_AREA_H 187 + self.text_area_h 190 188 } else { 191 189 IMAGE_DISPLAY_H 192 190 }; 193 191 192 + let img_max_w = self.text_w as u16; 194 193 let do_decode = |k_ref: &mut KernelHandle<'_>| -> Result<DecodedImage, &'static str> { 195 194 let k_cell = RefCell::new(k_ref); 196 195 let read_err = |e: Error| -> &'static str { e.into() }; ··· 204 203 }, 205 204 data_offset, 206 205 entry.uncomp_size, 207 - TEXT_W as u16, 206 + img_max_w, 208 207 img_max_h, 209 208 ) 210 209 } else if is_jpeg { ··· 218 217 data_offset, 219 218 entry.comp_size, 220 219 entry.uncomp_size, 221 - TEXT_W as u16, 220 + img_max_w, 222 221 img_max_h, 223 222 ) 224 223 } else if entry.method == zip::METHOD_STORED { ··· 231 230 }, 232 231 data_offset, 233 232 entry.uncomp_size, 234 - TEXT_W as u16, 233 + img_max_w, 235 234 img_max_h, 236 235 ) 237 236 } else { ··· 244 243 }, 245 244 data_offset, 246 245 entry.comp_size, 247 - TEXT_W as u16, 246 + img_max_w, 248 247 img_max_h, 249 248 ) 250 249 } ··· 421 420 epub_name, 422 421 &entry, 423 422 is_jpeg, 424 - TEXT_W as u16, 425 - TEXT_AREA_H, 423 + self.text_w as u16, 424 + self.text_area_h, 426 425 ) { 427 426 Ok(img) => { 428 427 log::info!( ··· 469 468 path_hash, 470 469 data, 471 470 is_jpeg, 472 - max_w: TEXT_W as u16, 473 - max_h: TEXT_AREA_H, 471 + max_w: self.text_w as u16, 472 + max_h: self.text_area_h, 474 473 }; 475 474 if work_queue::submit(self.epub.work_gen, task) { 476 475 return Ok(ScanResult::Dispatched {
+93 -44
src/apps/reader/mod.rs
··· 36 36 }; 37 37 use smol_epub::zip::{self, ZipIndex}; 38 38 39 + // chrome margin: used for header, status, progress bar, loading indicator. 40 + // this never changes; only the text content area responds to the reading theme. 39 41 pub(super) const MARGIN: u16 = 8; 40 42 41 43 pub(super) const HEADER_Y: u16 = CONTENT_TOP + TITLE_Y_OFFSET - 2; // slightly tighter ··· 78 80 // images <= this size are dispatched to async worker for decoding; 79 81 // images > this size are decoded on main loop via streaming SD reads 80 82 pub(super) const PRECACHE_IMG_MAX: u32 = 30 * 1024; 81 - 82 - pub(super) const READER_PROGRESS_H: u16 = 2; 83 - pub(super) const PROGRESS_Y: u16 = SCREEN_H - READER_PROGRESS_H - 1; 84 - pub(super) const PROGRESS_W: u16 = SCREEN_W - 2 * MARGIN; 85 83 86 84 const POSITION_OVERLAY_W: u16 = 280; 87 85 const POSITION_OVERLAY_H: u16 = 40; ··· 306 304 pub(super) font_ascent: u16, 307 305 pub(super) max_lines: usize, 308 306 307 + // reading theme: runtime layout derived from READING_THEMES 308 + pub(super) text_margin: u16, // horizontal margin for text content (from theme) 309 + pub(super) text_y: u16, // top of text area (TEXT_Y + theme vertical margin) 310 + pub(super) text_w: u32, // text content width (SCREEN_W - 2 * text_margin) 311 + pub(super) text_area_h: u16, // height of text area (SCREEN_H - text_y - bottom_pad) 312 + pub(super) reading_theme_idx: u8, 313 + 309 314 pub(super) book_font_size_idx: u8, 310 315 pub(super) applied_font_idx: u8, 311 316 ··· 343 348 font_ascent: LINE_H, 344 349 max_lines: LINES_PER_PAGE, 345 350 351 + text_margin: MARGIN, 352 + text_y: TEXT_Y, 353 + text_w: TEXT_W, 354 + text_area_h: TEXT_AREA_H, 355 + reading_theme_idx: 0, 356 + 346 357 book_font_size_idx: 0, 347 358 applied_font_idx: 0, 348 359 ··· 358 369 self.book_font_size_idx = idx; 359 370 self.apply_font_metrics(); 360 371 self.rebuild_quick_actions(); 372 + } 373 + 374 + pub fn set_reading_theme(&mut self, idx: u8) { 375 + self.reading_theme_idx = idx; 376 + self.apply_theme_layout(); 377 + self.apply_font_metrics(); 378 + } 379 + 380 + fn apply_theme_layout(&mut self) { 381 + let theme = crate::kernel::config::reading_theme(self.reading_theme_idx); 382 + self.text_margin = theme.margin_h; 383 + self.text_y = TEXT_Y + theme.margin_v; 384 + self.text_w = (SCREEN_W - 2 * self.text_margin) as u32; 385 + self.text_area_h = SCREEN_H.saturating_sub(self.text_y + 4); 361 386 } 362 387 363 388 pub fn set_chrome_font(&mut self, font: &'static BitmapFont) { ··· 464 489 self.font_ascent = LINE_H; 465 490 self.max_lines = LINES_PER_PAGE; 466 491 492 + let theme = crate::kernel::config::reading_theme(self.reading_theme_idx); 493 + let spacing_pct = theme.line_spacing_pct; 494 + 467 495 if fonts::font_data::HAS_REGULAR { 468 496 let fs = fonts::FontSet::for_size(self.book_font_size_idx); 469 - self.font_line_h = fs.line_height(fonts::Style::Regular).max(1); 497 + let native_h = fs.line_height(fonts::Style::Regular).max(1); 498 + // apply line spacing: scale native line height by theme percentage 499 + self.font_line_h = ((native_h as u32 * spacing_pct as u32) / 100).max(1) as u16; 470 500 self.font_ascent = fs.ascent(fonts::Style::Regular); 471 - self.max_lines = ((TEXT_AREA_H / self.font_line_h) as usize).min(LINES_PER_PAGE); 501 + self.max_lines = ((self.text_area_h / self.font_line_h) as usize).min(LINES_PER_PAGE); 472 502 log::info!( 473 - "font: size_idx={} line_h={} ascent={} max_lines={}", 503 + "font: size_idx={} line_h={} (native {} x {}%) ascent={} max_lines={} margin={}", 474 504 self.book_font_size_idx, 475 505 self.font_line_h, 506 + native_h, 507 + spacing_pct, 476 508 self.font_ascent, 477 - self.max_lines 509 + self.max_lines, 510 + self.text_margin, 478 511 ); 479 512 self.fonts = Some(fs); 480 513 } ··· 531 564 self.book_font_size_idx 532 565 } 533 566 534 - /// Restore reader state from RTC session data 535 567 pub fn restore_state( 536 568 &mut self, 537 569 filename: &[u8], ··· 721 753 722 754 self.is_epub = epub::is_epub_filename(self.name()); 723 755 self.rebuild_quick_actions(); 756 + self.apply_theme_layout(); 724 757 self.reset_paging(); 725 758 self.epub.ch_cache = Vec::new(); 726 759 self.file_size = 0; ··· 777 810 work_queue::set_active_generation(self.epub.work_gen); 778 811 } 779 812 813 + // re-derive text area geometry from the (possibly changed) theme 814 + self.apply_theme_layout(); 815 + 780 816 let font_changed = self.book_font_size_idx != self.applied_font_idx; 781 817 self.apply_font_metrics(); 782 818 if font_changed { ··· 1052 1088 self.epub.toc_selected = 0; 1053 1089 self.epub.toc_scroll = 0; 1054 1090 } 1055 - let vis = (TEXT_AREA_H / self.font_line_h) as usize; 1091 + let vis = (self.text_area_h / self.font_line_h) as usize; 1056 1092 if self.epub.toc_selected >= self.epub.toc_scroll + vis { 1057 1093 self.epub.toc_scroll = self.epub.toc_selected + 1 - vis; 1058 1094 } ··· 1067 1103 self.epub.toc_selected -= 1; 1068 1104 } else { 1069 1105 self.epub.toc_selected = len - 1; 1070 - let vis = (TEXT_AREA_H / self.font_line_h) as usize; 1106 + let vis = (self.text_area_h / self.font_line_h) as usize; 1071 1107 if self.epub.toc_selected >= vis { 1072 1108 self.epub.toc_scroll = self.epub.toc_selected + 1 - vis; 1073 1109 } ··· 1165 1201 Transition::None 1166 1202 } 1167 1203 1204 + // LongPress(NextJump): jump to end of current chapter 1205 + ActionEvent::LongPress(Action::NextJump) => { 1206 + if self.state == State::Ready && self.pg.total_pages > 0 { 1207 + self.pg.page = self.pg.total_pages - 1; 1208 + ctx.mark_dirty(PAGE_REGION); 1209 + } 1210 + Transition::None 1211 + } 1212 + 1213 + // LongPress(PrevJump): jump to start of current chapter 1214 + ActionEvent::LongPress(Action::PrevJump) => { 1215 + if self.state == State::Ready { 1216 + self.pg.page = 0; 1217 + ctx.mark_dirty(PAGE_REGION); 1218 + } 1219 + Transition::None 1220 + } 1221 + 1222 + // LongPress(Select): reserved for bookmark toggle (Phase 6) 1223 + // ActionEvent::LongPress(Action::Select) => { ... } 1168 1224 _ => Transition::None, 1169 1225 } 1170 1226 } ··· 1197 1253 for i in 0..self.epub.toc.len() { 1198 1254 if self.epub.toc.entries[i].spine_idx == self.epub.chapter { 1199 1255 self.epub.toc_selected = i; 1200 - let vis = (TEXT_AREA_H / self.font_line_h) as usize; 1256 + let vis = (self.text_area_h / self.font_line_h) as usize; 1201 1257 if self.epub.toc_selected >= vis { 1202 1258 self.epub.toc_scroll = self.epub.toc_selected + 1 - vis; 1203 1259 } ··· 1336 1392 1337 1393 if self.state == State::ShowToc { 1338 1394 let toc_len = self.epub.toc.len(); 1395 + let tx = self.text_margin as i32; 1396 + let ty = self.text_y as i32; 1339 1397 if self.fonts.is_some() { 1340 1398 let font = fonts::body_font(self.book_font_size_idx); 1341 1399 let line_h = font.line_height as i32; 1342 1400 let ascent = font.ascent as i32; 1343 - let vis_max = (TEXT_AREA_H / font.line_height) as usize; 1401 + let vis_max = (self.text_area_h / font.line_height) as usize; 1344 1402 let visible = vis_max.min(toc_len.saturating_sub(self.epub.toc_scroll)); 1345 1403 for i in 0..visible { 1346 1404 let idx = self.epub.toc_scroll + i; 1347 1405 let entry = &self.epub.toc.entries[idx]; 1348 - let y_top = TEXT_Y as i32 + i as i32 * line_h; 1406 + let y_top = ty + i as i32 * line_h; 1349 1407 let baseline = y_top + ascent; 1350 1408 let selected = idx == self.epub.toc_selected; 1351 1409 ··· 1364 1422 } else { 1365 1423 BinaryColor::On 1366 1424 }; 1367 - let mut cx = MARGIN as i32; 1425 + let mut cx = tx; 1368 1426 if entry.spine_idx != 0xFFFF && entry.spine_idx == self.epub.chapter { 1369 1427 cx += font.draw_char_fg(strip, '>', fg, cx, baseline) as i32; 1370 1428 cx += font.draw_char_fg(strip, ' ', fg, cx, baseline) as i32; ··· 1373 1431 } 1374 1432 } else { 1375 1433 let style = MonoTextStyle::new(&FONT_9X18, BinaryColor::On); 1376 - let vis_max = (TEXT_AREA_H / LINE_H) as usize; 1434 + let vis_max = (self.text_area_h / LINE_H) as usize; 1377 1435 let visible = vis_max.min(toc_len.saturating_sub(self.epub.toc_scroll)); 1378 1436 for i in 0..visible { 1379 1437 let idx = self.epub.toc_scroll + i; 1380 1438 let entry = &self.epub.toc.entries[idx]; 1381 - let y = TEXT_Y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 1439 + let y = ty + i as i32 * LINE_H as i32 + LINE_H as i32; 1382 1440 let marker = if idx == self.epub.toc_selected { 1383 1441 "> " 1384 1442 } else { ··· 1387 1445 Text::new(marker, Point::new(0, y), style) 1388 1446 .draw(strip) 1389 1447 .unwrap(); 1390 - Text::new(entry.title_str(), Point::new(MARGIN as i32, y), style) 1448 + Text::new(entry.title_str(), Point::new(tx, y), style) 1391 1449 .draw(strip) 1392 1450 .unwrap(); 1393 1451 } ··· 1402 1460 // fullscreen image: centre in text area, skip normal line layout 1403 1461 if self.fullscreen_img { 1404 1462 if let Some(ref img) = self.page_img { 1405 - let img_x = MARGIN as i32 + ((TEXT_W as i32 - img.width as i32) / 2).max(0); 1406 - let img_y = 1407 - TEXT_Y as i32 + ((TEXT_AREA_H as i32 - img.height as i32) / 2).max(0); 1463 + let img_x = self.text_margin as i32 1464 + + ((self.text_w as i32 - img.width as i32) / 2).max(0); 1465 + let img_y = self.text_y as i32 1466 + + ((self.text_area_h as i32 - img.height as i32) / 2).max(0); 1408 1467 strip.blit_1bpp( 1409 1468 &img.data, 1410 1469 0, ··· 1423 1482 1424 1483 if span.is_image() { 1425 1484 if span.is_image_origin() && !img_rendered { 1426 - let y_top = TEXT_Y as i32 + i as i32 * line_h; 1485 + let y_top = self.text_y as i32 + i as i32 * line_h; 1427 1486 if let Some(ref img) = self.page_img { 1428 - let img_x = 1429 - MARGIN as i32 + ((TEXT_W as i32 - img.width as i32) / 2).max(0); 1430 - let blit_h = (img.height as usize).min(IMAGE_DISPLAY_H as usize); 1487 + let img_x = self.text_margin as i32 1488 + + ((self.text_w as i32 - img.width as i32) / 2).max(0); 1489 + // clamp to remaining vertical space so images near 1490 + // the bottom of a page are never cut off 1491 + let space_below = 1492 + (self.text_area_h as i32 - i as i32 * line_h).max(0) as usize; 1493 + let blit_h = (img.height as usize).min(space_below); 1431 1494 strip.blit_1bpp( 1432 1495 &img.data, 1433 1496 0, ··· 1445 1508 strip, 1446 1509 "[image]", 1447 1510 fonts::Style::Italic, 1448 - MARGIN as i32, 1511 + self.text_margin as i32, 1449 1512 baseline, 1450 1513 ); 1451 1514 } ··· 1455 1518 1456 1519 let start = span.start as usize; 1457 1520 let end = start + span.len as usize; 1458 - let baseline = TEXT_Y as i32 + i as i32 * line_h + ascent; 1521 + let baseline = self.text_y as i32 + i as i32 * line_h + ascent; 1459 1522 let x_indent = INDENT_PX as i32 * span.indent as i32; 1460 1523 1461 1524 let line = &self.pg.buf[start..end]; 1462 - let mut cx = MARGIN as i32 + x_indent; 1525 + let mut cx = self.text_margin as i32 + x_indent; 1463 1526 let mut sty = span.style(); 1464 1527 let mut j = 0usize; 1465 1528 while j < line.len() { ··· 1503 1566 let start = span.start as usize; 1504 1567 let end = start + span.len as usize; 1505 1568 let text = core::str::from_utf8(&self.pg.buf[start..end]).unwrap_or(""); 1506 - let y = TEXT_Y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 1507 - Text::new(text, Point::new(MARGIN as i32, y), style) 1569 + let y = self.text_y as i32 + i as i32 * LINE_H as i32 + LINE_H as i32; 1570 + Text::new(text, Point::new(self.text_margin as i32, y), style) 1508 1571 .draw(strip) 1509 1572 .unwrap(); 1510 - } 1511 - } 1512 - 1513 - if self.state == State::Ready && (self.file_size > 0 || self.is_epub) { 1514 - let pct = self.progress_pct() as u32; 1515 - let filled_w = (PROGRESS_W as u32 * pct / 100).min(PROGRESS_W as u32); 1516 - if filled_w > 0 { 1517 - Rectangle::new( 1518 - Point::new(MARGIN as i32, PROGRESS_Y as i32), 1519 - Size::new(filled_w, READER_PROGRESS_H as u32), 1520 - ) 1521 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 1522 - .draw(strip) 1523 - .unwrap(); 1524 1573 } 1525 1574 } 1526 1575
+5 -4
src/apps/reader/paging.rs
··· 10 10 use crate::kernel::KernelHandle; 11 11 12 12 use super::{ 13 - decode_utf8_char, LineSpan, ReaderApp, State, IMAGE_DISPLAY_H, INDENT_PX, LINES_PER_PAGE, 14 - MAX_PAGES, NO_PREFETCH, PAGE_BUF, TEXT_W, 13 + IMAGE_DISPLAY_H, INDENT_PX, LINES_PER_PAGE, LineSpan, MAX_PAGES, NO_PREFETCH, PAGE_BUF, 14 + ReaderApp, State, decode_utf8_char, 15 15 }; 16 16 17 17 impl ReaderApp { ··· 25 25 &fs, 26 26 &mut self.pg.lines, 27 27 self.max_lines, 28 - TEXT_W, 28 + self.text_w, 29 29 ); 30 30 self.pg.line_count = count; 31 31 c ··· 436 436 } 437 437 438 438 let line_h = fonts.line_height(fonts::Style::Regular); 439 - let img_lines = (IMAGE_DISPLAY_H / line_h).max(1) as usize; 439 + // ceiling division: ensure reserved lines fully cover IMAGE_DISPLAY_H 440 + let img_lines = ((IMAGE_DISPLAY_H + line_h - 1) / line_h).max(1) as usize; 440 441 441 442 if line_count < max_l { 442 443 lines[line_count] = LineSpan {
+143 -29
src/apps/settings.rs
··· 1 1 // settings app UI; configuration types live in kernel::config 2 + // 3 + // settings items (6 total, all fit on one screen at default font): 4 + // 0: Sleep After – power management 5 + // 1: Ghost Clear – e-paper refresh interval 6 + // 2: Book Font – reading font size 7 + // 3: UI Font – chrome font size 8 + // 4: Reading Theme – Compact / Default / Relaxed / Spacious 9 + // 5: Swap Buttons – swap Back/OK with Left/Right for left-handed use 10 + 2 11 use core::fmt::Write as _; 3 12 4 13 use crate::apps::{App, AppContext, AppId, Transition}; ··· 10 19 use crate::kernel::KernelHandle; 11 20 use crate::kernel::config::{ 12 21 self, GHOST_CLEAR_STEP, MAX_GHOST_CLEAR, MAX_SLEEP_TIMEOUT, MIN_GHOST_CLEAR, 13 - SLEEP_TIMEOUT_STEP, SystemSettings, WifiConfig, parse_settings_txt, write_settings_txt, 22 + NUM_READING_THEMES, SLEEP_TIMEOUT_STEP, SystemSettings, WifiConfig, parse_settings_txt, 23 + reading_theme, write_settings_txt, 14 24 }; 15 25 use crate::ui::{ 16 - Alignment, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, LARGE_MARGIN, Region, SECTION_GAP, 17 - StackFmt, TITLE_Y, wrap_next, wrap_prev, 26 + Alignment, BUTTON_BAR_H, BitmapLabel, CONTENT_TOP, FULL_CONTENT_W, LARGE_MARGIN, Region, 27 + SECTION_GAP, StackFmt, TITLE_Y, wrap_next, wrap_prev, 18 28 }; 19 29 30 + // layout constants 20 31 const ROW_H: u16 = 40; 21 32 const ROW_GAP: u16 = 6; 22 33 const ROW_STRIDE: u16 = ROW_H + ROW_GAP; ··· 25 36 const LABEL_W: u16 = 160; 26 37 const COL_GAP: u16 = 8; 27 38 const VALUE_X: u16 = LABEL_X + LABEL_W + COL_GAP; 28 - const VALUE_W: u16 = FULL_CONTENT_W - LABEL_W - COL_GAP; // fills remaining width 39 + const VALUE_W: u16 = FULL_CONTENT_W - LABEL_W - COL_GAP; 29 40 30 - const NUM_ITEMS: usize = 4; 41 + const NUM_ITEMS: usize = 6; 31 42 const HEADING_ITEMS_GAP: u16 = SECTION_GAP; 32 43 33 44 impl Default for SettingsApp { ··· 40 51 settings: SystemSettings, 41 52 wifi: WifiConfig, 42 53 selected: usize, 54 + scroll: usize, 43 55 loaded: bool, 44 56 save_needed: bool, 45 57 ui_fonts: fonts::UiFonts, ··· 53 65 settings: SystemSettings::defaults(), 54 66 wifi: WifiConfig::empty(), 55 67 selected: 0, 68 + scroll: 0, 56 69 loaded: false, 57 70 save_needed: false, 58 71 ui_fonts: uf, ··· 125 138 } 126 139 } 127 140 141 + // visible items: how many rows fit between items_top and BUTTON_BAR_H 142 + fn visible_items(&self) -> usize { 143 + let avail = SCREEN_H.saturating_sub(self.items_top + BUTTON_BAR_H); 144 + let count = (avail / ROW_STRIDE) as usize; 145 + count.max(1).min(NUM_ITEMS) 146 + } 147 + 148 + // item labels and values: 149 + 128 150 fn item_label(i: usize) -> &'static str { 129 151 match i { 130 152 0 => "Sleep After", 131 153 1 => "Ghost Clear", 132 154 2 => "Book Font", 133 155 3 => "UI Font", 156 + 4 => "Theme", 157 + 5 => "Swap Buttons", 134 158 _ => "", 135 159 } 136 160 } ··· 162 186 fonts::font_size_name(self.settings.ui_font_size_idx) 163 187 ); 164 188 } 189 + 4 => { 190 + let theme = reading_theme(self.settings.reading_theme); 191 + let _ = write!(buf, "{}", theme.name); 192 + } 193 + 5 => { 194 + let _ = write!( 195 + buf, 196 + "{}", 197 + if self.settings.swap_buttons { 198 + "Yes" 199 + } else { 200 + "No" 201 + } 202 + ); 203 + } 165 204 _ => {} 166 205 } 167 206 } 207 + 208 + // increment/decrement: 168 209 169 210 fn increment(&mut self) { 170 211 match self.selected { ··· 192 233 self.settings.ui_font_size_idx += 1; 193 234 } 194 235 } 236 + 4 => { 237 + if self.settings.reading_theme < NUM_READING_THEMES - 1 { 238 + self.settings.reading_theme += 1; 239 + } 240 + } 241 + 5 => { 242 + self.settings.swap_buttons = !self.settings.swap_buttons; 243 + } 195 244 _ => return, 196 245 } 197 246 self.save_needed = true; ··· 222 271 self.settings.ui_font_size_idx -= 1; 223 272 } 224 273 } 274 + 4 => { 275 + if self.settings.reading_theme > 0 { 276 + self.settings.reading_theme -= 1; 277 + } 278 + } 279 + 5 => { 280 + self.settings.swap_buttons = !self.settings.swap_buttons; 281 + } 225 282 _ => return, 226 283 } 227 284 self.save_needed = true; 228 285 } 229 286 287 + // scroll management: 288 + 289 + fn scroll_into_view(&mut self) { 290 + let vis = self.visible_items(); 291 + if self.selected < self.scroll { 292 + self.scroll = self.selected; 293 + } else if self.selected >= self.scroll + vis { 294 + self.scroll = self.selected + 1 - vis; 295 + } 296 + } 297 + 298 + // row region helpers (visible_idx = position on screen, 0 = first visible): 299 + 230 300 #[inline] 231 - fn label_region(&self, i: usize) -> Region { 301 + fn label_region(&self, visible_idx: usize) -> Region { 232 302 Region::new( 233 303 LABEL_X, 234 - self.items_top + i as u16 * ROW_STRIDE, 304 + self.items_top + visible_idx as u16 * ROW_STRIDE, 235 305 LABEL_W, 236 306 ROW_H, 237 307 ) 238 308 } 239 309 240 310 #[inline] 241 - fn value_region(&self, i: usize) -> Region { 311 + fn value_region(&self, visible_idx: usize) -> Region { 242 312 Region::new( 243 313 VALUE_X, 244 - self.items_top + i as u16 * ROW_STRIDE, 314 + self.items_top + visible_idx as u16 * ROW_STRIDE, 245 315 VALUE_W, 246 316 ROW_H, 247 317 ) 248 318 } 249 319 250 320 #[inline] 251 - fn row_region(&self, i: usize) -> Region { 321 + fn row_region(&self, visible_idx: usize) -> Region { 252 322 Region::new( 253 323 LABEL_X, 254 - self.items_top + i as u16 * ROW_STRIDE, 324 + self.items_top + visible_idx as u16 * ROW_STRIDE, 255 325 LABEL_W + COL_GAP + VALUE_W, 256 326 ROW_H, 257 327 ) 258 328 } 329 + 330 + fn list_region(&self) -> Region { 331 + let vis = self.visible_items(); 332 + Region::new( 333 + LABEL_X, 334 + self.items_top, 335 + LABEL_W + COL_GAP + VALUE_W, 336 + vis as u16 * ROW_STRIDE, 337 + ) 338 + } 259 339 } 260 340 261 341 impl App<AppId> for SettingsApp { 262 342 fn on_enter(&mut self, ctx: &mut AppContext, _k: &mut KernelHandle<'_>) { 263 343 self.selected = 0; 344 + self.scroll = 0; 264 345 self.save_needed = false; 265 346 ctx.mark_dirty(Region::new( 266 347 0, ··· 271 352 } 272 353 273 354 fn on_event(&mut self, event: ActionEvent, ctx: &mut AppContext) -> Transition { 355 + let vis = self.visible_items(); 356 + 274 357 match event { 275 358 ActionEvent::Press(Action::Back) => Transition::Pop, 276 359 ActionEvent::LongPress(Action::Back) => Transition::Home, 277 360 278 361 ActionEvent::Press(Action::Next) => { 279 - let old = self.selected; 362 + let old_selected = self.selected; 363 + let old_scroll = self.scroll; 280 364 self.selected = wrap_next(self.selected, NUM_ITEMS); 281 - if self.selected != old { 282 - ctx.mark_dirty(self.row_region(old)); 283 - ctx.mark_dirty(self.row_region(self.selected)); 365 + if self.selected < old_selected { 366 + self.scroll = 0; 367 + } else { 368 + self.scroll_into_view(); 369 + } 370 + if self.scroll != old_scroll { 371 + ctx.mark_dirty(self.list_region()); 372 + } else if self.selected != old_selected { 373 + let old_vis = old_selected - old_scroll; 374 + let new_vis = self.selected - self.scroll; 375 + ctx.mark_dirty(self.row_region(old_vis)); 376 + ctx.mark_dirty(self.row_region(new_vis)); 284 377 } 285 378 Transition::None 286 379 } 287 380 288 381 ActionEvent::Press(Action::Prev) => { 289 - let old = self.selected; 382 + let old_selected = self.selected; 383 + let old_scroll = self.scroll; 290 384 self.selected = wrap_prev(self.selected, NUM_ITEMS); 291 - if self.selected != old { 292 - ctx.mark_dirty(self.row_region(old)); 293 - ctx.mark_dirty(self.row_region(self.selected)); 385 + if self.selected > old_selected { 386 + self.scroll = NUM_ITEMS.saturating_sub(vis); 387 + } else { 388 + self.scroll_into_view(); 389 + } 390 + if self.scroll != old_scroll { 391 + ctx.mark_dirty(self.list_region()); 392 + } else if self.selected != old_selected { 393 + let old_vis = old_selected - old_scroll; 394 + let new_vis = self.selected - self.scroll; 395 + ctx.mark_dirty(self.row_region(old_vis)); 396 + ctx.mark_dirty(self.row_region(new_vis)); 294 397 } 295 398 Transition::None 296 399 } 297 400 298 401 ActionEvent::Press(Action::NextJump) | ActionEvent::Repeat(Action::NextJump) => { 299 402 self.increment(); 300 - ctx.mark_dirty(self.value_region(self.selected)); 403 + let v = self.selected - self.scroll; 404 + ctx.mark_dirty(self.value_region(v)); 301 405 Transition::None 302 406 } 303 407 304 408 ActionEvent::Press(Action::PrevJump) | ActionEvent::Repeat(Action::PrevJump) => { 305 409 self.decrement(); 306 - ctx.mark_dirty(self.value_region(self.selected)); 410 + let v = self.selected - self.scroll; 411 + ctx.mark_dirty(self.value_region(v)); 307 412 Transition::None 308 413 } 309 414 ··· 326 431 } 327 432 328 433 fn draw(&self, strip: &mut StripBuffer) { 329 - let title_region = 330 - Region::new(LARGE_MARGIN, TITLE_Y, FULL_CONTENT_W, self.ui_fonts.heading.line_height); 434 + // heading 435 + let title_region = Region::new( 436 + LARGE_MARGIN, 437 + TITLE_Y, 438 + FULL_CONTENT_W, 439 + self.ui_fonts.heading.line_height, 440 + ); 331 441 BitmapLabel::new(title_region, "Settings", self.ui_fonts.heading) 332 442 .alignment(Alignment::CenterLeft) 333 443 .draw(strip) ··· 342 452 return; 343 453 } 344 454 455 + // draw visible settings rows 456 + let vis = self.visible_items(); 457 + let visible_count = vis.min(NUM_ITEMS - self.scroll); 345 458 let mut val_buf = StackFmt::<20>::new(); 346 459 347 - for i in 0..NUM_ITEMS { 348 - let selected = i == self.selected; 460 + for vi in 0..visible_count { 461 + let item_idx = self.scroll + vi; 462 + let selected = item_idx == self.selected; 349 463 350 464 BitmapLabel::new( 351 - self.label_region(i), 352 - Self::item_label(i), 465 + self.label_region(vi), 466 + Self::item_label(item_idx), 353 467 self.ui_fonts.body, 354 468 ) 355 469 .alignment(Alignment::CenterLeft) ··· 357 471 .draw(strip) 358 472 .unwrap(); 359 473 360 - self.format_value(i, &mut val_buf); 361 - BitmapLabel::new(self.value_region(i), val_buf.as_str(), self.ui_fonts.body) 474 + self.format_value(item_idx, &mut val_buf); 475 + BitmapLabel::new(self.value_region(vi), val_buf.as_str(), self.ui_fonts.body) 362 476 .alignment(Alignment::Center) 363 477 .inverted(selected) 364 478 .draw(strip)
+2 -2
src/apps/upload.rs
··· 60 60 const MAX_BOUNDARY_LEN: usize = 120; 61 61 const WORK_BUF_SIZE: usize = 2048; 62 62 63 - // TCP buffer sizes 63 + // TCP buffer sizes 64 64 65 65 const TCP_RX_BUF_SIZE: usize = 2048; 66 66 const TCP_TX_BUF_SIZE: usize = 1536; ··· 69 69 70 70 const DIR_LIST_MAX: usize = 64; 71 71 72 - // HTTP timing 72 + // HTTP timing 73 73 const HTTP_TIMEOUT_SECS: u64 = 30; 74 74 const ACCEPT_RETRY_MS: u64 = 200; 75 75
+26 -3
src/apps/widgets/button_feedback.rs
··· 1 + // button label overlay at screen edges 2 + // 3 + // renders action labels ("Back", "OK", "<<", ">>") near the 4 + // physical button positions so users know what each button does. 5 + // uses the shared ButtonMapper so labels update when buttons are 6 + // swapped via settings. 7 + 1 8 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::PrimitiveStyle}; 2 9 3 10 use crate::board::action::{Action, ButtonMapper}; ··· 97 104 } 98 105 99 106 pub struct ButtonFeedback { 100 - mapper: ButtonMapper, 107 + swap: bool, 101 108 font: Option<&'static BitmapFont>, 102 109 } 103 110 ··· 110 117 impl ButtonFeedback { 111 118 pub const fn new() -> Self { 112 119 Self { 113 - mapper: ButtonMapper::new(), 120 + swap: false, 114 121 font: None, 115 122 } 116 123 } ··· 119 126 self.font = Some(font); 120 127 } 121 128 129 + pub fn set_swap(&mut self, swap: bool) -> bool { 130 + if self.swap != swap { 131 + self.swap = swap; 132 + true 133 + } else { 134 + false 135 + } 136 + } 137 + 122 138 pub fn draw(&self, strip: &mut StripBuffer) { 123 139 let font = self.font.unwrap_or(&font_data::REGULAR_BODY_SMALL); 140 + let mapper = if self.swap { 141 + let mut m = ButtonMapper::new(); 142 + m.set_swap(true); 143 + m 144 + } else { 145 + ButtonMapper::new() 146 + }; 124 147 125 148 for def in BUMPS.iter() { 126 149 if def.edge != Edge::Bottom { ··· 138 161 .draw(strip) 139 162 .unwrap(); 140 163 141 - let action = self.mapper.map_button(def.button); 164 + let action = mapper.map_button(def.button); 142 165 let label = action_label(action); 143 166 if label.is_empty() { 144 167 continue;
+100
src/apps/widgets/format.rs
··· 1 + // formatting helpers for common UI patterns 2 + // 3 + // small helpers for frequently-used format strings like position 4 + // indicators (1/10), percentages, etc. reduces code duplication 5 + // and ensures consistent formatting across apps. 6 + 7 + use core::fmt::Write; 8 + 9 + use crate::fonts::bitmap::BitmapFont; 10 + use crate::ui::{Alignment, BitmapDynLabel, Region}; 11 + 12 + // format and draw a position indicator like "3/15" or "3/15 ..." 13 + pub fn draw_position_indicator<const N: usize>( 14 + strip: &mut crate::drivers::strip::StripBuffer, 15 + region: Region, 16 + current: usize, 17 + total: usize, 18 + font: &'static BitmapFont, 19 + suffix: Option<&str>, 20 + ) { 21 + if total == 0 { 22 + return; 23 + } 24 + let mut label = BitmapDynLabel::<N>::new(region, font).alignment(Alignment::CenterRight); 25 + let _ = write!(label, "{}/{}", current, total); 26 + if let Some(s) = suffix { 27 + let _ = write!(label, "{}", s); 28 + } 29 + label.draw(strip).unwrap(); 30 + } 31 + 32 + // format position as "current/total" into a buffer 33 + pub fn fmt_position(buf: &mut [u8], current: usize, total: usize) -> usize { 34 + let mut pos = 0; 35 + 36 + // format current 37 + if current >= 1000 { 38 + buf[pos] = b'0' + ((current / 1000) % 10) as u8; 39 + pos += 1; 40 + } 41 + if current >= 100 { 42 + buf[pos] = b'0' + ((current / 100) % 10) as u8; 43 + pos += 1; 44 + } 45 + if current >= 10 { 46 + buf[pos] = b'0' + ((current / 10) % 10) as u8; 47 + pos += 1; 48 + } 49 + buf[pos] = b'0' + (current % 10) as u8; 50 + pos += 1; 51 + 52 + buf[pos] = b'/'; 53 + pos += 1; 54 + 55 + // format total 56 + if total >= 1000 { 57 + buf[pos] = b'0' + ((total / 1000) % 10) as u8; 58 + pos += 1; 59 + } 60 + if total >= 100 { 61 + buf[pos] = b'0' + ((total / 100) % 10) as u8; 62 + pos += 1; 63 + } 64 + if total >= 10 { 65 + buf[pos] = b'0' + ((total / 10) % 10) as u8; 66 + pos += 1; 67 + } 68 + buf[pos] = b'0' + (total % 10) as u8; 69 + pos += 1; 70 + 71 + pos 72 + } 73 + 74 + // format a percentage (0-100) into a buffer as "NN%" 75 + pub fn fmt_percent(buf: &mut [u8], pct: u8) -> usize { 76 + let pct = pct.min(100); 77 + let mut pos = 0; 78 + 79 + if pct >= 100 { 80 + buf[pos] = b'1'; 81 + pos += 1; 82 + buf[pos] = b'0'; 83 + pos += 1; 84 + buf[pos] = b'0'; 85 + pos += 1; 86 + } else if pct >= 10 { 87 + buf[pos] = b'0' + (pct / 10); 88 + pos += 1; 89 + buf[pos] = b'0' + (pct % 10); 90 + pos += 1; 91 + } else { 92 + buf[pos] = b'0' + pct; 93 + pos += 1; 94 + } 95 + 96 + buf[pos] = b'%'; 97 + pos += 1; 98 + 99 + pos 100 + }
+185
src/apps/widgets/list.rs
··· 1 + // list selection and scrolling helper 2 + // 3 + // consolidates the repeated pattern of managing a selection cursor 4 + // within a scrollable list. handles wrapping, scroll adjustment, 5 + // and visibility calculations. 6 + use crate::ui::{wrap_next, wrap_prev}; 7 + 8 + #[derive(Clone, Copy, Debug, Default)] 9 + pub struct ListSelection { 10 + pub selected: usize, 11 + pub scroll: usize, 12 + pub count: usize, 13 + pub visible: usize, 14 + } 15 + 16 + impl ListSelection { 17 + pub const fn new(count: usize, visible: usize) -> Self { 18 + Self { 19 + selected: 0, 20 + scroll: 0, 21 + count, 22 + visible, 23 + } 24 + } 25 + 26 + pub fn reset(&mut self) { 27 + self.selected = 0; 28 + self.scroll = 0; 29 + } 30 + 31 + pub fn set_count(&mut self, count: usize) { 32 + self.count = count; 33 + if count == 0 { 34 + self.selected = 0; 35 + self.scroll = 0; 36 + } else { 37 + if self.selected >= count { 38 + self.selected = count - 1; 39 + } 40 + self.scroll_into_view(); 41 + } 42 + } 43 + 44 + pub fn set_visible(&mut self, visible: usize) { 45 + self.visible = visible; 46 + self.scroll_into_view(); 47 + } 48 + 49 + pub fn move_next(&mut self) -> bool { 50 + if self.count == 0 { 51 + return false; 52 + } 53 + let old = self.selected; 54 + self.selected = wrap_next(self.selected, self.count); 55 + if self.selected < old { 56 + // wrapped to start 57 + self.scroll = 0; 58 + } else { 59 + self.scroll_into_view(); 60 + } 61 + self.selected != old 62 + } 63 + 64 + pub fn move_prev(&mut self) -> bool { 65 + if self.count == 0 { 66 + return false; 67 + } 68 + let old = self.selected; 69 + self.selected = wrap_prev(self.selected, self.count); 70 + if self.selected > old { 71 + // wrapped to end 72 + self.scroll = self.count.saturating_sub(self.visible); 73 + } else { 74 + self.scroll_into_view(); 75 + } 76 + self.selected != old 77 + } 78 + 79 + pub fn move_down(&mut self) -> bool { 80 + if self.count == 0 || self.selected + 1 >= self.count { 81 + return false; 82 + } 83 + self.selected += 1; 84 + self.scroll_into_view(); 85 + true 86 + } 87 + 88 + pub fn move_up(&mut self) -> bool { 89 + if self.count == 0 || self.selected == 0 { 90 + return false; 91 + } 92 + self.selected -= 1; 93 + self.scroll_into_view(); 94 + true 95 + } 96 + 97 + pub fn page_down(&mut self) -> bool { 98 + if self.count == 0 { 99 + return false; 100 + } 101 + let old = self.selected; 102 + self.selected = (self.selected + self.visible).min(self.count - 1); 103 + self.scroll_into_view(); 104 + self.selected != old 105 + } 106 + 107 + pub fn page_up(&mut self) -> bool { 108 + if self.count == 0 { 109 + return false; 110 + } 111 + let old = self.selected; 112 + self.selected = self.selected.saturating_sub(self.visible); 113 + self.scroll_into_view(); 114 + self.selected != old 115 + } 116 + 117 + pub fn jump_to_start(&mut self) -> bool { 118 + if self.count == 0 || self.selected == 0 { 119 + return false; 120 + } 121 + self.selected = 0; 122 + self.scroll = 0; 123 + true 124 + } 125 + 126 + pub fn jump_to_end(&mut self) -> bool { 127 + if self.count == 0 || self.selected == self.count - 1 { 128 + return false; 129 + } 130 + self.selected = self.count - 1; 131 + self.scroll_into_view(); 132 + true 133 + } 134 + 135 + pub fn select(&mut self, index: usize) -> bool { 136 + if self.count == 0 { 137 + return false; 138 + } 139 + let index = index.min(self.count - 1); 140 + if self.selected == index { 141 + return false; 142 + } 143 + self.selected = index; 144 + self.scroll_into_view(); 145 + true 146 + } 147 + 148 + pub fn scroll_into_view(&mut self) { 149 + if self.count == 0 || self.visible == 0 { 150 + return; 151 + } 152 + 153 + // selected is above visible window 154 + if self.selected < self.scroll { 155 + self.scroll = self.selected; 156 + } 157 + 158 + // selected is below visible window 159 + if self.selected >= self.scroll + self.visible { 160 + self.scroll = self.selected + 1 - self.visible; 161 + } 162 + 163 + // clamp scroll to valid range 164 + let max_scroll = self.count.saturating_sub(self.visible); 165 + if self.scroll > max_scroll { 166 + self.scroll = max_scroll; 167 + } 168 + } 169 + 170 + pub fn visible_count(&self) -> usize { 171 + self.visible.min(self.count.saturating_sub(self.scroll)) 172 + } 173 + 174 + pub fn is_visible(&self, index: usize) -> bool { 175 + index >= self.scroll && index < self.scroll + self.visible 176 + } 177 + 178 + pub fn to_visible_index(&self, index: usize) -> Option<usize> { 179 + if self.is_visible(index) { 180 + Some(index - self.scroll) 181 + } else { 182 + None 183 + } 184 + } 185 + }
+6
src/apps/widgets/mod.rs
··· 6 6 7 7 pub mod bitmap_label; 8 8 pub mod button_feedback; 9 + pub mod format; 10 + pub mod list; 9 11 pub mod quick_menu; 12 + pub mod selectable_row; 10 13 11 14 pub use bitmap_label::{BitmapDynLabel, BitmapLabel}; 12 15 pub use button_feedback::{BUTTON_BAR_H, ButtonFeedback}; 16 + pub use format::{draw_position_indicator, fmt_percent, fmt_position}; 17 + pub use list::ListSelection; 13 18 pub use quick_menu::QuickMenu; 19 + pub use selectable_row::{draw_selection, draw_selection_if_visible, selection_fg};
+4 -17
src/apps/widgets/quick_menu.rs
··· 9 9 use crate::ui::stack_fmt::StackFmt; 10 10 use crate::ui::{Alignment, Region, wrap_next, wrap_prev}; 11 11 12 + use super::selectable_row::draw_selection_if_visible; 13 + 12 14 const OVERLAY_W: u16 = 400; 13 15 const OVERLAY_X: u16 = (SCREEN_W - OVERLAY_W) / 2; 14 16 const OVERLAY_BOTTOM: u16 = 760; ··· 308 310 309 311 for i in 0..self.count { 310 312 let selected = i == self.selected; 313 + let row_region = Region::new(OVERLAY_X, self.item_y(i), OVERLAY_W, ITEM_H); 314 + let fg = draw_selection_if_visible(strip, row_region, selected); 311 315 312 316 let label_region = self.item_label_region(i); 313 317 let value_region = self.item_value_region(i); 314 - 315 - if selected { 316 - let row_region = Region::new(OVERLAY_X, self.item_y(i), OVERLAY_W, ITEM_H); 317 - if row_region.intersects(strip.logical_window()) { 318 - row_region 319 - .to_rect() 320 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 321 - .draw(strip) 322 - .unwrap(); 323 - } 324 - } 325 - 326 - let fg = if selected { 327 - BinaryColor::Off 328 - } else { 329 - BinaryColor::On 330 - }; 331 318 332 319 if label_region.intersects(strip.logical_window()) { 333 320 font.draw_aligned(
+57
src/apps/widgets/selectable_row.rs
··· 1 + // selectable row widget: inverted selection highlight for list items 2 + // 3 + // consolidates the repeated pattern of drawing a selected/unselected row 4 + // with inverted colors. used by home, files, settings, reader TOC, and 5 + // quick menu for consistent selection rendering. 6 + // the widget draws the selection background and returns the foreground 7 + // color to use for text, letting the caller handle the actual content. 8 + 9 + use embedded_graphics::pixelcolor::BinaryColor; 10 + use embedded_graphics::prelude::*; 11 + use embedded_graphics::primitives::PrimitiveStyle; 12 + 13 + use crate::drivers::strip::StripBuffer; 14 + use crate::ui::Region; 15 + 16 + #[inline] 17 + pub fn draw_selection(strip: &mut StripBuffer, region: Region, selected: bool) -> BinaryColor { 18 + if selected { 19 + region 20 + .to_rect() 21 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 22 + .draw(strip) 23 + .unwrap(); 24 + BinaryColor::Off 25 + } else { 26 + BinaryColor::On 27 + } 28 + } 29 + 30 + #[inline] 31 + pub fn draw_selection_if_visible( 32 + strip: &mut StripBuffer, 33 + region: Region, 34 + selected: bool, 35 + ) -> BinaryColor { 36 + if selected && region.intersects(strip.logical_window()) { 37 + region 38 + .to_rect() 39 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 40 + .draw(strip) 41 + .unwrap(); 42 + BinaryColor::Off 43 + } else if selected { 44 + BinaryColor::Off 45 + } else { 46 + BinaryColor::On 47 + } 48 + } 49 + 50 + #[inline] 51 + pub const fn selection_fg(selected: bool) -> BinaryColor { 52 + if selected { 53 + BinaryColor::Off 54 + } else { 55 + BinaryColor::On 56 + } 57 + }
+12 -4
src/bin/main.rs
··· 144 144 145 145 kernel.boot(&mut app_mgr).await; 146 146 147 - spawner.spawn(tasks::input_task(input)).expect("spawn input_task"); 148 - spawner.spawn(tasks::housekeeping_task()).expect("spawn housekeeping_task"); 149 - spawner.spawn(tasks::idle_timeout_task()).expect("spawn idle_timeout_task"); 150 - spawner.spawn(work_queue::worker_task()).expect("spawn worker_task"); 147 + spawner 148 + .spawn(tasks::input_task(input)) 149 + .expect("spawn input_task"); 150 + spawner 151 + .spawn(tasks::housekeeping_task()) 152 + .expect("spawn housekeeping_task"); 153 + spawner 154 + .spawn(tasks::idle_timeout_task()) 155 + .expect("spawn idle_timeout_task"); 156 + spawner 157 + .spawn(work_queue::worker_task()) 158 + .expect("spawn worker_task"); 151 159 info!("kernel ready."); 152 160 153 161 kernel.run(&mut app_mgr).await
+4
src/ui/mod.rs
··· 12 12 pub use crate::apps::widgets::QuickMenu; 13 13 pub use crate::apps::widgets::bitmap_label::{BitmapDynLabel, BitmapLabel}; 14 14 pub use crate::apps::widgets::button_feedback::{BUTTON_BAR_H, ButtonFeedback}; 15 + pub use crate::apps::widgets::list::ListSelection; 15 16 pub use crate::apps::widgets::quick_menu; 17 + pub use crate::apps::widgets::selectable_row::{ 18 + draw_selection, draw_selection_if_visible, selection_fg, 19 + };