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.

add: widgets, ui elements

hansmrtn 3099d4c4 0f283858

+891 -203
-12
Cargo.lock
··· 943 943 "esp-println", 944 944 "log", 945 945 "nb 1.1.0", 946 - "ssd1677", 947 946 "static_cell", 948 947 ] 949 948 ··· 1109 1108 version = "0.2.2" 1110 1109 source = "registry+https://github.com/rust-lang/crates.io-index" 1111 1110 checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" 1112 - 1113 - [[package]] 1114 - name = "ssd1677" 1115 - version = "0.1.0" 1116 - source = "registry+https://github.com/rust-lang/crates.io-index" 1117 - checksum = "2560a0e0d835b69b9666494f3337d723801db172255970d983996bb274d2be13" 1118 - dependencies = [ 1119 - "embedded-graphics-core", 1120 - "embedded-hal 1.0.0", 1121 - "log", 1122 - ] 1123 1111 1124 1112 [[package]] 1125 1113 name = "stable_deref_trait"
-1
Cargo.toml
··· 25 25 critical-section = "1.2.0" 26 26 static_cell = "2.1.1" 27 27 embedded-hal = "1.0.0" 28 - ssd1677 = "0.1.0" 29 28 embedded-hal-bus = "0.3.0" 30 29 nb = "1.1.0" 31 30 embedded-graphics-core = "0.4.1"
+39 -109
src/bin/main.rs
··· 1 1 #![no_std] 2 2 #![no_main] 3 - #![deny( 4 - clippy::mem_forget, 5 - reason = "mem::forget is generally not safe to do with esp_hal types" 6 - )] 7 - #![deny(clippy::large_stack_frames)] 8 3 9 4 use esp_backtrace as _; 10 5 use esp_hal::clock::CpuClock; 11 6 use esp_hal::delay::Delay; 12 7 use log::info; 13 8 14 - use embedded_graphics::{ 15 - mono_font::{MonoTextStyle, ascii::FONT_10X20}, 16 - pixelcolor::BinaryColor, 17 - prelude::*, 18 - primitives::{Circle, Line, PrimitiveStyle, Rectangle}, 19 - text::Text, 20 - }; 9 + use embedded_graphics::mono_font::ascii::FONT_10X20; 21 10 22 11 use pulp_os::board::Board; 23 - use pulp_os::drivers::input::InputDriver; 12 + use pulp_os::drivers::input::{InputDriver, Event}; 13 + use pulp_os::ui::{Region, Widget, Label, Button}; 24 14 25 15 extern crate alloc; 26 16 27 17 esp_bootloader_esp_idf::esp_app_desc!(); 28 18 29 - /// The rectangle that will flash on button events. 30 - /// X must be 8-pixel aligned for efficient partial refresh. 31 - const FLASH_RECT_X: u16 = 24; // Aligned to 8 pixels 32 - const FLASH_RECT_Y: u16 = 70; 33 - const FLASH_RECT_W: u16 = 120; // Multiple of 8 34 - const FLASH_RECT_H: u16 = 60; 19 + // 8px aligned 20 + const TITLE: Region = Region::new(16, 16, 200, 32); 21 + const BTN: Region = Region::new(16, 80, 120, 48); 22 + const STATUS: Region = Region::new(16, 160, 200, 32); 35 23 36 - #[allow( 37 - clippy::large_stack_frames, 38 - reason = "it's not unusual to allocate larger buffers etc. in main" 39 - )] 40 24 #[esp_hal::main] 41 25 fn main() -> ! { 42 26 esp_println::logger::init_logger_from_env(); ··· 44 28 let peripherals = esp_hal::init(config); 45 29 esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 66320); 46 30 47 - info!("booting pulp-os..."); 31 + info!("booting..."); 48 32 49 - // ---- Hardware init ---- 50 33 let mut board = Board::init(peripherals); 51 34 let mut delay = Delay::new(); 52 35 53 - // Initialize display 54 36 board.display.epd.init(&mut delay); 55 - 56 - let sz = board.display.epd.size(); 57 - let w = sz.width as i32; 58 - let h = sz.height as i32; 59 - info!("display: {}x{}", w, h); 60 - 61 - // Fill framebuffer with white (no display update yet) 62 37 board.display.epd.fill_white(); 63 38 64 - // Draw initial content using embedded-graphics 65 - let black_stroke = PrimitiveStyle::with_stroke(BinaryColor::On, 2); 66 - let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On); 67 - 68 - // 1. Border 69 - Rectangle::new(Point::new(2, 2), Size::new(sz.width - 4, sz.height - 4)) 70 - .into_styled(black_stroke) 71 - .draw(&mut board.display.epd) 72 - .unwrap(); 73 - 74 - // 2. Title 75 - Text::new("pulp-os", Point::new(20, 40), text_style) 76 - .draw(&mut board.display.epd) 77 - .unwrap(); 78 - 79 - // 3. Crosshair at center 80 - let cx = w / 2; 81 - let cy = h / 2; 82 - Line::new(Point::new(cx - 20, cy), Point::new(cx + 20, cy)) 83 - .into_styled(black_stroke) 84 - .draw(&mut board.display.epd) 85 - .unwrap(); 86 - Line::new(Point::new(cx, cy - 20), Point::new(cx, cy + 20)) 87 - .into_styled(black_stroke) 88 - .draw(&mut board.display.epd) 89 - .unwrap(); 90 - 91 - // 4. The flash rectangle (starts black) 92 - let mut rect_is_black = true; 93 - Rectangle::new( 94 - Point::new(FLASH_RECT_X as i32, FLASH_RECT_Y as i32), 95 - Size::new(FLASH_RECT_W as u32, FLASH_RECT_H as u32), 96 - ) 97 - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 98 - .draw(&mut board.display.epd) 99 - .unwrap(); 39 + // test widgets 40 + let title = Label::new(TITLE, "pulp-os", &FONT_10X20); 41 + let mut btn = Button::new(BTN, "Press", &FONT_10X20); 42 + let mut status = Label::new(STATUS, "Ready", &FONT_10X20); 100 43 101 - // 5. Circle 102 - Circle::new(Point::new(180, 70), 60) 103 - .into_styled(black_stroke) 104 - .draw(&mut board.display.epd) 105 - .unwrap(); 44 + // init draw 45 + title.draw(&mut board.display.epd).unwrap(); 46 + btn.draw(&mut board.display.epd).unwrap(); 47 + status.draw(&mut board.display.epd).unwrap(); 48 + board.display.epd.refresh_full(&mut delay); 106 49 107 - // 6. Instructions 108 - Text::new("display OK", Point::new(20, h - 20), text_style) 109 - .draw(&mut board.display.epd) 110 - .unwrap(); 50 + info!("UI ready"); 111 51 112 - // Initial full refresh 113 - board.display.epd.refresh_full(&mut delay); 114 - info!("Initial draw complete"); 115 - 116 - // ---- Input polling and event loop ---- 117 52 let mut input = InputDriver::new(board.input); 118 53 119 54 loop { 120 - if let Some(btn) = input.poll() { 121 - info!("[BTN] {:?} pressed", btn); 122 - 123 - // Toggle rectangle 124 - rect_is_black = !rect_is_black; 125 - let color = if rect_is_black { 126 - BinaryColor::On 127 - } else { 128 - BinaryColor::Off 129 - }; 55 + if let Some(event) = input.poll() { 56 + match event { 57 + Event::Press(button) => { 58 + info!("[BTN] Press: {}", button.name()); 130 59 131 - // Draw to framebuffer 132 - Rectangle::new( 133 - Point::new(FLASH_RECT_X as i32, FLASH_RECT_Y as i32), 134 - Size::new(FLASH_RECT_W as u32, FLASH_RECT_H as u32), 135 - ) 136 - .into_styled(PrimitiveStyle::with_fill(color)) 137 - .draw(&mut board.display.epd) 138 - .unwrap(); 60 + btn.set_pressed(true); 61 + btn.draw(&mut board.display.epd).unwrap(); 62 + let r = btn.refresh_bounds(); 63 + board.display.epd.refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 139 64 140 - // Partial refresh - only the rectangle region 141 - board.display.epd.refresh_partial( 142 - FLASH_RECT_X, 143 - FLASH_RECT_Y, 144 - FLASH_RECT_W, 145 - FLASH_RECT_H, 146 - &mut delay, 147 - ); 65 + status.set_text(button.name()); 66 + status.draw(&mut board.display.epd).unwrap(); 67 + let r = status.refresh_bounds(); 68 + board.display.epd.refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 69 + } 70 + Event::Release(_button) => { 71 + btn.set_pressed(false); 72 + btn.draw(&mut board.display.epd).unwrap(); 73 + let r = btn.refresh_bounds(); 74 + board.display.epd.refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 75 + } 76 + _ => {} // Ignore LongPress, Repeat 77 + } 148 78 } 149 79 150 80 delay.delay_millis(10);
+120 -79
src/board/display.rs
··· 3 3 //! Based on GxEPD2_426_GDEQ0426T82.cpp by Jean-Marc Zingg 4 4 //! <https://github.com/ZinggJM/GxEPD2> 5 5 use embedded_graphics_core::{ 6 - Pixel, 7 6 draw_target::DrawTarget, 8 7 geometry::{OriginDimensions, Size}, 9 8 pixelcolor::BinaryColor, 9 + Pixel, 10 10 }; 11 11 use embedded_hal::digital::{InputPin, OutputPin}; 12 12 use embedded_hal::spi::SpiDevice; 13 13 use esp_hal::delay::Delay; 14 14 15 - // Display dimensions 15 + // Display dimensions (physical) 16 16 pub const WIDTH: u16 = 800; 17 17 pub const HEIGHT: u16 = 480; 18 18 pub const FRAMEBUFFER_SIZE: usize = (WIDTH as usize * HEIGHT as usize) / 8; ··· 27 27 const FULL_REFRESH_TIME_MS: u32 = 1600; 28 28 const PARTIAL_REFRESH_TIME_MS: u32 = 600; 29 29 30 + /// Display rotation 31 + #[derive(Clone, Copy, Debug, Default, PartialEq)] 32 + pub enum Rotation { 33 + /// Landscape, 800x480, no rotation 34 + #[default] 35 + Deg0, 36 + /// Portrait, 480x800, rotated 90° clockwise 37 + Deg90, 38 + /// Landscape, 800x480, upside down 39 + Deg180, 40 + /// Portrait, 480x800, rotated 270° clockwise 41 + Deg270, 42 + } 43 + 30 44 // SSD1677 Commands (matching GxEPD2 exactly) 31 45 mod cmd { 32 46 pub const DRIVER_OUTPUT_CONTROL: u8 = 0x01; ··· 39 53 pub const MASTER_ACTIVATION: u8 = 0x20; 40 54 pub const DISPLAY_UPDATE_CONTROL_1: u8 = 0x21; 41 55 pub const DISPLAY_UPDATE_CONTROL_2: u8 = 0x22; 42 - pub const WRITE_RAM_BW: u8 = 0x24; // Current/New buffer 43 - pub const WRITE_RAM_RED: u8 = 0x26; // Previous buffer (for differential) 56 + pub const WRITE_RAM_BW: u8 = 0x24; // Current/New buffer 57 + pub const WRITE_RAM_RED: u8 = 0x26; // Previous buffer (for differential) 44 58 pub const BORDER_WAVEFORM: u8 = 0x3C; 45 59 pub const SET_RAM_X_RANGE: u8 = 0x44; 46 60 pub const SET_RAM_Y_RANGE: u8 = 0x45; ··· 48 62 pub const SET_RAM_Y_COUNTER: u8 = 0x4F; 49 63 } 50 64 51 - pub enum Rotation { 52 - Deg0, 53 - Deg90, 54 - Deg180, 55 - Deg270, 56 - } 57 - 58 65 /// Display driver for SSD1677-based e-paper (GDEQ0426T82) 59 66 pub struct DisplayDriver<SPI, DC, RST, BUSY> { 60 67 spi: SPI, ··· 62 69 rst: RST, 63 70 busy: BUSY, 64 71 framebuffer: [u8; FRAMEBUFFER_SIZE], 72 + rotation: Rotation, 65 73 power_is_on: bool, 66 74 init_done: bool, 67 75 initial_refresh: bool, 68 76 initial_write: bool, 69 - rotation: Rotation, 70 77 } 71 78 72 79 impl<SPI, DC, RST, BUSY, E> DisplayDriver<SPI, DC, RST, BUSY> ··· 84 91 rst, 85 92 busy, 86 93 framebuffer: [0xFF; FRAMEBUFFER_SIZE], // White 94 + rotation: Rotation::Deg270, 87 95 power_is_on: false, 88 96 init_done: false, 89 97 initial_refresh: true, 90 98 initial_write: true, 91 - rotation: Rotation::Deg0, 99 + } 100 + } 101 + 102 + /// Set display rotation 103 + pub fn set_rotation(&mut self, rotation: Rotation) { 104 + self.rotation = rotation; 105 + } 106 + 107 + /// Get current rotation 108 + pub fn rotation(&self) -> Rotation { 109 + self.rotation 110 + } 111 + 112 + /// Get logical display size (accounts for rotation) 113 + pub fn size(&self) -> Size { 114 + match self.rotation { 115 + Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), 116 + Rotation::Deg90 | Rotation::Deg270 => Size::new(HEIGHT as u32, WIDTH as u32), 92 117 } 93 118 } 94 119 ··· 106 131 pub fn init(&mut self, delay: &mut Delay) { 107 132 self.reset(delay); 108 133 self.init_display(delay); 109 - self.set_rotation(Rotation::Deg270); 110 134 } 111 135 112 136 /// Clear the entire screen to white ··· 125 149 self.framebuffer.fill(0x00); 126 150 } 127 151 128 - /// Set a pixel in the framebuffer (0,0 is top-left) 152 + /// Set a pixel in the framebuffer using LOGICAL coordinates 153 + /// Coordinates are transformed based on rotation 129 154 pub fn set_pixel(&mut self, x: u16, y: u16, black: bool) { 130 - // Transform logical (x, y) to physical (px, py) 155 + // Get logical dimensions for bounds check 156 + let (log_w, log_h) = match self.rotation { 157 + Rotation::Deg0 | Rotation::Deg180 => (WIDTH, HEIGHT), 158 + Rotation::Deg90 | Rotation::Deg270 => (HEIGHT, WIDTH), 159 + }; 160 + 161 + if x >= log_w || y >= log_h { 162 + return; 163 + } 164 + 165 + // Transform logical → physical coordinates 131 166 let (px, py) = match self.rotation { 132 - Rotation::Deg0 => { 133 - if x >= WIDTH || y >= HEIGHT { 134 - return; 135 - } 136 - (x, y) 137 - } 138 - Rotation::Deg90 => { 139 - // Logical size: 480×800 (HEIGHT × WIDTH) 140 - if x >= HEIGHT || y >= WIDTH { 141 - return; 142 - } 143 - (WIDTH - 1 - y, x) 144 - } 145 - Rotation::Deg180 => { 146 - if x >= WIDTH || y >= HEIGHT { 147 - return; 148 - } 149 - (WIDTH - 1 - x, HEIGHT - 1 - y) 150 - } 151 - Rotation::Deg270 => { 152 - // Logical size: 480×800 (HEIGHT × WIDTH) 153 - if x >= HEIGHT || y >= WIDTH { 154 - return; 155 - } 156 - (y, HEIGHT - 1 - x) 157 - } 167 + Rotation::Deg0 => (x, y), 168 + Rotation::Deg90 => (WIDTH - 1 - y, x), 169 + Rotation::Deg180 => (WIDTH - 1 - x, HEIGHT - 1 - y), 170 + Rotation::Deg270 => (y, HEIGHT - 1 - x), 158 171 }; 159 172 160 173 let idx = (px as usize / 8) + (py as usize * (WIDTH as usize / 8)); 161 - let bit = 7 - (px % 8); // Use px, not x! 174 + let bit = 7 - (px % 8); 162 175 if black { 163 176 self.framebuffer[idx] &= !(1 << bit); 164 177 } else { ··· 178 191 } 179 192 180 193 // Small delay to ensure display is ready 181 - delay.delay_millis(10); 194 + delay.delay_millis(1); 182 195 183 196 // Write to both buffers for full refresh 184 197 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 185 198 self.write_full_buffer(cmd::WRITE_RAM_RED); // Previous 186 - 199 + 187 200 delay.delay_millis(1); // Yield between large transfers 188 - 201 + 189 202 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 190 - self.write_full_buffer(cmd::WRITE_RAM_BW); // Current 203 + self.write_full_buffer(cmd::WRITE_RAM_BW); // Current 191 204 192 205 self.update_full(delay); 193 206 self.initial_refresh = false; ··· 195 208 } 196 209 197 210 /// Partial screen refresh (fast, for small updates) 211 + /// Takes LOGICAL coordinates (affected by rotation) 198 212 /// Matches GxEPD2's drawImage() sequence exactly: 199 213 /// 1. writeImage to 0x24 (current buffer only) 200 214 /// 2. refresh (partial update compares 0x24 vs 0x26) ··· 209 223 self.init_display(delay); 210 224 } 211 225 226 + // Transform logical region to physical region 227 + let (px, py, pw, ph) = self.transform_region(x, y, w, h); 228 + 212 229 // Ensure x and w are multiples of 8 (byte boundary requirement) 213 - let x = x & !7; 214 - let mut w = w + (x & 7); // Add back what we subtracted from x 215 - if w % 8 > 0 { 216 - w += 8 - (w % 8); 230 + let px_aligned = px & !7; 231 + let extra = px - px_aligned; 232 + let mut pw = pw + extra; 233 + if pw % 8 > 0 { 234 + pw += 8 - (pw % 8); 217 235 } 218 236 219 237 // Clamp to screen bounds 220 - let x = x.min(WIDTH); 221 - let y = y.min(HEIGHT); 222 - let w = w.min(WIDTH - x); 223 - let h = h.min(HEIGHT - y); 238 + let px = px_aligned.min(WIDTH); 239 + let py = py.min(HEIGHT); 240 + let pw = pw.min(WIDTH - px); 241 + let ph = ph.min(HEIGHT - py); 224 242 225 - if w == 0 || h == 0 { 243 + if pw == 0 || ph == 0 { 226 244 return; 227 245 } 228 246 229 247 // Step 1: Write to current buffer (0x24) only 230 - self.write_image_partial(cmd::WRITE_RAM_BW, x, y, w, h); 248 + self.write_image_partial_physical(cmd::WRITE_RAM_BW, px, py, pw, ph); 231 249 232 250 // Step 2: Partial refresh 233 - self.set_partial_ram_area(x, y, w, h); 251 + self.set_partial_ram_area(px, py, pw, ph); 234 252 self.update_partial(delay); 235 253 236 254 // Step 3: Sync buffers - write to BOTH previous (0x26) AND current (0x24) 237 255 // This is writeImageAgain() in GxEPD2 238 - self.write_image_partial(cmd::WRITE_RAM_RED, x, y, w, h); 239 - self.write_image_partial(cmd::WRITE_RAM_BW, x, y, w, h); 256 + self.write_image_partial_physical(cmd::WRITE_RAM_RED, px, py, pw, ph); 257 + self.write_image_partial_physical(cmd::WRITE_RAM_BW, px, py, pw, ph); 258 + } 259 + 260 + /// Transform logical region to physical region based on rotation 261 + fn transform_region(&self, x: u16, y: u16, w: u16, h: u16) -> (u16, u16, u16, u16) { 262 + match self.rotation { 263 + Rotation::Deg0 => (x, y, w, h), 264 + Rotation::Deg90 => { 265 + // Logical (x,y,w,h) in 480x800 → Physical in 800x480 266 + // Logical top-left (x,y) → Physical (WIDTH-1-y, x) 267 + // But we need the physical top-left of the region 268 + (WIDTH - y - h, x, h, w) 269 + } 270 + Rotation::Deg180 => { 271 + (WIDTH - x - w, HEIGHT - y - h, w, h) 272 + } 273 + Rotation::Deg270 => { 274 + (y, HEIGHT - x - w, h, w) 275 + } 276 + } 240 277 } 241 278 242 279 /// Refresh a rectangular window (convenience method) ··· 281 318 // Driver output control 282 319 self.send_command(cmd::DRIVER_OUTPUT_CONTROL); 283 320 self.send_data(&[ 284 - ((HEIGHT - 1) & 0xFF) as u8, // A[7:0] 285 - ((HEIGHT - 1) >> 8) as u8, // A[9:8] 286 - 0x02, // SM = interlaced 321 + ((HEIGHT - 1) & 0xFF) as u8, // A[7:0] 322 + ((HEIGHT - 1) >> 8) as u8, // A[9:8] 323 + 0x02, // SM = interlaced 287 324 ]); 288 325 289 326 // Border waveform ··· 367 404 // Write in normal row order - gate reversal is handled by RAM address setup 368 405 // (Y-decrease mode in _setPartialRamArea), NOT by reversing rows here 369 406 let bytes_per_row = (WIDTH / 8) as usize; 370 - 407 + 371 408 // Use a temporary buffer to avoid borrow checker issues 372 409 let mut row_buf = [0u8; 256]; 373 - 410 + 374 411 for row in 0..HEIGHT as usize { 375 412 let start = row * bytes_per_row; 376 413 // Write row in chunks to avoid issues 377 414 for chunk_start in (0..bytes_per_row).step_by(256) { 378 415 let chunk_end = (chunk_start + 256).min(bytes_per_row); 379 416 let chunk_len = chunk_end - chunk_start; 380 - 417 + 381 418 // Copy to temp buffer 382 - row_buf[..chunk_len] 383 - .copy_from_slice(&self.framebuffer[start + chunk_start..start + chunk_end]); 419 + row_buf[..chunk_len].copy_from_slice( 420 + &self.framebuffer[start + chunk_start..start + chunk_end] 421 + ); 384 422 self.send_data(&row_buf[..chunk_len]); 385 423 } 386 424 } 387 425 } 388 426 389 - fn write_image_partial(&mut self, command: u8, x: u16, y: u16, w: u16, h: u16) { 427 + fn write_image_partial_physical(&mut self, command: u8, x: u16, y: u16, w: u16, h: u16) { 390 428 self.set_partial_ram_area(x, y, w, h); 391 429 self.send_command(command); 392 430 ··· 400 438 401 439 // Write rows in NORMAL order (row 0, 1, 2, ...) 402 440 // Gate reversal is handled by the RAM address setup, not here! 441 + // x, y, w, h are PHYSICAL coordinates 403 442 for row in 0..h as usize { 404 443 let src_row = y as usize + row; 405 444 let src_start = src_row * bytes_per_row + x_byte; 406 - 445 + 407 446 // Copy to temp buffer 408 - row_buf[..window_bytes] 409 - .copy_from_slice(&self.framebuffer[src_start..src_start + window_bytes]); 447 + row_buf[..window_bytes].copy_from_slice( 448 + &self.framebuffer[src_start..src_start + window_bytes] 449 + ); 410 450 self.send_data(&row_buf[..window_bytes]); 411 451 } 412 452 } ··· 462 502 let _ = self.dc.set_high(); 463 503 let _ = self.spi.write(data); 464 504 } 465 - 466 - fn set_rotation(&mut self, rotation: Rotation) { 467 - self.rotation = rotation; 468 - } 469 505 } 470 506 471 - // ========== embedded-graphics integration ========== 507 + // embedded-graphics integration 472 508 473 509 impl<SPI, DC, RST, BUSY, E> OriginDimensions for DisplayDriver<SPI, DC, RST, BUSY> 474 510 where ··· 478 514 BUSY: InputPin, 479 515 { 480 516 fn size(&self) -> Size { 481 - match &self.rotation { 517 + match self.rotation { 482 518 Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), 483 519 Rotation::Deg90 | Rotation::Deg270 => Size::new(HEIGHT as u32, WIDTH as u32), 484 520 } ··· 499 535 where 500 536 I: IntoIterator<Item = Pixel<Self::Color>>, 501 537 { 538 + let size = self.size(); 539 + let log_w = size.width as i32; 540 + let log_h = size.height as i32; 541 + 502 542 for Pixel(coord, color) in pixels { 503 - if coord.x >= 0 && coord.x < WIDTH as i32 && coord.y >= 0 && coord.y < HEIGHT as i32 { 543 + // Bounds check against LOGICAL dimensions 544 + if coord.x >= 0 && coord.x < log_w && coord.y >= 0 && coord.y < log_h { 504 545 self.set_pixel( 505 546 coord.x as u16, 506 547 coord.y as u16,
+1 -2
src/lib.rs
··· 1 1 #![no_std] 2 2 3 - // pub mod input; 4 3 pub mod board; 5 4 pub mod drivers; 6 - // pub mod display; 5 + pub mod ui;
+156
src/ui/button.rs
··· 1 + //! Button widget for interactive UI elements 2 + 3 + use embedded_graphics::{ 4 + prelude::*, 5 + pixelcolor::BinaryColor, 6 + mono_font::MonoFont, 7 + mono_font::MonoTextStyle, 8 + text::Text, 9 + primitives::{Rectangle, PrimitiveStyle, RoundedRectangle, CornerRadii}, 10 + }; 11 + 12 + use super::widget::{Region, Widget, Alignment, WidgetState}; 13 + 14 + /// Button visual style 15 + #[derive(Clone, Copy, Debug, Default, PartialEq)] 16 + pub enum ButtonStyle { 17 + /// Simple rectangle outline 18 + #[default] 19 + Outlined, 20 + /// Filled rectangle 21 + Filled, 22 + /// Rounded corners (specify radius) 23 + Rounded(u32), 24 + } 25 + 26 + /// A clickable button widget 27 + pub struct Button<'a> { 28 + region: Region, 29 + label: &'a str, 30 + font: &'static MonoFont<'static>, 31 + style: ButtonStyle, 32 + pressed: bool, 33 + state: WidgetState, 34 + } 35 + 36 + impl<'a> Button<'a> { 37 + pub fn new(region: Region, label: &'a str, font: &'static MonoFont<'static>) -> Self { 38 + Self { 39 + region, 40 + label, 41 + font, 42 + style: ButtonStyle::Outlined, 43 + pressed: false, 44 + state: WidgetState::Dirty, 45 + } 46 + } 47 + 48 + pub const fn style(mut self, style: ButtonStyle) -> Self { 49 + self.style = style; 50 + self 51 + } 52 + 53 + pub fn set_pressed(&mut self, pressed: bool) { 54 + if self.pressed != pressed { 55 + self.pressed = pressed; 56 + self.state = WidgetState::Dirty; 57 + } 58 + } 59 + 60 + pub fn is_pressed(&self) -> bool { 61 + self.pressed 62 + } 63 + 64 + pub fn toggle(&mut self) { 65 + self.pressed = !self.pressed; 66 + self.state = WidgetState::Dirty; 67 + } 68 + 69 + /// Check if a point is within this button's bounds 70 + pub fn contains(&self, point: Point) -> bool { 71 + self.region.contains(point) 72 + } 73 + 74 + fn text_size(&self) -> Size { 75 + let char_width = self.font.character_size.width + self.font.character_spacing; 76 + let width = self.label.len() as u32 * char_width; 77 + let height = self.font.character_size.height; 78 + Size::new(width, height) 79 + } 80 + } 81 + 82 + impl<'a> Widget for Button<'a> { 83 + fn bounds(&self) -> Region { 84 + self.region 85 + } 86 + 87 + fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 88 + where 89 + D: DrawTarget<Color = BinaryColor>, 90 + { 91 + // When pressed, invert colors 92 + let (bg, fg) = if self.pressed { 93 + (BinaryColor::On, BinaryColor::Off) 94 + } else { 95 + (BinaryColor::Off, BinaryColor::On) 96 + }; 97 + 98 + let rect = self.region.to_rect(); 99 + 100 + // Draw button background/border based on style 101 + match self.style { 102 + ButtonStyle::Outlined => { 103 + // Clear background 104 + rect.into_styled(PrimitiveStyle::with_fill(bg)) 105 + .draw(display)?; 106 + // Draw border 107 + rect.into_styled(PrimitiveStyle::with_stroke(fg, 2)) 108 + .draw(display)?; 109 + } 110 + ButtonStyle::Filled => { 111 + rect.into_styled(PrimitiveStyle::with_fill(fg)) 112 + .draw(display)?; 113 + } 114 + ButtonStyle::Rounded(radius) => { 115 + let rounded = RoundedRectangle::new(rect, CornerRadii::new(Size::new(radius, radius))); 116 + // Clear background 117 + rounded 118 + .into_styled(PrimitiveStyle::with_fill(bg)) 119 + .draw(display)?; 120 + // Draw border 121 + rounded 122 + .into_styled(PrimitiveStyle::with_stroke(fg, 2)) 123 + .draw(display)?; 124 + } 125 + } 126 + 127 + // Calculate centered text position 128 + let text_size = self.text_size(); 129 + let mut pos = Alignment::Center.position(self.region, text_size); 130 + pos.y += self.font.character_size.height as i32; 131 + 132 + // For filled style, text color is inverted 133 + let text_color = match self.style { 134 + ButtonStyle::Filled => bg, 135 + _ => fg, 136 + }; 137 + 138 + // Draw label 139 + let style = MonoTextStyle::new(self.font, text_color); 140 + Text::new(self.label, pos, style).draw(display)?; 141 + 142 + Ok(()) 143 + } 144 + 145 + fn is_dirty(&self) -> bool { 146 + self.state == WidgetState::Dirty 147 + } 148 + 149 + fn mark_clean(&mut self) { 150 + self.state = WidgetState::Clean; 151 + } 152 + 153 + fn mark_dirty(&mut self) { 154 + self.state = WidgetState::Dirty; 155 + } 156 + }
+245
src/ui/label.rs
··· 1 + use embedded_graphics::{ 2 + prelude::*, 3 + pixelcolor::BinaryColor, 4 + mono_font::{MonoFont, MonoTextStyle}, 5 + text::Text, 6 + primitives::PrimitiveStyle, 7 + }; 8 + 9 + use super::widget::{Region, Widget, Alignment, WidgetState}; 10 + 11 + /// A text label widget 12 + /// Automatically handles background clearing on redraw. 13 + pub struct Label<'a> { 14 + region: Region, 15 + text: &'a str, 16 + font: &'static MonoFont<'static>, 17 + alignment: Alignment, 18 + inverted: bool, 19 + state: WidgetState, 20 + } 21 + 22 + impl<'a> Label<'a> { 23 + pub fn new(region: Region, text: &'a str, font: &'static MonoFont<'static>) -> Self { 24 + Self { 25 + region, 26 + text, 27 + font, 28 + alignment: Alignment::CenterLeft, 29 + inverted: false, 30 + state: WidgetState::Dirty, 31 + } 32 + } 33 + 34 + pub const fn alignment(mut self, alignment: Alignment) -> Self { 35 + self.alignment = alignment; 36 + self 37 + } 38 + 39 + pub const fn inverted(mut self, inverted: bool) -> Self { 40 + self.inverted = inverted; 41 + self 42 + } 43 + 44 + pub fn set_text(&mut self, text: &'a str) { 45 + if self.text != text { 46 + self.text = text; 47 + self.state = WidgetState::Dirty; 48 + } 49 + } 50 + 51 + pub fn set_inverted(&mut self, inverted: bool) { 52 + if self.inverted != inverted { 53 + self.inverted = inverted; 54 + self.state = WidgetState::Dirty; 55 + } 56 + } 57 + 58 + /// Calculate text size based on font metrics 59 + fn text_size(&self) -> Size { 60 + let char_width = self.font.character_size.width + self.font.character_spacing; 61 + let width = self.text.len() as u32 * char_width; 62 + let height = self.font.character_size.height; 63 + Size::new(width, height) 64 + } 65 + } 66 + 67 + impl<'a> Widget for Label<'a> { 68 + fn bounds(&self) -> Region { 69 + self.region 70 + } 71 + 72 + fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 73 + where 74 + D: DrawTarget<Color = BinaryColor>, 75 + { 76 + let (bg, fg) = if self.inverted { 77 + (BinaryColor::On, BinaryColor::Off) 78 + } else { 79 + (BinaryColor::Off, BinaryColor::On) 80 + }; 81 + 82 + // Clear background 83 + self.region 84 + .to_rect() 85 + .into_styled(PrimitiveStyle::with_fill(bg)) 86 + .draw(display)?; 87 + 88 + // Calculate text position 89 + let text_size = self.text_size(); 90 + let mut pos = self.alignment.position(self.region, text_size); 91 + 92 + // Adjust for text baseline (embedded-graphics draws from baseline) 93 + pos.y += self.font.character_size.height as i32; 94 + 95 + // Draw text 96 + let style = MonoTextStyle::new(self.font, fg); 97 + Text::new(self.text, pos, style).draw(display)?; 98 + 99 + Ok(()) 100 + } 101 + 102 + fn is_dirty(&self) -> bool { 103 + self.state == WidgetState::Dirty 104 + } 105 + 106 + fn mark_clean(&mut self) { 107 + self.state = WidgetState::Clean; 108 + } 109 + 110 + fn mark_dirty(&mut self) { 111 + self.state = WidgetState::Dirty; 112 + } 113 + } 114 + 115 + /// A label that owns its text (for dynamic content) 116 + pub struct DynamicLabel<const N: usize> { 117 + region: Region, 118 + buffer: [u8; N], 119 + len: usize, 120 + font: &'static MonoFont<'static>, 121 + alignment: Alignment, 122 + inverted: bool, 123 + state: WidgetState, 124 + } 125 + 126 + impl<const N: usize> DynamicLabel<N> { 127 + pub fn new(region: Region, font: &'static MonoFont<'static>) -> Self { 128 + Self { 129 + region, 130 + buffer: [0u8; N], 131 + len: 0, 132 + font, 133 + alignment: Alignment::CenterLeft, 134 + inverted: false, 135 + state: WidgetState::Dirty, 136 + } 137 + } 138 + 139 + pub const fn alignment(mut self, alignment: Alignment) -> Self { 140 + self.alignment = alignment; 141 + self 142 + } 143 + 144 + pub const fn inverted(mut self, inverted: bool) -> Self { 145 + self.inverted = inverted; 146 + self 147 + } 148 + 149 + pub fn set_text(&mut self, text: &str) { 150 + let bytes = text.as_bytes(); 151 + let copy_len = bytes.len().min(N); 152 + self.buffer[..copy_len].copy_from_slice(&bytes[..copy_len]); 153 + self.len = copy_len; 154 + self.state = WidgetState::Dirty; 155 + } 156 + 157 + /// Clear the text buffer 158 + pub fn clear_text(&mut self) { 159 + self.len = 0; 160 + self.state = WidgetState::Dirty; 161 + } 162 + 163 + pub fn text(&self) -> &str { 164 + core::str::from_utf8(&self.buffer[..self.len]).unwrap_or("") 165 + } 166 + 167 + pub fn set_inverted(&mut self, inverted: bool) { 168 + if self.inverted != inverted { 169 + self.inverted = inverted; 170 + self.state = WidgetState::Dirty; 171 + } 172 + } 173 + 174 + fn text_size(&self) -> Size { 175 + let char_width = self.font.character_size.width + self.font.character_spacing; 176 + let width = self.len as u32 * char_width; 177 + let height = self.font.character_size.height; 178 + Size::new(width, height) 179 + } 180 + } 181 + 182 + impl<const N: usize> Widget for DynamicLabel<N> { 183 + fn bounds(&self) -> Region { 184 + self.region 185 + } 186 + 187 + fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 188 + where 189 + D: DrawTarget<Color = BinaryColor>, 190 + { 191 + let (bg, fg) = if self.inverted { 192 + (BinaryColor::On, BinaryColor::Off) 193 + } else { 194 + (BinaryColor::Off, BinaryColor::On) 195 + }; 196 + 197 + // Clear background 198 + self.region 199 + .to_rect() 200 + .into_styled(PrimitiveStyle::with_fill(bg)) 201 + .draw(display)?; 202 + 203 + // Calculate text position 204 + let text_size = self.text_size(); 205 + let mut pos = self.alignment.position(self.region, text_size); 206 + pos.y += self.font.character_size.height as i32; 207 + 208 + // Draw text 209 + let style = MonoTextStyle::new(self.font, fg); 210 + Text::new(self.text(), pos, style).draw(display)?; 211 + 212 + Ok(()) 213 + } 214 + 215 + fn is_dirty(&self) -> bool { 216 + self.state == WidgetState::Dirty 217 + } 218 + 219 + fn mark_clean(&mut self) { 220 + self.state = WidgetState::Clean; 221 + } 222 + 223 + fn mark_dirty(&mut self) { 224 + self.state = WidgetState::Dirty; 225 + } 226 + } 227 + 228 + /// Write formatted text to a DynamicLabel 229 + /// 230 + /// Usage: 231 + /// ```ignore 232 + /// use core::fmt::Write; 233 + /// write!(label, "Count: {}", 42).ok(); 234 + /// ``` 235 + impl<const N: usize> core::fmt::Write for DynamicLabel<N> { 236 + fn write_str(&mut self, s: &str) -> core::fmt::Result { 237 + let bytes = s.as_bytes(); 238 + let available = N - self.len; 239 + let copy_len = bytes.len().min(available); 240 + self.buffer[self.len..self.len + copy_len].copy_from_slice(&bytes[..copy_len]); 241 + self.len += copy_len; 242 + self.state = WidgetState::Dirty; 243 + Ok(()) 244 + } 245 + }
+64
src/ui/mod.rs
··· 1 + //! UI primitives for e-paper displays 2 + //! 3 + //! This module provides rotation-aware widgets that handle their own 4 + //! partial refresh regions automatically. 5 + //! 6 + //! # Example 7 + //! 8 + //! ```ignore 9 + //! use pulp_os::ui::{Region, Widget, Label, Button}; 10 + //! use embedded_graphics::mono_font::ascii::FONT_10X20; 11 + //! 12 + //! // Define regions (8-pixel aligned for partial refresh) 13 + //! const TITLE_REGION: Region = Region::new(16, 8, 200, 32); 14 + //! const BTN_REGION: Region = Region::new(16, 48, 96, 40); 15 + //! 16 + //! // Create widgets 17 + //! let title = Label::new(TITLE_REGION, "Hello!", &FONT_10X20); 18 + //! let mut btn = Button::new(BTN_REGION, "Click", &FONT_10X20); 19 + //! 20 + //! // Draw and refresh 21 + //! title.draw(&mut display).unwrap(); 22 + //! btn.draw(&mut display).unwrap(); 23 + //! display.refresh_full(&mut delay); 24 + //! 25 + //! // Later, update just the button 26 + //! btn.set_pressed(true); 27 + //! btn.draw(&mut display).unwrap(); 28 + //! let r = btn.refresh_bounds(); 29 + //! display.refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 30 + //! ``` 31 + 32 + mod widget; 33 + mod label; 34 + mod button; 35 + // mod progress; 36 + 37 + pub use widget::{Region, Widget, Alignment, WidgetState}; 38 + pub use label::{Label, DynamicLabel}; 39 + pub use button::{Button, ButtonStyle}; 40 + // pub use progress::{ProgressBar, BatteryIndicator, Orientation}; 41 + 42 + use embedded_graphics::{pixelcolor::BinaryColor, prelude::*}; 43 + 44 + /// Extension trait for drawing and refreshing widgets 45 + /// 46 + /// This trait provides convenience methods for displays that support 47 + /// partial refresh. Import it to use `draw_widget` and `refresh_widget`. 48 + pub trait WidgetExt<D> 49 + where 50 + D: DrawTarget<Color = BinaryColor>, 51 + { 52 + /// Draw a widget to the display (does not refresh) 53 + fn draw_widget<W: Widget>(&mut self, widget: &W) -> Result<(), D::Error>; 54 + } 55 + 56 + impl<D> WidgetExt<D> for D 57 + where 58 + D: DrawTarget<Color = BinaryColor>, 59 + { 60 + fn draw_widget<W: Widget>(&mut self, widget: &W) -> Result<(), D::Error> { 61 + widget.draw(self) 62 + } 63 + } 64 +
+266
src/ui/widget.rs
··· 1 + //! Widgets are self-contained UI elements that know their bounds and can 2 + //! draw themselves. They work in logical coordinates (rotation-aware). 3 + use embedded_graphics::{ 4 + prelude::*, 5 + pixelcolor::BinaryColor, 6 + primitives::{Rectangle, PrimitiveStyle}, 7 + }; 8 + 9 + /// A rectangular region in logical coordinates. 10 + #[derive(Clone, Copy, Debug, Default)] 11 + pub struct Region { 12 + pub x: u16, 13 + pub y: u16, 14 + pub w: u16, 15 + pub h: u16, 16 + } 17 + 18 + impl Region { 19 + /// Create a new region. X and W should be 8-pixel aligned for best performance. 20 + pub const fn new(x: u16, y: u16, w: u16, h: u16) -> Self { 21 + Self { x, y, w, h } 22 + } 23 + 24 + /// Create from embedded-graphics Rectangle 25 + pub fn from_rect(rect: Rectangle) -> Self { 26 + Self { 27 + x: rect.top_left.x.max(0) as u16, 28 + y: rect.top_left.y.max(0) as u16, 29 + w: rect.size.width as u16, 30 + h: rect.size.height as u16, 31 + } 32 + } 33 + 34 + /// Convert to embedded-graphics Rectangle 35 + pub fn to_rect(self) -> Rectangle { 36 + Rectangle::new( 37 + Point::new(self.x as i32, self.y as i32), 38 + Size::new(self.w as u32, self.h as u32), 39 + ) 40 + } 41 + 42 + pub fn top_left(self) -> Point { 43 + Point::new(self.x as i32, self.y as i32) 44 + } 45 + 46 + pub fn center(self) -> Point { 47 + Point::new( 48 + (self.x + self.w / 2) as i32, 49 + (self.y + self.h / 2) as i32, 50 + ) 51 + } 52 + 53 + /// Align X to 8-pixel boundary (required for partial refresh) 54 + pub fn align8(self) -> Self { 55 + let aligned_x = (self.x / 8) * 8; 56 + let extra = self.x - aligned_x; 57 + Self { 58 + x: aligned_x, 59 + y: self.y, 60 + w: ((self.w + extra + 7) / 8) * 8, // Round up width to compensate 61 + h: self.h, 62 + } 63 + } 64 + 65 + pub fn contains(self, point: Point) -> bool { 66 + point.x >= self.x as i32 67 + && point.x < (self.x + self.w) as i32 68 + && point.y >= self.y as i32 69 + && point.y < (self.y + self.h) as i32 70 + } 71 + 72 + pub fn inset(self, margin: u16) -> Self { 73 + Self { 74 + x: self.x + margin, 75 + y: self.y + margin, 76 + w: self.w.saturating_sub(margin * 2), 77 + h: self.h.saturating_sub(margin * 2), 78 + } 79 + } 80 + 81 + pub fn expand(self, margin: u16) -> Self { 82 + Self { 83 + x: self.x.saturating_sub(margin), 84 + y: self.y.saturating_sub(margin), 85 + w: self.w + margin * 2, 86 + h: self.h + margin * 2, 87 + } 88 + } 89 + } 90 + 91 + /// Text/content alignment within a widget 92 + #[derive(Clone, Copy, Debug, Default, PartialEq)] 93 + pub enum Alignment { 94 + #[default] 95 + TopLeft, 96 + TopCenter, 97 + TopRight, 98 + CenterLeft, 99 + Center, 100 + CenterRight, 101 + BottomLeft, 102 + BottomCenter, 103 + BottomRight, 104 + } 105 + 106 + impl Alignment { 107 + /// Calculate position for content of given size within a region 108 + pub fn position(self, region: Region, content_size: Size) -> Point { 109 + let cw = content_size.width as i32; 110 + let ch = content_size.height as i32; 111 + let rx = region.x as i32; 112 + let ry = region.y as i32; 113 + let rw = region.w as i32; 114 + let rh = region.h as i32; 115 + 116 + match self { 117 + Alignment::TopLeft => Point::new(rx, ry), 118 + Alignment::TopCenter => Point::new(rx + (rw - cw) / 2, ry), 119 + Alignment::TopRight => Point::new(rx + rw - cw, ry), 120 + Alignment::CenterLeft => Point::new(rx, ry + (rh - ch) / 2), 121 + Alignment::Center => Point::new(rx + (rw - cw) / 2, ry + (rh - ch) / 2), 122 + Alignment::CenterRight => Point::new(rx + rw - cw, ry + (rh - ch) / 2), 123 + Alignment::BottomLeft => Point::new(rx, ry + rh - ch), 124 + Alignment::BottomCenter => Point::new(rx + (rw - cw) / 2, ry + rh - ch), 125 + Alignment::BottomRight => Point::new(rx + rw - cw, ry + rh - ch), 126 + } 127 + } 128 + } 129 + 130 + /// Widget state for tracking if redraw is needed 131 + #[derive(Clone, Copy, Debug, Default, PartialEq)] 132 + pub enum WidgetState { 133 + /// Widget needs to be redrawn 134 + #[default] 135 + Dirty, 136 + /// Widget is up to date 137 + Clean, 138 + } 139 + 140 + /// Core widget trait for UI elements 141 + /// 142 + /// Widgets are self-contained UI components that: 143 + /// - Know their bounds (region) 144 + /// - Can draw themselves to any DrawTarget 145 + /// - Track dirty state for efficient updates 146 + pub trait Widget { 147 + /// Get the widget's bounding region (in logical coordinates) 148 + fn bounds(&self) -> Region; 149 + 150 + /// Draw the widget to a display 151 + fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 152 + where 153 + D: DrawTarget<Color = BinaryColor>; 154 + 155 + /// Check if widget needs redraw 156 + fn is_dirty(&self) -> bool { 157 + true // Default: always redraw 158 + } 159 + 160 + /// Mark widget as clean (called after draw) 161 + fn mark_clean(&mut self) { 162 + // Default: no-op 163 + } 164 + 165 + /// Mark widget as needing redraw 166 + fn mark_dirty(&mut self) { 167 + // Default: no-op 168 + } 169 + 170 + /// Clear the widget's region to background color 171 + fn clear<D>(&self, display: &mut D) -> Result<(), D::Error> 172 + where 173 + D: DrawTarget<Color = BinaryColor>, 174 + { 175 + self.bounds() 176 + .to_rect() 177 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off)) 178 + .draw(display) 179 + } 180 + 181 + /// Get the 8-pixel aligned bounds for partial refresh 182 + fn refresh_bounds(&self) -> Region { 183 + self.bounds().align8() 184 + } 185 + } 186 + 187 + pub struct RectWidget { 188 + region: Region, 189 + filled: bool, 190 + inverted: bool, 191 + state: WidgetState, 192 + } 193 + 194 + impl RectWidget { 195 + pub const fn new(region: Region) -> Self { 196 + Self { 197 + region, 198 + filled: true, 199 + inverted: false, 200 + state: WidgetState::Dirty, 201 + } 202 + } 203 + 204 + pub const fn filled(mut self, filled: bool) -> Self { 205 + self.filled = filled; 206 + self 207 + } 208 + 209 + pub const fn inverted(mut self, inverted: bool) -> Self { 210 + self.inverted = inverted; 211 + self 212 + } 213 + 214 + pub fn set_inverted(&mut self, inverted: bool) { 215 + if self.inverted != inverted { 216 + self.inverted = inverted; 217 + self.state = WidgetState::Dirty; 218 + } 219 + } 220 + 221 + pub fn toggle(&mut self) { 222 + self.inverted = !self.inverted; 223 + self.state = WidgetState::Dirty; 224 + } 225 + } 226 + 227 + impl Widget for RectWidget { 228 + fn bounds(&self) -> Region { 229 + self.region 230 + } 231 + 232 + fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 233 + where 234 + D: DrawTarget<Color = BinaryColor>, 235 + { 236 + let color = if self.inverted { 237 + BinaryColor::On 238 + } else { 239 + BinaryColor::Off 240 + }; 241 + 242 + if self.filled { 243 + self.region 244 + .to_rect() 245 + .into_styled(PrimitiveStyle::with_fill(color)) 246 + .draw(display) 247 + } else { 248 + self.region 249 + .to_rect() 250 + .into_styled(PrimitiveStyle::with_stroke(color, 1)) 251 + .draw(display) 252 + } 253 + } 254 + 255 + fn is_dirty(&self) -> bool { 256 + self.state == WidgetState::Dirty 257 + } 258 + 259 + fn mark_clean(&mut self) { 260 + self.state = WidgetState::Clean; 261 + } 262 + 263 + fn mark_dirty(&mut self) { 264 + self.state = WidgetState::Dirty; 265 + } 266 + }