Skip to main content

device_envoy_core/
wasm.rs

1//! Browser implementations for CYD, buttons, flash storage, clocks, DNS, and simulators.
2//!
3//! Enable the `wasm` feature when compiling application code for a browser.
4//!
5//! ## Implementations
6//!
7//! - [`CydWasm`] provides an HTML-canvas display and browser pointer-based touch
8//!   input.
9//! - [`ButtonWasm`] provides browser-controlled button input.
10//! - [`FlashBlockWasm`] provides flash-block storage backed by browser local
11//!   storage.
12//! - [`ClockSyncWasm`] provides browser clock synchronization; see the
13//!   [`clock` module](clock).
14//! - [`DnsSimulatorWasm`] provides deterministic DNS simulation; see the
15//!   [`dns` module](dns).
16//! - [`CydSimulatorWasm`] and [`WifiSimulatorWasm`] provide higher-level browser
17//!   simulators; see the [`simulator` module](simulator).
18//!
19//! The [`cyd_web` module](cyd_web) supplies the browser shell for CYD
20//! applications, and [`next_animation_frame`] provides browser frame pacing.
21
22mod animation_frame;
23pub mod clock;
24pub mod cyd_web;
25pub mod dns;
26pub mod simulator;
27
28use core::{
29    cell::{Cell, RefCell},
30    convert::Infallible,
31    ops::Range,
32};
33use std::{collections::VecDeque, rc::Rc};
34
35use crate::cyd::{
36    Cyd, CydDisplay, CydTouch,
37    backend::{CalibrationConfig, RawTouchEvent},
38    display::{CydFrame, Orientation},
39    touch::{RawPoint, TouchEvent},
40};
41use crate::{
42    button::{__ButtonMonitor, BUTTON_POLL_INTERVAL, Button},
43    flash_block::{
44        Error as FlashBlockError, FlashBlock, FlashDevice, clear_block, load_block, save_block,
45    },
46    pixel_target::{PixelTarget, rgb888_from_rgb565},
47};
48use embassy_time::Timer;
49use embedded_graphics::pixelcolor::RgbColor;
50use embedded_graphics::{
51    Drawable, Pixel,
52    mono_font::{MonoFont, MonoTextStyle},
53    pixelcolor::{IntoStorage, Rgb565, Rgb888},
54    prelude::{Dimensions, DrawTarget, Point, Size},
55    primitives::Rectangle,
56    text::{Baseline, Text},
57};
58use serde::{Deserialize, Serialize};
59use wasm_bindgen::Clamped;
60use web_sys::{CanvasRenderingContext2d, ImageData, Storage};
61
62pub use animation_frame::next_animation_frame;
63pub use clock::ClockSyncWasm;
64pub use dns::DnsSimulatorWasm;
65pub use simulator::{
66    CydSimulatorControlWasm, CydSimulatorWasm, WifiConnectOutcome, WifiSimulatorWasm,
67};
68
69const FLASH_BLOCK_SIZE: usize = 4096;
70const FLASH_BLOCK_OFFSET: u32 = 0;
71const FLASH_ERASED_BYTE: u8 = 0xFF;
72
73const fn identity_calibration_config() -> CalibrationConfig {
74    CalibrationConfig::new(1.0, 0.0, 0.0, 0.0, 1.0, 0.0)
75}
76
77#[cfg_attr(
78    feature = "doc-images",
79    doc = ::embed_doc_image::embed_image!(
80        "linkage_blaze_gallery",
81        "docs/assets/linkage_blaze_gallery.png"
82    )
83)]
84/// A CYD device rendered to an HTML canvas with browser-supplied touch input.
85///
86/// `CydWasm` implements the portable [`Cyd`] interface used by the hardware
87/// implementations. Write normal application code against the
88/// [`cyd`](crate::cyd) module and its [`CydDisplay`] drawing APIs; only browser
89/// setup and input plumbing need WASM-specific types.
90///
91/// Browser pointer handlers feed touch input through [`CydTouchWasmSource`],
92/// where it is calibrated and oriented like hardware touch input.
93/// [`CydFrameWasm::flush`] presents the current frame and awaits the next
94/// browser animation frame for frame pacing.
95///
96/// ## See CYD in action
97///
98/// [Linkage Blaze] demonstrates animated clocks, mechanisms, and figures built
99/// with Device Envoy's portable CYD display APIs. Explore the examples in the
100/// [interactive Linkage Blaze gallery].
101#[cfg_attr(
102    feature = "doc-images",
103    doc = "\n[![Linkage Blaze gallery showing CYD applications][linkage_blaze_gallery]][interactive Linkage Blaze gallery]\n"
104)]
105///
106/// [Linkage Blaze]: https://github.com/CarlKCarlK/linkage-blaze
107/// [interactive Linkage Blaze gallery]: https://carlkcarlk.github.io/linkage-blaze/demos/
108///
109/// # Example
110///
111/// ```rust,no_run
112/// use device_envoy_core::{
113///     cyd::display::Orientation,
114///     wasm::{CydTouchWasmSource, CydWasm},
115/// };
116/// use embedded_graphics::{
117///     mono_font::ascii::FONT_6X10,
118///     pixelcolor::{Rgb888, RgbColor},
119/// };
120/// use wasm_bindgen::{JsCast, JsValue};
121/// use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
122///
123/// fn create_cyd(canvas: HtmlCanvasElement) -> Result<CydWasm, JsValue> {
124///     let orientation = Orientation::Landscape;
125///     canvas.set_width(orientation.width());
126///     canvas.set_height(orientation.height());
127///     let context = canvas
128///         .get_context("2d")?
129///         .ok_or_else(|| JsValue::from_str("2D canvas context unavailable"))?
130///         .dyn_into::<CanvasRenderingContext2d>()?;
131///
132///     Ok(CydWasm::new(
133///         context,
134///         orientation,
135///         Rgb888::BLACK,
136///         Rgb888::WHITE,
137///         &FONT_6X10,
138///         CydTouchWasmSource::new(),
139///     ))
140/// }
141/// ```
142pub struct CydWasm {
143    display: CydDisplayWasm,
144    touch: CydTouchWasm,
145}
146
147/// Owned HTML-canvas display half of [`CydWasm`].
148#[derive(Clone)]
149pub struct CydDisplayWasm {
150    context: CanvasRenderingContext2d,
151    size: Size,
152    orientation: Orientation,
153    background_color: Rgb888,
154    foreground_color: Rgb888,
155    background565: Rgb565,
156    foreground565: Rgb565,
157    font: &'static MonoFont<'static>,
158}
159
160/// Owned calibrated and oriented touch half of [`CydWasm`].
161#[derive(Clone)]
162pub struct CydTouchWasm {
163    raw_touch_events: RawTouchEvents,
164    interaction_state: Rc<Cell<InteractionState>>,
165    latest_raw_point: Rc<Cell<Option<RawPoint>>>,
166    calibration_config: CalibrationConfig,
167    orientation: Orientation,
168}
169
170/// Input source used by browser pointer handlers to feed touch events to a [`CydWasm`].
171#[derive(Clone)]
172pub struct CydTouchWasmSource {
173    raw_touch_events: RawTouchEvents,
174    interaction_state: Rc<Cell<InteractionState>>,
175    latest_raw_point: Rc<Cell<Option<RawPoint>>>,
176}
177
178/// Browser button state controlled by a [`ButtonWasmSource`].
179pub struct ButtonWasm {
180    pressed: Rc<Cell<bool>>,
181}
182
183#[derive(Clone)]
184/// Input source used by browser controls to update a [`ButtonWasm`].
185pub struct ButtonWasmSource {
186    pressed: Rc<Cell<bool>>,
187}
188
189/// Flash-block storage backed by browser local storage.
190pub struct FlashBlockWasm {
191    flash_device: FlashDeviceWasm,
192}
193
194type RawTouchEvents = Rc<RefCell<VecDeque<RawTouchEvent>>>;
195
196struct FlashDeviceWasm {
197    storage: Storage,
198    storage_key: String,
199    bytes: [u8; FLASH_BLOCK_SIZE],
200}
201
202/// Errors reported by browser-backed WASM support.
203///
204/// These errors concern persistent browser storage used by [`FlashBlockWasm`];
205/// display drawing and calibrated touch use the in-memory simulator state.
206#[derive(Debug)]
207pub enum Error {
208    /// The browser does not provide the requested storage facility.
209    StorageUnavailable,
210    /// The browser rejected or failed a storage operation.
211    StorageAccess,
212}
213
214#[derive(Clone, Copy, Eq, PartialEq)]
215enum InteractionState {
216    Ready,
217    PointerDown,
218    WaitingForFreshPress,
219}
220
221impl CydWasm {
222    /// Construct a simulated CYD that presents frames onto `context`.
223    ///
224    /// `orientation` determines the logical display size and touch mapping. The
225    /// caller should size the context's canvas to match. Colors and `font`
226    /// configure the portable [`CydDisplay`] helpers, and `touch_source`
227    /// receives input from the browser's pointer handlers.
228    #[must_use]
229    pub fn new(
230        context: CanvasRenderingContext2d,
231        orientation: Orientation,
232        background_color: Rgb888,
233        foreground_color: Rgb888,
234        font: &'static MonoFont<'static>,
235        touch_source: CydTouchWasmSource,
236    ) -> Self {
237        let display = CydDisplayWasm {
238            context: context.clone(),
239            size: orientation.size(),
240            orientation,
241            background_color,
242            foreground_color,
243            background565: Rgb565::from(background_color),
244            foreground565: Rgb565::from(foreground_color),
245            font,
246        };
247        let touch = CydTouchWasm {
248            raw_touch_events: touch_source.raw_touch_events,
249            interaction_state: touch_source.interaction_state,
250            latest_raw_point: touch_source.latest_raw_point,
251            calibration_config: identity_calibration_config(),
252            orientation,
253        };
254        Self { display, touch }
255    }
256
257    #[must_use]
258    /// Clone the input source that feeds browser pointer events to this device.
259    pub fn touch_source(&self) -> CydTouchWasmSource {
260        CydTouchWasmSource {
261            raw_touch_events: self.touch.raw_touch_events.clone(),
262            interaction_state: self.touch.interaction_state.clone(),
263            latest_raw_point: self.touch.latest_raw_point.clone(),
264        }
265    }
266
267    #[must_use]
268    /// Clone the HTML-canvas display component.
269    pub fn display(&self) -> CydDisplayWasm {
270        self.display.clone()
271    }
272
273    /// Return owned display and calibrated touch handles.
274    ///
275    /// The handles share the same browser canvas and touch-event state as this
276    /// device.
277    #[must_use]
278    pub fn owned_parts(&self) -> (CydDisplayWasm, CydTouchWasm) {
279        (self.display.clone(), self.touch.clone())
280    }
281}
282
283impl Cyd for CydWasm {
284    /// Browser CYD display and touch operations are infallible.
285    type Error = Infallible;
286    type Display = CydDisplayWasm;
287    type Touch = CydTouchWasm;
288
289    fn parts(&mut self) -> (&mut Self::Display, &mut Self::Touch) {
290        (&mut self.display, &mut self.touch)
291    }
292
293    fn orientation(&self) -> Orientation {
294        self.display.orientation
295    }
296}
297
298impl CydTouchWasmSource {
299    /// Construct an empty browser touch-event source.
300    #[must_use]
301    pub fn new() -> Self {
302        Self {
303            raw_touch_events: Rc::new(RefCell::new(VecDeque::new())),
304            interaction_state: Rc::new(Cell::new(InteractionState::Ready)),
305            latest_raw_point: Rc::new(Cell::new(None)),
306        }
307    }
308
309    /// Queue a calibrated-panel point in fixed landscape coordinates.
310    ///
311    /// The browser control converts logical canvas coordinates to this raw
312    /// calibration boundary before calling the source. `CydTouchWasm::try_read`
313    /// performs the one runtime-orientation mapping for the application.
314    pub fn touch_down(&self, x: f32, y: f32) {
315        match self.interaction_state.get() {
316            InteractionState::WaitingForFreshPress => return,
317            InteractionState::Ready | InteractionState::PointerDown => {
318                self.interaction_state.set(InteractionState::PointerDown);
319            }
320        }
321        assert!((0.0..=u16::MAX as f32).contains(&x));
322        assert!((0.0..=u16::MAX as f32).contains(&y));
323        let raw_point = RawPoint {
324            x: x as u16,
325            y: y as u16,
326        };
327        self.latest_raw_point.set(Some(raw_point));
328        self.push(RawTouchEvent::Down {
329            raw_x: raw_point.x,
330            raw_y: raw_point.y,
331        });
332    }
333
334    /// Queue a movement point in fixed landscape calibration coordinates.
335    pub fn touch_move(&self, x: f32, y: f32) {
336        if self.interaction_state.get() != InteractionState::PointerDown {
337            return;
338        }
339        assert!((0.0..=u16::MAX as f32).contains(&x));
340        assert!((0.0..=u16::MAX as f32).contains(&y));
341        let raw_point = RawPoint {
342            x: x as u16,
343            y: y as u16,
344        };
345        self.latest_raw_point.set(Some(raw_point));
346        self.push(RawTouchEvent::Move {
347            raw_x: raw_point.x,
348            raw_y: raw_point.y,
349        });
350    }
351
352    /// Queue a touch-release event and allow the next press.
353    pub fn touch_up(&self) {
354        let interaction_state = self.interaction_state.get();
355        self.interaction_state.set(InteractionState::Ready);
356        self.latest_raw_point.set(None);
357        if interaction_state == InteractionState::WaitingForFreshPress {
358            return;
359        }
360        self.push(RawTouchEvent::Up);
361    }
362
363    /// Discard pending input until the next touch-down event.
364    pub fn wait_for_fresh_press(&self) {
365        self.raw_touch_events.borrow_mut().clear();
366        self.latest_raw_point.set(None);
367        self.interaction_state
368            .set(InteractionState::WaitingForFreshPress);
369    }
370
371    fn push(&self, raw_touch_event: RawTouchEvent) {
372        self.raw_touch_events
373            .borrow_mut()
374            .push_back(raw_touch_event);
375    }
376}
377
378impl Default for CydTouchWasmSource {
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384impl ButtonWasmSource {
385    /// Construct a released browser button source.
386    #[must_use]
387    pub fn new() -> Self {
388        Self {
389            pressed: Rc::new(Cell::new(false)),
390        }
391    }
392
393    #[must_use]
394    /// Return a button handle backed by this source's current state.
395    pub fn button(&self) -> ButtonWasm {
396        ButtonWasm {
397            pressed: self.pressed.clone(),
398        }
399    }
400
401    /// Set the browser button state to pressed.
402    pub fn press(&self) {
403        self.pressed.set(true);
404    }
405
406    /// Set the browser button state to released.
407    pub fn release(&self) {
408        self.pressed.set(false);
409    }
410}
411
412impl Default for ButtonWasmSource {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418// TODO (may no longer apply) When a dedicated `device-envoy-wasm` crate exists, move `ButtonWasm`
419// there so browser button plumbing lives beside the platform button adapter.
420impl __ButtonMonitor for ButtonWasm {
421    fn is_pressed_raw(&self) -> bool {
422        self.pressed.get()
423    }
424
425    async fn wait_until_pressed_state(&mut self, pressed: bool) {
426        loop {
427            if self.is_pressed_raw() == pressed {
428                break;
429            }
430            Timer::after(BUTTON_POLL_INTERVAL).await;
431        }
432    }
433}
434
435impl Button for ButtonWasm {}
436
437impl FlashBlockWasm {
438    /// Create browser-backed storage under `storage_key`.
439    pub fn new(storage_key: &str) -> Result<Self, Error> {
440        Ok(Self {
441            flash_device: FlashDeviceWasm::new(storage_key)?,
442        })
443    }
444}
445
446impl FlashDeviceWasm {
447    fn new(storage_key: &str) -> Result<Self, Error> {
448        let window = web_sys::window().ok_or(Error::StorageUnavailable)?;
449        let storage = window
450            .local_storage()
451            .map_err(|_error| Error::StorageAccess)?
452            .ok_or(Error::StorageUnavailable)?;
453        let mut flash_device = Self {
454            storage,
455            storage_key: storage_key.to_owned(),
456            bytes: [FLASH_ERASED_BYTE; FLASH_BLOCK_SIZE],
457        };
458        flash_device.load_from_storage()?;
459        Ok(flash_device)
460    }
461
462    fn load_from_storage(&mut self) -> Result<(), Error> {
463        let Some(encoded_bytes) = self
464            .storage
465            .get_item(&self.storage_key)
466            .map_err(|_error| Error::StorageAccess)?
467        else {
468            return Ok(());
469        };
470
471        if encoded_bytes.len() != FLASH_BLOCK_SIZE * 2 {
472            return Ok(());
473        }
474
475        let mut decoded_bytes = [FLASH_ERASED_BYTE; FLASH_BLOCK_SIZE];
476        if !decode_hex_into(&encoded_bytes, &mut decoded_bytes) {
477            return Ok(());
478        }
479        self.bytes = decoded_bytes;
480        Ok(())
481    }
482
483    fn persist(&self) -> Result<(), Error> {
484        let encoded_bytes = encode_hex(&self.bytes);
485        self.storage
486            .set_item(&self.storage_key, &encoded_bytes)
487            .map_err(|_error| Error::StorageAccess)
488    }
489
490    fn checked_range(&self, offset: u32, len: usize) -> Range<usize> {
491        let start = usize::try_from(offset).expect("flash offset must fit in usize");
492        let end = start
493            .checked_add(len)
494            .expect("flash range must fit in usize");
495        assert!(
496            end <= FLASH_BLOCK_SIZE,
497            "flash range must stay within the block"
498        );
499        start..end
500    }
501}
502
503impl FlashDevice for FlashDeviceWasm {
504    type Error = Error;
505
506    fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> {
507        let checked_range = self.checked_range(offset, bytes.len());
508        bytes.copy_from_slice(&self.bytes[checked_range]);
509        Ok(())
510    }
511
512    fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> {
513        let checked_range = self.checked_range(offset, bytes.len());
514        self.bytes[checked_range].copy_from_slice(bytes);
515        self.persist()
516    }
517
518    fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> {
519        let len = usize::try_from(to.saturating_sub(from)).expect("flash erase length fits usize");
520        let checked_range = self.checked_range(from, len);
521        self.bytes[checked_range].fill(FLASH_ERASED_BYTE);
522        self.persist()
523    }
524}
525
526impl FlashBlock for FlashBlockWasm {
527    type Error = FlashBlockError<Error>;
528
529    fn load<T>(&mut self) -> Result<Option<T>, Self::Error>
530    where
531        T: Serialize + for<'de> Deserialize<'de>,
532    {
533        load_block::<FLASH_BLOCK_SIZE, T, _>(&mut self.flash_device, FLASH_BLOCK_OFFSET)
534    }
535
536    fn save<T>(&mut self, value: &T) -> Result<(), Self::Error>
537    where
538        T: Serialize + for<'de> Deserialize<'de>,
539    {
540        save_block::<FLASH_BLOCK_SIZE, _, _>(&mut self.flash_device, FLASH_BLOCK_OFFSET, value)
541    }
542
543    fn clear(&mut self) -> Result<(), Self::Error> {
544        clear_block::<FLASH_BLOCK_SIZE, _>(&mut self.flash_device, FLASH_BLOCK_OFFSET)
545    }
546}
547
548fn encode_hex(bytes: &[u8]) -> String {
549    const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
550
551    let mut encoded = String::with_capacity(bytes.len() * 2);
552    for byte in bytes {
553        encoded.push(HEX_DIGITS[(byte >> 4) as usize] as char);
554        encoded.push(HEX_DIGITS[(byte & 0x0F) as usize] as char);
555    }
556    encoded
557}
558
559fn decode_hex_into(encoded_bytes: &str, dst: &mut [u8]) -> bool {
560    let encoded_bytes = encoded_bytes.as_bytes();
561    if encoded_bytes.len() != dst.len() * 2 {
562        return false;
563    }
564
565    for (dst_index, chunk) in encoded_bytes.chunks_exact(2).enumerate() {
566        let Some(high) = decode_hex_nibble(chunk[0]) else {
567            return false;
568        };
569        let Some(low) = decode_hex_nibble(chunk[1]) else {
570            return false;
571        };
572        dst[dst_index] = (high << 4) | low;
573    }
574
575    true
576}
577
578const fn decode_hex_nibble(byte: u8) -> Option<u8> {
579    match byte {
580        b'0'..=b'9' => Some(byte - b'0'),
581        b'a'..=b'f' => Some(byte - b'a' + 10),
582        b'A'..=b'F' => Some(byte - b'A' + 10),
583        _ => None,
584    }
585}
586
587impl crate::cyd::backend::DisplayBackend for CydDisplayWasm {
588    type Error = Infallible;
589    type Frame<'a>
590        = CydFrameWasm<'a>
591    where
592        Self: 'a;
593
594    fn create_frame_mut(&mut self, rectangle: Rectangle) -> Self::Frame<'_> {
595        let size = rectangle.size;
596        let pixel_count = size.width as usize * size.height as usize;
597        let pixels = vec![self.background565.into_storage(); pixel_count];
598        CydFrameWasm {
599            context: &self.context,
600            pixels,
601            rectangle,
602            background565: self.background565,
603            foreground565: self.foreground565,
604            font: self.font,
605        }
606    }
607}
608
609impl CydDisplay for CydDisplayWasm {
610    fn screen_size(&self) -> Size {
611        self.size
612    }
613
614    fn background_color(&self) -> Rgb888 {
615        self.background_color
616    }
617
618    fn foreground_color(&self) -> Rgb888 {
619        self.foreground_color
620    }
621
622    fn background_565(&self) -> Rgb565 {
623        self.background565
624    }
625
626    fn foreground_565(&self) -> Rgb565 {
627        self.foreground565
628    }
629
630    fn fill_rectangle(&mut self, rectangle: Rectangle, color: Rgb565) -> Result<(), Infallible> {
631        let screen_rectangle = Rectangle::new(Point::zero(), self.size);
632        let rectangle = rectangle.intersection(&screen_rectangle);
633        if rectangle.size.width == 0 || rectangle.size.height == 0 {
634            return Ok(());
635        }
636
637        let pixel_count = rectangle.size.width as usize * rectangle.size.height as usize;
638        let mut bytes = Vec::with_capacity(pixel_count * 4);
639        for _pixel_index in 0..pixel_count {
640            push_rgb565_rgba(&mut bytes, color.into_storage());
641        }
642
643        put_image_data(&self.context, rectangle, &bytes);
644        Ok(())
645    }
646
647    fn fill_contiguous<I>(&mut self, rectangle: Rectangle, pixels: I) -> Result<(), Infallible>
648    where
649        I: IntoIterator<Item = Rgb565>,
650    {
651        if rectangle.size.width == 0 || rectangle.size.height == 0 {
652            return Ok(());
653        }
654
655        let mut bytes =
656            Vec::with_capacity(rectangle.size.width as usize * rectangle.size.height as usize * 4);
657        for pixel in pixels {
658            push_rgb565_rgba(&mut bytes, pixel.into_storage());
659        }
660
661        put_image_data(&self.context, rectangle, &bytes);
662        Ok(())
663    }
664}
665
666impl CydTouch for CydTouchWasm {
667    type Error = Infallible;
668
669    fn try_read(&mut self) -> Result<Option<TouchEvent>, Infallible> {
670        Ok(self
671            .raw_touch_events
672            .borrow_mut()
673            .pop_front()
674            .map(|raw_touch_event| match raw_touch_event {
675                RawTouchEvent::Down { raw_x, raw_y } => {
676                    let (x, y) = self.calibration_config.map_raw_to_screen(raw_x, raw_y);
677                    TouchEvent::Down {
678                        point: self
679                            .orientation
680                            .map_landscape_point(Point::new(x as i32, y as i32)),
681                    }
682                }
683                RawTouchEvent::Move { raw_x, raw_y } => {
684                    let (x, y) = self.calibration_config.map_raw_to_screen(raw_x, raw_y);
685                    TouchEvent::Move {
686                        point: self
687                            .orientation
688                            .map_landscape_point(Point::new(x as i32, y as i32)),
689                    }
690                }
691                RawTouchEvent::Up => TouchEvent::Up,
692            }))
693    }
694}
695
696fn put_image_data(context: &CanvasRenderingContext2d, rectangle: Rectangle, bytes: &[u8]) {
697    let image_data = ImageData::new_with_u8_clamped_array_and_sh(
698        Clamped(bytes),
699        rectangle.size.width,
700        rectangle.size.height,
701    )
702    .expect("ImageData dimensions match the rectangle");
703    context
704        .put_image_data(
705            &image_data,
706            f64::from(rectangle.top_left.x),
707            f64::from(rectangle.top_left.y),
708        )
709        .expect("put_image_data with in-bounds coordinates cannot fail");
710}
711
712fn push_rgb565_rgba(bytes: &mut Vec<u8>, pixel: u16) {
713    let color = rgb888_from_rgb565(pixel);
714    bytes.push(color.r());
715    bytes.push(color.g());
716    bytes.push(color.b());
717    bytes.push(255);
718}
719
720/// A single in-progress frame backed by an `Rgb565` pixel buffer.
721///
722/// Drawing uses logical display coordinates. [`CydFrame::flush`] presents the
723/// buffered region to the canvas and awaits the next browser animation frame.
724pub struct CydFrameWasm<'a> {
725    context: &'a CanvasRenderingContext2d,
726    pixels: Vec<u16>,
727    // Where this frame presents and how large it is: set from the `Rectangle`
728    // passed to `frame_mut`, so `flush` needs no separate position argument.
729    rectangle: Rectangle,
730    background565: Rgb565,
731    foreground565: Rgb565,
732    font: &'static MonoFont<'static>,
733}
734
735impl CydFrameWasm<'_> {
736    fn width(&self) -> usize {
737        self.rectangle.size.width as usize
738    }
739
740    fn height(&self) -> usize {
741        self.rectangle.size.height as usize
742    }
743
744    fn local_x(&self, x: i32) -> Option<usize> {
745        usize::try_from(x.checked_sub(self.rectangle.top_left.x)?).ok()
746    }
747
748    fn local_y(&self, y: i32) -> Option<usize> {
749        usize::try_from(y.checked_sub(self.rectangle.top_left.y)?).ok()
750    }
751
752    /// Convert the `Rgb565` buffer to RGBA8 and `putImageData` it at the frame's top-left.
753    fn present(&self) {
754        let mut bytes = Vec::with_capacity(self.pixels.len() * 4);
755        for pixel in &self.pixels {
756            let color = rgb888_from_rgb565(*pixel);
757            bytes.push(color.r());
758            bytes.push(color.g());
759            bytes.push(color.b());
760            bytes.push(255);
761        }
762        let image_data = ImageData::new_with_u8_clamped_array_and_sh(
763            Clamped(&bytes),
764            self.rectangle.size.width,
765            self.rectangle.size.height,
766        )
767        .expect("ImageData dimensions match the pixel buffer");
768        self.context
769            .put_image_data(
770                &image_data,
771                f64::from(self.rectangle.top_left.x),
772                f64::from(self.rectangle.top_left.y),
773            )
774            .expect("put_image_data with in-bounds coordinates cannot fail");
775    }
776}
777
778impl DrawTarget for CydFrameWasm<'_> {
779    type Color = Rgb565;
780    type Error = Infallible;
781
782    fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
783        CydFrame::fill(self, color);
784        Ok(())
785    }
786
787    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
788    where
789        I: IntoIterator<Item = Pixel<Self::Color>>,
790    {
791        for Pixel(point, color) in pixels {
792            let Some(local_x) = self.local_x(point.x) else {
793                continue;
794            };
795            let Some(local_y) = self.local_y(point.y) else {
796                continue;
797            };
798            if local_x < CydFrameWasm::width(self) && local_y < CydFrameWasm::height(self) {
799                let index = local_y * CydFrameWasm::width(self) + local_x;
800                self.pixels[index] = color.into_storage();
801            }
802        }
803        Ok(())
804    }
805}
806
807impl Dimensions for CydFrameWasm<'_> {
808    fn bounding_box(&self) -> Rectangle {
809        self.rectangle
810    }
811}
812
813impl PixelTarget for CydFrameWasm<'_> {
814    fn width(&self) -> usize {
815        usize::try_from(self.rectangle.top_left.x)
816            .expect("frame top-left x must be non-negative")
817            .checked_add(CydFrameWasm::width(self))
818            .expect("frame width must fit in usize")
819    }
820
821    fn height(&self) -> usize {
822        usize::try_from(self.rectangle.top_left.y)
823            .expect("frame top-left y must be non-negative")
824            .checked_add(CydFrameWasm::height(self))
825            .expect("frame height must fit in usize")
826    }
827
828    fn put_pixel(&mut self, x: usize, y: usize, color: Rgb888) {
829        let Some(local_x) = self.local_x(x as i32) else {
830            return;
831        };
832        let Some(local_y) = self.local_y(y as i32) else {
833            return;
834        };
835        if local_x >= CydFrameWasm::width(self) || local_y >= CydFrameWasm::height(self) {
836            return;
837        }
838        let stride = CydFrameWasm::width(self);
839        self.pixels[local_y * stride + local_x] = Rgb565::from(color).into_storage();
840    }
841
842    /// The frame buffer already stores RGB565, so a decoded image pixel can be
843    /// written verbatim with no RGB888 round-trip.
844    fn put_pixel_565(&mut self, x: usize, y: usize, rgb565: u16) {
845        let Some(local_x) = self.local_x(x as i32) else {
846            return;
847        };
848        let Some(local_y) = self.local_y(y as i32) else {
849            return;
850        };
851        if local_x >= CydFrameWasm::width(self) || local_y >= CydFrameWasm::height(self) {
852            return;
853        }
854        let stride = CydFrameWasm::width(self);
855        self.pixels[local_y * stride + local_x] = rgb565;
856    }
857}
858
859impl CydFrame for CydFrameWasm<'_> {
860    /// WASM frame presentation is infallible.
861    type Error = Infallible;
862
863    fn rectangle(&self) -> Rectangle {
864        self.rectangle
865    }
866
867    fn fill(&mut self, color: Rgb565) -> &mut Self {
868        self.pixels.fill(color.into_storage());
869        self
870    }
871
872    fn clear(&mut self) -> &mut Self {
873        self.fill(self.background565)
874    }
875
876    fn copy_from_565(&mut self, src: &[u16]) -> crate::Result<()> {
877        if self.pixels.len() != src.len() {
878            return Err(crate::Error::CopySize {
879                src_len: src.len(),
880                frame_len: self.pixels.len(),
881            });
882        }
883        self.pixels.copy_from_slice(src);
884        Ok(())
885    }
886
887    fn write_text(&mut self, text: &str) -> &mut Self {
888        let style = MonoTextStyle::new(self.font, self.foreground565);
889        Text::with_baseline(text, self.rectangle.top_left, style, Baseline::Top)
890            .draw(self)
891            .expect("drawing onto an Infallible frame cannot fail");
892        self
893    }
894
895    async fn flush(&mut self) -> Result<(), Infallible> {
896        // Present immediately so the first drawn frame is visible without
897        // waiting a browser tick, then yield to the next animation frame to
898        // pace the loop.
899        self.present();
900        next_animation_frame().await;
901        Ok(())
902    }
903}