fui-rs 0.2.11

Web-first retained Rust UI for WebAssembly and native desktop applications
Documentation
use crate::assets;
use crate::drawing::DrawContext;
use crate::ffi;
use crate::frame_scheduler::on_loaded;
use crate::logger::error;
use crate::node::Node;
use crate::text::TextLayout;
use crate::typography::FontFace;
use std::cell::RefCell;
use std::rc::Rc;

const MAX_DIRTY_RECTS: usize = 16;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// Event payload emitted when retained bitmap text is ready to rasterize.
pub struct BitmapTextReadyEventArgs;

impl BitmapTextReadyEventArgs {
    /// The stateless readiness payload.
    pub const EMPTY: Self = Self;
}

struct BitmapState {
    width: u32,
    height: u32,
    texture_id: u32,
    pixel_bytes: Vec<u8>,
    offscreen_id: u32,
    canvas_used: bool,
    draw_context: Option<DrawContext>,
    disposed: bool,
    dirty_rects: Vec<(u32, u32, u32, u32)>,
}

#[derive(Clone)]
/// A shared premultiplied-RGBA buffer, texture, and optional offscreen surface.
///
/// Clones share one resource. The final clone releases the texture and
/// offscreen surface on browser and native hosts.
///
/// ```no_run
/// use fui::prelude::*;
///
/// let bitmap = Bitmap::new(32, 32);
/// {
///     let mut pixels = bitmap.pixels();
///     pixels[0..4].copy_from_slice(&[255, 96, 32, 255]);
/// }
/// bitmap.clear_dirty_rects().dirty_rect(0, 0, 1, 1).commit();
/// ```
pub struct Bitmap {
    inner: Rc<RefCell<BitmapState>>,
}

impl Bitmap {
    /// Allocates a non-empty bitmap and its host texture/offscreen resources.
    pub fn new(width: u32, height: u32) -> Self {
        assert!(
            width > 0 && height > 0,
            "Bitmap width and height must be greater than zero."
        );
        let byte_len = (width as usize)
            .checked_mul(height as usize)
            .and_then(|value| value.checked_mul(4))
            .expect("Bitmap byte length overflow.");
        let texture_id = assets::allocate_dynamic_texture_id();
        let offscreen_id = unsafe { ffi::fui_canvas_create_offscreen(width, height) };
        Self {
            inner: Rc::new(RefCell::new(BitmapState {
                width,
                height,
                texture_id,
                pixel_bytes: vec![0; byte_len],
                offscreen_id,
                canvas_used: false,
                draw_context: None,
                disposed: false,
                dirty_rects: Vec::new(),
            })),
        }
    }

    /// Returns the physical pixel width.
    pub fn width(&self) -> u32 {
        self.inner.borrow().width
    }

    /// Returns the physical pixel height.
    pub fn height(&self) -> u32 {
        self.inner.borrow().height
    }

    /// Returns the texture ID accepted by [`DrawContext::draw_image`](crate::drawing::DrawContext::draw_image).
    pub fn texture_id(&self) -> u32 {
        self.inner.borrow().texture_id
    }

