Skip to main content

fui/
bitmap.rs

1use crate::assets;
2use crate::drawing::DrawContext;
3use crate::ffi;
4use crate::frame_scheduler::on_loaded;
5use crate::logger::error;
6use crate::node::Node;
7use crate::text::TextLayout;
8use crate::typography::FontFace;
9use std::cell::RefCell;
10use std::rc::Rc;
11
12const MAX_DIRTY_RECTS: usize = 16;
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15/// Event payload emitted when retained bitmap text is ready to rasterize.
16pub struct BitmapTextReadyEventArgs;
17
18impl BitmapTextReadyEventArgs {
19    /// The stateless readiness payload.
20    pub const EMPTY: Self = Self;
21}
22
23struct BitmapState {
24    width: u32,
25    height: u32,
26    texture_id: u32,
27    pixel_bytes: Vec<u8>,
28    offscreen_id: u32,
29    canvas_used: bool,
30    draw_context: Option<DrawContext>,
31    disposed: bool,
32    dirty_rects: Vec<(u32, u32, u32, u32)>,
33}
34
35#[derive(Clone)]
36/// A shared premultiplied-RGBA buffer, texture, and optional offscreen surface.
37///
38/// Clones share one resource. The final clone releases the texture and
39/// offscreen surface on browser and native hosts.
40///
41/// ```no_run
42/// use fui::prelude::*;
43///
44/// let bitmap = Bitmap::new(32, 32);
45/// {
46///     let mut pixels = bitmap.pixels();
47///     pixels[0..4].copy_from_slice(&[255, 96, 32, 255]);
48/// }
49/// bitmap.clear_dirty_rects().dirty_rect(0, 0, 1, 1).commit();
50/// ```
51pub struct Bitmap {
52    inner: Rc<RefCell<BitmapState>>,
53}
54
55impl Bitmap {
56    /// Allocates a non-empty bitmap and its host texture/offscreen resources.
57    pub fn new(width: u32, height: u32) -> Self {
58        assert!(
59            width > 0 && height > 0,
60            "Bitmap width and height must be greater than zero."
61        );
62        let byte_len = (width as usize)
63            .checked_mul(height as usize)
64            .and_then(|value| value.checked_mul(4))
65            .expect("Bitmap byte length overflow.");
66        let texture_id = assets::allocate_dynamic_texture_id();
67        let offscreen_id = unsafe { ffi::fui_canvas_create_offscreen(width, height) };
68        Self {
69            inner: Rc::new(RefCell::new(BitmapState {
70                width,
71                height,
72                texture_id,
73                pixel_bytes: vec![0; byte_len],
74                offscreen_id,
75                canvas_used: false,
76                draw_context: None,
77                disposed: false,
78                dirty_rects: Vec::new(),
79            })),
80        }
81    }
82
83    /// Returns the physical pixel width.
84    pub fn width(&self) -> u32 {
85        self.inner.borrow().width
86    }
87
88    /// Returns the physical pixel height.
89    pub fn height(&self) -> u32 {
90        self.inner.borrow().height
91    }
92
93    /// Returns the texture ID accepted by [`DrawContext::draw_image`](crate::drawing::DrawContext::draw_image).
94    pub fn texture_id(&self) -> u32 {
95        self.inner.borrow().texture_id
96    }
97
98    /// Mutably borrows premultiplied RGBA bytes.
99    ///
100    /// Drop the returned borrow before invoking any other bitmap method.
101    pub fn pixels(&self) -> std::cell::RefMut<'_, Vec<u8>> {
102        std::cell::RefMut::map(self.inner.borrow_mut(), |state| {
103            assert!(!state.disposed, "Bitmap.pixels() called after dispose.");
104            &mut state.pixel_bytes
105        })
106    }
107
108    /// Returns the current pixel-buffer address for low-level host interop.
109    pub fn pixel_ptr(&self) -> usize {
110        let state = self.inner.borrow();
111        if state.pixel_bytes.is_empty() {
112            0
113        } else {
114            state.pixel_bytes.as_ptr() as usize
115        }
116    }
117
118    /// Returns the persistent offscreen drawing context.
119    ///
120    /// Once used, each [`commit`](Self::commit) flushes and reads this surface
121    /// over the pixel buffer before texture upload. Do not subsequently mix
122    /// direct pixel ownership into the same bitmap.
123    pub fn canvas(&self) -> DrawContext {
124        let mut state = self.inner.borrow_mut();
125        assert!(!state.disposed, "Bitmap.canvas() called after dispose.");
126        state.canvas_used = true;
127        if let Some(context) = &state.draw_context {
128            return context.clone();
129        }
130        let ptr = unsafe { ffi::fui_canvas_get_offscreen_ptr(state.offscreen_id) };
131        let context = DrawContext::new(ptr);
132        state.draw_context = Some(context.clone());
133        context
134    }
135
136    /// Rasterizes a built, laid-out retained node into the pixel buffer.
137    ///
138    /// Call this after layout, then call [`commit`](Self::commit). `scale`
139    /// maps logical node coordinates to physical bitmap pixels.
140    pub fn render<T: Node>(&self, node: &T, x: f32, y: f32, scale: f32) -> bool {
141        let mut state = self.inner.borrow_mut();
142        assert!(!state.disposed, "Bitmap.render() called after dispose.");
143        let handle = node.handle().raw();
144        if handle == 0 {
145            return false;
146        }
147        unsafe {
148            ffi::fui_render_node_to_rgba(
149                handle,
150                state.width,
151                state.height,
152                state.pixel_bytes.as_mut_ptr() as usize,
153                state.pixel_bytes.len() as u32,
154                scale,
155                x,
156                y,
157            ) != 0
158        }
159    }
160
161    /// Rasterizes a ready retained text layout into the pixel buffer.
162    pub fn render_text_layout(&self, layout: &TextLayout, x: f32, y: f32, scale: f32) -> bool {
163        if !layout.is_ready() {
164            error(
165                "TextLayout",
166                "Bitmap.render_text_layout() called before the TextLayout was ready; register on_ready and render after the callback.",
167            );
168            return false;
169        }
170        let node = layout.draw_node();
171        self.render(&node, x, y, scale)
172    }
173
174    /// Builds and prepares a retained text node for bitmap rasterization.
175    pub fn prepare_text<T: Node>(node: &T) {
176        node.build();
177        crate::bindings::ui::prepare_node(node.handle().raw());
178    }
179
180    /// Invokes `callback` once required fonts and initial app load are ready.
181    pub fn on_text_ready<T: Node + Clone + 'static>(
182        &self,
183        node: &T,
184        callback: impl FnOnce(BitmapTextReadyEventArgs) + 'static,
185    ) -> &Self {
186        let node = node.clone();
187        let required_font_ids = node.required_font_ids_for_preparation();
188        let callback = Rc::new(RefCell::new(Some(callback)));
189        FontFace::when_fonts_loaded(&required_font_ids, move |_| {
190            let node = node.clone();
191            let callback = callback.clone();
192            on_loaded(move |_| {
193                Self::prepare_text(&node);
194                if let Some(callback) = callback.borrow_mut().take() {
195                    callback(BitmapTextReadyEventArgs::EMPTY);
196                }
197            });
198        });
199        self
200    }
201
202    /// Adds a clipped dirty upload region for the next commit.
203    ///
204    /// Empty/outside regions are ignored and at most 16 regions are retained.
205    pub fn dirty_rect(&self, x: u32, y: u32, w: u32, h: u32) -> &Self {
206        let mut state = self.inner.borrow_mut();
207        if w == 0 || h == 0 || x >= state.width || y >= state.height {
208            return self;
209        }
210        let cw = (x + w).min(state.width) - x;
211        let ch = (y + h).min(state.height) - y;
212        if state.dirty_rects.len() < MAX_DIRTY_RECTS {
213            state.dirty_rects.push((x, y, cw, ch));
214        }
215        self
216    }
217
218    /// Clears pending dirty regions so the next commit is a full upload unless
219    /// new regions are added.
220    pub fn clear_dirty_rects(&self) -> &Self {
221        self.inner.borrow_mut().dirty_rects.clear();
222        self
223    }
224
225    /// Reports whether the next commit contains partial upload regions.
226    pub fn has_dirty_rects(&self) -> bool {
227        !self.inner.borrow().dirty_rects.is_empty()
228    }
229
230    /// Flushes offscreen drawing if used, uploads full or dirty pixels, consumes
231    /// dirty regions, marks the texture ready, and returns its texture ID.
232    pub fn commit(&self) -> u32 {
233        let mut state = self.inner.borrow_mut();
234        assert!(!state.disposed, "Bitmap.commit() called after dispose.");
235        if state.canvas_used {
236            if let Some(context) = &state.draw_context {
237                context.flush();
238            }
239            unsafe {
240                ffi::fui_canvas_read_offscreen_pixels(
241                    state.offscreen_id,
242                    state.pixel_bytes.as_mut_ptr() as usize,
243                    state.width,
244                    state.height,
245                )
246            };
247        }
248        if state.dirty_rects.is_empty() {
249            unsafe {
250                ffi::fui_bitmap_commit(
251                    state.texture_id,
252                    state.pixel_bytes.as_ptr() as usize,
253                    state.pixel_bytes.len() as u32,
254                    state.width,
255                    state.height,
256                )
257            };
258        } else {
259            let dirty_rects = std::mem::take(&mut state.dirty_rects);
260            for (x, y, w, h) in dirty_rects {
261                let mut rect_bytes = vec![0u8; (w as usize) * (h as usize) * 4];
262                for row in 0..h as usize {
263                    let src = (((y as usize + row) * state.width as usize) + x as usize) * 4;
264                    let dst = row * w as usize * 4;
265                    let len = w as usize * 4;
266                    rect_bytes[dst..dst + len].copy_from_slice(&state.pixel_bytes[src..src + len]);
267                }
268                unsafe {
269                    ffi::fui_bitmap_commit_dirty(
270                        state.texture_id,
271                        rect_bytes.as_ptr() as usize,
272                        rect_bytes.len() as u32,
273                        state.width,
274                        state.height,
275                        x,
276                        y,
277                        w,
278                        h,
279                    )
280                };
281            }
282        }
283        assets::mark_texture_asset_ready(state.texture_id, state.width as f32, state.height as f32);
284        state.texture_id
285    }
286
287    /// Releases the texture and offscreen surface early.
288    ///
289    /// This operation is idempotent. Drawing and pixel operations after
290    /// disposal are invalid.
291    pub fn dispose(&self) {
292        let mut state = self.inner.borrow_mut();
293        if state.disposed {
294            return;
295        }
296        state.disposed = true;
297        unsafe { ffi::fui_canvas_destroy_offscreen(state.offscreen_id) };
298        unsafe { ffi::fui_bitmap_release(state.texture_id) };
299        state.pixel_bytes.clear();
300        state.draw_context = None;
301    }
302}
303
304impl Drop for Bitmap {
305    fn drop(&mut self) {
306        if Rc::strong_count(&self.inner) == 1 {
307            self.dispose();
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::Bitmap;
315    use crate::drawing::Paint;
316    use crate::ffi::{self, Call};
317    use crate::frame_scheduler;
318    use crate::node::{Node, TextNode};
319    use std::cell::Cell;
320    use std::rc::Rc;
321
322    #[test]
323    fn bitmap_canvas_commit_flushes_offscreen_and_marks_asset_ready() {
324        ffi::test::reset();
325        let bitmap = Bitmap::new(32, 24);
326        let canvas = bitmap.canvas();
327        canvas.draw_rect(0.0, 0.0, 10.0, 12.0, Paint::fill(0xFF00FFFF));
328        bitmap.commit();
329
330        let calls = ffi::test::take_calls();
331        assert!(calls.iter().any(|call| matches!(
332            call,
333            Call::CanvasCreateOffscreen {
334                width: 32,
335                height: 24,
336                ..
337            }
338        )));
339        assert!(calls
340            .iter()
341            .any(|call| matches!(call, Call::CanvasDrawBatch { .. })));
342        assert!(calls.iter().any(|call| matches!(
343            call,
344            Call::CanvasReadOffscreenPixels {
345                width: 32,
346                height: 24,
347                ..
348            }
349        )));
350        assert!(calls.iter().any(|call| matches!(
351            call,
352            Call::BitmapCommit {
353                width: 32,
354                height: 24,
355                ..
356            }
357        )));
358    }
359
360    #[test]
361    fn bitmap_dirty_commit_uses_subrect_upload() {
362        ffi::test::reset();
363        let bitmap = Bitmap::new(8, 6);
364        bitmap.dirty_rect(2, 1, 3, 2).commit();
365
366        let calls = ffi::test::take_calls();
367        assert!(calls.iter().any(|call| matches!(
368            call,
369            Call::BitmapCommitDirty {
370                full_width: 8,
371                full_height: 6,
372                sub_x: 2,
373                sub_y: 1,
374                sub_w: 3,
375                sub_h: 2,
376                ..
377            }
378        )));
379    }
380
381    #[test]
382    fn bitmap_text_ready_builds_detached_text_before_preparing_it() {
383        ffi::test::reset();
384        frame_scheduler::reset_commit_state();
385        let bitmap = Bitmap::new(64, 32);
386        let text = TextNode::new("Detached bitmap text");
387        let fired = Rc::new(Cell::new(false));
388        bitmap.on_text_ready(&text, {
389            let fired = fired.clone();
390            move |_| fired.set(true)
391        });
392
393        frame_scheduler::fire_loaded_callbacks();
394
395        assert!(fired.get());
396        assert!(text.has_built_handle());
397        let calls = ffi::test::take_calls();
398        assert!(calls
399            .iter()
400            .any(|call| matches!(call, Call::PrepareNode { .. })));
401    }
402
403    #[test]
404    fn bitmap_render_retains_host_written_pixels_for_the_next_upload() {
405        ffi::test::reset();
406        let bitmap = Bitmap::new(2, 2);
407        let text = TextNode::new("Rich bitmap text");
408        text.build();
409
410        assert!(bitmap.render(&text, 0.0, 0.0, 1.0));
411        bitmap.commit();
412
413        let calls = ffi::test::take_calls();
414        assert!(calls.iter().any(|call| matches!(
415            call,
416            Call::BitmapCommit { bytes, .. }
417                if bytes.chunks_exact(4).any(|pixel| pixel == [0x3a, 0xc5, 0x6c, 0xff])
418        )));
419    }
420}