    /// Mutably borrows premultiplied RGBA bytes.
    ///
    /// Drop the returned borrow before invoking any other bitmap method.
    pub fn pixels(&self) -> std::cell::RefMut<'_, Vec<u8>> {
        std::cell::RefMut::map(self.inner.borrow_mut(), |state| {
            assert!(!state.disposed, "Bitmap.pixels() called after dispose.");
            &mut state.pixel_bytes
        })
    }

    /// Returns the current pixel-buffer address for low-level host interop.
    pub fn pixel_ptr(&self) -> usize {
        let state = self.inner.borrow();
        if state.pixel_bytes.is_empty() {
            0
        } else {
            state.pixel_bytes.as_ptr() as usize
        }
    }

    /// Returns the persistent offscreen drawing context.
    ///
    /// Once used, each [`commit`](Self::commit) flushes and reads this surface
    /// over the pixel buffer before texture upload. Do not subsequently mix
    /// direct pixel ownership into the same bitmap.
    pub fn canvas(&self) -> DrawContext {
        let mut state = self.inner.borrow_mut();
        assert!(!state.disposed, "Bitmap.canvas() called after dispose.");
        state.canvas_used = true;
        if let Some(context) = &state.draw_context {
            return context.clone();
        }
        let ptr = unsafe { ffi::fui_canvas_get_offscreen_ptr(state.offscreen_id) };
        let context = DrawContext::new(ptr);
        state.draw_context = Some(context.clone());
        context
    }

    /// Rasterizes a built, laid-out retained node into the pixel buffer.
    ///
    /// Call this after layout, then call [`commit`](Self::commit). `scale`
    /// maps logical node coordinates to physical bitmap pixels.
    pub fn render<T: Node>(&self, node: &T, x: f32, y: f32, scale: f32) {
        let state = self.inner.borrow();
        assert!(!state.disposed, "Bitmap.render() called after dispose.");
        let handle = node.handle().raw();
        if handle == 0 {
            return;
        }
        unsafe {
            ffi::fui_render_node_to_rgba(
                handle,
                state.width,
                state.height,
                state.pixel_bytes.as_ptr() as usize,
                state.pixel_bytes.len() as u32,
                scale,
                x,
                y,
            )
        };
    }

    /// Rasterizes a ready retained text layout into the pixel buffer.
    pub fn render_text_layout(&self, layout: &TextLayout, x: f32, y: f32, scale: f32) {
        if !layout.is_ready() {
            error(
                "TextLayout",
                "Bitmap.render_text_layout() called before the TextLayout was ready; register on_ready and render after the callback.",
            );
            return;
        }
        let node = layout.draw_node();
        self.render(&node, x, y, scale);
    }

    /// Builds and prepares a retained text node for bitmap rasterization.
    pub fn prepare_text<T: Node>(node: &T) {
        node.build();
        crate::bindings::ui::prepare_node(node.handle().raw());
    }

    /// Invokes `callback` once required fonts and initial app load are ready.
    pub fn on_text_ready<T: Node + Clone + 'static>(
        &self,
        node: &T,
        callback: impl FnOnce(BitmapTextReadyEventArgs) + 'static,
    ) -> &Self {
        let node = node.clone();
        let required_font_ids = node.required_font_ids_for_preparation();
        let callback = Rc::new(RefCell::new(Some(callback)));
        FontFace::when_fonts_loaded(&required_font_ids, move |_| {
            let node = node.clone();
            let callback = callback.clone();
            on_loaded(move |_| {
                Self::prepare_text(&node);
                if let Some(callback) = callback.borrow_mut().take() {
                    callback(BitmapTextReadyEventArgs::EMPTY);
                }
            });
        });
        self
    }

    /// Adds a clipped dirty upload region for the next commit.
    ///
    /// Empty/outside regions are ignored and at most 16 regions are retained.
    pub fn dirty_rect(&self, x: u32, y: u32, w: u32, h: u32) -> &Self {
        let mut state = self.inner.borrow_mut();
        if w == 0 || h == 0 || x >= state.width || y >= state.height {
            return self;
        }
        let cw = (x + w).min(state.width) - x;
        let ch = (y + h).min(state.height) - y;
        if state.dirty_rects.len() < MAX_DIRTY_RECTS {
            state.dirty_rects.push((x, y, cw, ch));
        }
        self
    }

    /// Clears pending dirty regions so the next commit is a full upload unless
    /// new regions are added.
    pub fn clear_dirty_rects(&self) -> &Self {
        self.inner.borrow_mut().dirty_rects.clear();
        self
    }

    /// Reports whether the next commit contains partial upload regions.
    pub fn has_dirty_rects(&self) -> bool {
        !self.inner.borrow().dirty_rects.is_empty()
    }

    /// Flushes offscreen drawing if used, uploads full or dirty pixels, consumes
    /// dirty regions, marks the texture ready, and returns its texture ID.
    pub fn commit(&self) -> u32 {
        let mut state = self.inner.borrow_mut();
        assert!(!state.disposed, "Bitmap.commit() called after dispose.");
        if state.canvas_used {
            if let Some(context) = &state.draw_context {
                context.flush();
            }
            unsafe {
                ffi::fui_canvas_read_offscreen_pixels(
                    state.offscreen_id,
                    state.pixel_bytes.as_mut_ptr() as usize,
                    state.width,
                    state.height,
                )
            };
        }
        if state.dirty_rects.is_empty() {
            unsafe {
                ffi::fui_bitmap_commit(
                    state.texture_id,
                    state.pixel_bytes.as_ptr() as usize,
                    state.pixel_bytes.len() as u32,
                    state.width,
                    state.height,
                )
            };
        } else {
            let dirty_rects = std::mem::take(&mut state.dirty_rects);
            for (x, y, w, h) in dirty_rects {
                let mut rect_bytes = vec![0u8; (w as usize) * (h as usize) * 4];
                for row in 0..h as usize {
                    let src = (((y as usize + row) * state.width as usize) + x as usize) * 4;
                    let dst = row * w as usize * 4;
                    let len = w as usize * 4;
                    rect_bytes[dst..dst + len].copy_from_slice(&state.pixel_bytes[src..src + len]);
                }
                unsafe {
                    ffi::fui_bitmap_commit_dirty(
                        state.texture_id,
                        rect_bytes.as_ptr() as usize,
                        rect_bytes.len() as u32,
                        state.width,
                        state.height,
                        x,
                        y,
                        w,
                        h,
                    )
                };
            }
        }
        assets::mark_texture_asset_ready(state.texture_id, state.width as f32, state.height as f32);
        state.texture_id
    }

    /// Releases the texture and offscreen surface early.
    ///
    /// This operation is idempotent. Drawing and pixel operations after
    /// disposal are invalid.
    pub fn dispose(&self) {
        let mut state = self.inner.borrow_mut();
        if state.disposed {
            return;
        }
        state.disposed = true;
        unsafe { ffi::fui_canvas_destroy_offscreen(state.offscreen_id) };
        unsafe { ffi::fui_bitmap_release(state.texture_id) };
        state.pixel_bytes.clear();
        state.draw_context = None;
    }
}

impl Drop for Bitmap {
    fn drop(&mut self) {
        if Rc::strong_count(&self.inner) == 1 {
            self.dispose();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::Bitmap;
    use crate::drawing::Paint;
    use crate::ffi::{self, Call};
    use crate::frame_scheduler;
    use crate::node::{Node, TextNode};
    use std::cell::Cell;
    use std::rc::Rc;

    #[test]
    fn bitmap_canvas_commit_flushes_offscreen_and_marks_asset_ready() {
        ffi::test::reset();
        let bitmap = Bitmap::new(32, 24);
        let canvas = bitmap.canvas();
        canvas.draw_rect(0.0, 0.0, 10.0, 12.0, Paint::fill(0xFF00FFFF));
        bitmap.commit();

        let calls = ffi::test::take_calls();
        assert!(calls.iter().any(|call| matches!(
            call,
            Call::CanvasCreateOffscreen {
                width: 32,
                height: 24,
                ..
            }
        )));
        assert!(calls
            .iter()
            .any(|call| matches!(call, Call::CanvasDrawBatch { .. })));
        assert!(calls.iter().any(|call| matches!(
            call,
            Call::CanvasReadOffscreenPixels {
                width: 32,
                height: 24,
                ..
            }
        )));
        assert!(calls.iter().any(|call| matches!(
            call,
            Call::BitmapCommit {
                width: 32,
                height: 24,
                ..
            }
        )));
    }

    #[test]
    fn bitmap_dirty_commit_uses_subrect_upload() {
        ffi::test::reset();
        let bitmap = Bitmap::new(8, 6);
        bitmap.dirty_rect(2, 1, 3, 2).commit();

        let calls = ffi::test::take_calls();
        assert!(calls.iter().any(|call| matches!(
            call,
            Call::BitmapCommitDirty {
                full_width: 8,
                full_height: 6,
                sub_x: 2,
                sub_y: 1,
                sub_w: 3,
                sub_h: 2,
                ..
            }
        )));
    }

    #[test]
    fn bitmap_text_ready_builds_detached_text_before_preparing_it() {
        ffi::test::reset();
        frame_scheduler::reset_commit_state();
        let bitmap = Bitmap::new(64, 32);
        let text = TextNode::new("Detached bitmap text");
        let fired = Rc::new(Cell::new(false));
        bitmap.on_text_ready(&text, {
            let fired = fired.clone();
            move |_| fired.set(true)
        });

        frame_scheduler::fire_loaded_callbacks();

        assert!(fired.get());
        assert!(text.has_built_handle());
        let calls = ffi::test::take_calls();
        assert!(calls
            .iter()
            .any(|call| matches!(call, Call::PrepareNode { .. })));
    }
}