Skip to main content

fui/
assets.rs

1use crate::ffi;
2use crate::logger::warn;
3use crate::signal::{Callback, Signal, SubscriptionGuard};
4use std::cell::{Cell, RefCell};
5use std::collections::{HashMap, HashSet};
6use std::rc::Rc;
7use std::thread::LocalKey;
8
9const FIRST_DYNAMIC_SVG_ID: u32 = 0x1000_0000;
10const FIRST_DYNAMIC_TEXTURE_ID: u32 = 0x2000_0000;
11const MAX_DYNAMIC_ASSET_ID: u32 = 0xffff_ffff;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum AssetLoadState {
15    Idle = 0,
16    Loading = 1,
17    Ready = 2,
18    Failed = 3,
19}
20
21#[derive(Clone)]
22pub struct AssetStateSignal {
23    inner: Rc<RefCell<Signal<AssetLoadState>>>,
24}
25
26impl AssetStateSignal {
27    fn new(initial: AssetLoadState) -> Self {
28        Self {
29            inner: Rc::new(RefCell::new(Signal::new(initial))),
30        }
31    }
32
33    pub fn get(&self) -> AssetLoadState {
34        self.inner.borrow().get()
35    }
36
37    pub fn subscribe(&self, callback: Callback) -> SubscriptionGuard {
38        self.inner.borrow_mut().subscribe(callback)
39    }
40
41    fn set(&self, next: AssetLoadState) {
42        let callbacks = self.inner.borrow_mut().set(next);
43        if let Some(callbacks) = callbacks {
44            for callback in callbacks {
45                callback();
46            }
47        }
48    }
49}
50
51#[derive(Clone)]
52struct AssetRecord {
53    state: AssetStateSignal,
54    error: String,
55    width: f32,
56    height: f32,
57    url: String,
58}
59
60impl Default for AssetRecord {
61    fn default() -> Self {
62        Self {
63            state: AssetStateSignal::new(AssetLoadState::Idle),
64            error: String::new(),
65            width: 0.0,
66            height: 0.0,
67            url: String::new(),
68        }
69    }
70}
71
72thread_local! {
73    static SVG_ASSETS: RefCell<HashMap<u32, Rc<RefCell<AssetRecord>>>> = RefCell::new(HashMap::new());
74    static TEXTURE_ASSETS: RefCell<HashMap<u32, Rc<RefCell<AssetRecord>>>> = RefCell::new(HashMap::new());
75    static LOADED_FONT_IDS: RefCell<HashSet<u32>> = RefCell::new(default_loaded_font_ids());
76    static SVG_IDS_BY_URL: RefCell<HashMap<String, u32>> = RefCell::new(HashMap::new());
77    static TEXTURE_IDS_BY_URL: RefCell<HashMap<String, u32>> = RefCell::new(HashMap::new());
78    static SVG_REF_COUNTS: RefCell<HashMap<u32, i32>> = RefCell::new(HashMap::new());
79    static TEXTURE_REF_COUNTS: RefCell<HashMap<u32, i32>> = RefCell::new(HashMap::new());
80    static PINNED_SVG_IDS: RefCell<HashSet<u32>> = RefCell::new(HashSet::new());
81    static PINNED_TEXTURE_IDS: RefCell<HashSet<u32>> = RefCell::new(HashSet::new());
82    static NEXT_DYNAMIC_SVG_ID: Cell<u32> = const { Cell::new(FIRST_DYNAMIC_SVG_ID) };
83    static NEXT_DYNAMIC_TEXTURE_ID: Cell<u32> = const { Cell::new(FIRST_DYNAMIC_TEXTURE_ID) };
84}
85
86fn default_loaded_font_ids() -> HashSet<u32> {
87    HashSet::from([1, 2, 7])
88}
89
90fn with_utf8(value: &str, callback: impl FnOnce(usize, u32)) {
91    let bytes = value.as_bytes();
92    callback(
93        if bytes.is_empty() {
94            0
95        } else {
96            bytes.as_ptr() as usize
97        },
98        bytes.len() as u32,
99    );
100}
101
102fn next_dynamic_svg_id() -> u32 {
103    NEXT_DYNAMIC_SVG_ID.with(|slot| {
104        let next = slot.get();
105        assert!(
106            next != MAX_DYNAMIC_ASSET_ID,
107            "Dynamic SVG asset id space exhausted."
108        );
109        slot.set(next + 1);
110        next
111    })
112}
113
114fn next_dynamic_texture_id() -> u32 {
115    NEXT_DYNAMIC_TEXTURE_ID.with(|slot| {
116        let next = slot.get();
117        assert!(
118            next != MAX_DYNAMIC_ASSET_ID,
119            "Dynamic texture asset id space exhausted."
120        );
121        slot.set(next + 1);
122        next
123    })
124}
125
126fn get_svg_record(svg_id: u32) -> Rc<RefCell<AssetRecord>> {
127    SVG_ASSETS.with(|records| {
128        let mut records = records.borrow_mut();
129        records
130            .entry(svg_id)
131            .or_insert_with(|| Rc::new(RefCell::new(AssetRecord::default())))
132            .clone()
133    })
134}
135
136fn get_texture_record(texture_id: u32) -> Rc<RefCell<AssetRecord>> {
137    TEXTURE_ASSETS.with(|records| {
138        let mut records = records.borrow_mut();
139        records
140            .entry(texture_id)
141            .or_insert_with(|| Rc::new(RefCell::new(AssetRecord::default())))
142            .clone()
143    })
144}
145
146fn begin_load(record: &Rc<RefCell<AssetRecord>>) {
147    let state = {
148        let mut record = record.borrow_mut();
149        record.error.clear();
150        record.width = 0.0;
151        record.height = 0.0;
152        record.state.clone()
153    };
154    state.set(AssetLoadState::Loading);
155}
156
157fn mark_loaded(record: &Rc<RefCell<AssetRecord>>, width: f32, height: f32) {
158    let state = {
159        let mut record = record.borrow_mut();
160        record.error.clear();
161        record.width = width;
162        record.height = height;
163        record.state.clone()
164    };
165    state.set(AssetLoadState::Ready);
166}
167
168fn mark_failed(record: &Rc<RefCell<AssetRecord>>, error: String) {
169    let state = {
170        let mut record = record.borrow_mut();
171        record.error = error;
172        record.width = 0.0;
173        record.height = 0.0;
174        record.state.clone()
175    };
176    state.set(AssetLoadState::Failed);
177}
178
179fn increment_ref_count(ref_counts: &RefCell<HashMap<u32, i32>>, asset_id: u32) {
180    let mut ref_counts = ref_counts.borrow_mut();
181    let next = ref_counts.get(&asset_id).copied().unwrap_or(0) + 1;
182    ref_counts.insert(asset_id, next);
183}
184
185fn decrement_ref_count(ref_counts: &RefCell<HashMap<u32, i32>>, asset_id: u32) -> i32 {
186    let mut ref_counts = ref_counts.borrow_mut();
187    let Some(current) = ref_counts.get(&asset_id).copied() else {
188        return -1;
189    };
190    let next = current - 1;
191    if next <= 0 {
192        ref_counts.remove(&asset_id);
193        0
194    } else {
195        ref_counts.insert(asset_id, next);
196        next
197    }
198}
199
200fn remove_url_binding(
201    url_to_id: &'static LocalKey<RefCell<HashMap<String, u32>>>,
202    url: &str,
203    asset_id: u32,
204) {
205    if url.is_empty() {
206        return;
207    }
208    url_to_id.with(|url_to_id| {
209        let mut url_to_id = url_to_id.borrow_mut();
210        if url_to_id.get(url).copied() == Some(asset_id) {
211            url_to_id.remove(url);
212        }
213    });
214}
215
216fn load_svg_internal(svg_id: u32, url: &str, pinned: bool) {
217    let record = get_svg_record(svg_id);
218    {
219        let mut record_mut = record.borrow_mut();
220        if !record_mut.url.is_empty() && record_mut.url != url {
221            remove_url_binding(&SVG_IDS_BY_URL, &record_mut.url, svg_id);
222        }
223        record_mut.url = url.to_string();
224    }
225    SVG_IDS_BY_URL.with(|map| {
226        map.borrow_mut().insert(url.to_string(), svg_id);
227    });
228    PINNED_SVG_IDS.with(|ids| {
229        let mut ids = ids.borrow_mut();
230        if pinned {
231            ids.insert(svg_id);
232        } else {
233            ids.remove(&svg_id);
234        }
235    });
236    begin_load(&record);
237    with_utf8(url, |ptr, len| unsafe {
238        ffi::fui_load_svg(svg_id, ptr, len);
239    });
240}
241
242fn load_texture_internal(texture_id: u32, url: &str, pinned: bool) {
243    let record = get_texture_record(texture_id);
244    {
245        let mut record_mut = record.borrow_mut();
246        if !record_mut.url.is_empty() && record_mut.url != url {
247            remove_url_binding(&TEXTURE_IDS_BY_URL, &record_mut.url, texture_id);
248        }
249        record_mut.url = url.to_string();
250    }
251    TEXTURE_IDS_BY_URL.with(|map| {
252        map.borrow_mut().insert(url.to_string(), texture_id);
253    });
254    PINNED_TEXTURE_IDS.with(|ids| {
255        let mut ids = ids.borrow_mut();
256        if pinned {
257            ids.insert(texture_id);
258        } else {
259            ids.remove(&texture_id);
260        }
261    });
262    begin_load(&record);
263    with_utf8(url, |ptr, len| unsafe {
264        ffi::fui_load_texture(texture_id, ptr, len);
265    });
266}
267
268pub(crate) fn load_font(font_id: u32, url: &str) {
269    LOADED_FONT_IDS.with(|ids| {
270        ids.borrow_mut().remove(&font_id);
271    });
272    with_utf8(url, |ptr, len| unsafe {
273        ffi::fui_load_font(font_id, ptr, len);
274    });
275}
276
277pub fn allocate_dynamic_texture_id() -> u32 {
278    next_dynamic_texture_id()
279}
280
281pub(crate) fn is_font_loaded(font_id: u32) -> bool {
282    LOADED_FONT_IDS.with(|ids| ids.borrow().contains(&font_id))
283}
284
285pub(crate) fn on_font_loaded(font_id: u32) {
286    LOADED_FONT_IDS.with(|ids| {
287        ids.borrow_mut().insert(font_id);
288    });
289}
290
291pub fn load_svg(svg_id: u32, url: &str) {
292    load_svg_internal(svg_id, url, true);
293}
294
295pub fn load_texture(texture_id: u32, url: &str) {
296    load_texture_internal(texture_id, url, true);
297}
298
299pub fn acquire_svg_asset(url: &str) -> u32 {
300    if url.is_empty() {
301        return 0;
302    }
303    let svg_id = SVG_IDS_BY_URL
304        .with(|map| map.borrow().get(url).copied())
305        .unwrap_or_else(|| {
306            let svg_id = next_dynamic_svg_id();
307            load_svg_internal(svg_id, url, false);
308            svg_id
309        });
310    SVG_REF_COUNTS.with(|ref_counts| increment_ref_count(ref_counts, svg_id));
311    svg_id
312}
313
314pub fn acquire_texture_asset(url: &str) -> u32 {
315    if url.is_empty() {
316        return 0;
317    }
318    let texture_id = TEXTURE_IDS_BY_URL
319        .with(|map| map.borrow().get(url).copied())
320        .unwrap_or_else(|| {
321            let texture_id = next_dynamic_texture_id();
322            load_texture_internal(texture_id, url, false);
323            texture_id
324        });
325    TEXTURE_REF_COUNTS.with(|ref_counts| increment_ref_count(ref_counts, texture_id));
326    texture_id
327}
328
329pub fn release_svg_asset(svg_id: u32) {
330    if svg_id == 0 {
331        return;
332    }
333    let remaining = SVG_REF_COUNTS.with(|ref_counts| decrement_ref_count(ref_counts, svg_id));
334    let pinned = PINNED_SVG_IDS.with(|ids| ids.borrow().contains(&svg_id));
335    if remaining != 0 || pinned {
336        return;
337    }
338    let url = get_svg_record(svg_id).borrow().url.clone();
339    remove_url_binding(&SVG_IDS_BY_URL, &url, svg_id);
340    SVG_ASSETS.with(|records| {
341        records.borrow_mut().remove(&svg_id);
342    });
343    unsafe {
344        ffi::fui_release_svg(svg_id);
345    }
346}
347
348pub fn release_texture_asset(texture_id: u32) {
349    if texture_id == 0 {
350        return;
351    }
352    let remaining =
353        TEXTURE_REF_COUNTS.with(|ref_counts| decrement_ref_count(ref_counts, texture_id));
354    let pinned = PINNED_TEXTURE_IDS.with(|ids| ids.borrow().contains(&texture_id));
355    if remaining != 0 || pinned {
356        return;
357    }
358    let url = get_texture_record(texture_id).borrow().url.clone();
359    remove_url_binding(&TEXTURE_IDS_BY_URL, &url, texture_id);
360    TEXTURE_ASSETS.with(|records| {
361        records.borrow_mut().remove(&texture_id);
362    });
363    unsafe {
364        ffi::fui_release_texture(texture_id);
365    }
366}
367
368pub fn ensure_svg_asset(url: &str) -> u32 {
369    acquire_svg_asset(url)
370}
371
372pub fn ensure_texture_asset(url: &str) -> u32 {
373    acquire_texture_asset(url)
374}
375
376pub fn get_svg_asset_state(svg_id: u32) -> AssetStateSignal {
377    get_svg_record(svg_id).borrow().state.clone()
378}
379
380pub fn get_texture_asset_state(texture_id: u32) -> AssetStateSignal {
381    get_texture_record(texture_id).borrow().state.clone()
382}
383
384pub fn get_svg_asset_state_value(svg_id: u32) -> AssetLoadState {
385    get_svg_record(svg_id).borrow().state.get()
386}
387
388pub fn get_texture_asset_state_value(texture_id: u32) -> AssetLoadState {
389    get_texture_record(texture_id).borrow().state.get()
390}
391
392pub fn get_svg_asset_error(svg_id: u32) -> String {
393    get_svg_record(svg_id).borrow().error.clone()
394}
395
396pub fn get_svg_asset_url(svg_id: u32) -> String {
397    get_svg_record(svg_id).borrow().url.clone()
398}
399
400pub fn get_svg_asset_width(svg_id: u32) -> f32 {
401    get_svg_record(svg_id).borrow().width
402}
403
404pub fn get_svg_asset_height(svg_id: u32) -> f32 {
405    get_svg_record(svg_id).borrow().height
406}
407
408pub fn get_texture_asset_error(texture_id: u32) -> String {
409    get_texture_record(texture_id).borrow().error.clone()
410}
411
412pub fn get_texture_asset_url(texture_id: u32) -> String {
413    get_texture_record(texture_id).borrow().url.clone()
414}
415
416pub fn get_texture_asset_width(texture_id: u32) -> f32 {
417    get_texture_record(texture_id).borrow().width
418}
419
420pub fn get_texture_asset_height(texture_id: u32) -> f32 {
421    get_texture_record(texture_id).borrow().height
422}
423
424pub fn mark_texture_asset_ready(texture_id: u32, width: f32, height: f32) {
425    mark_loaded(&get_texture_record(texture_id), width, height);
426}
427
428pub fn on_svg_loaded(svg_id: u32, width: f32, height: f32) {
429    mark_loaded(&get_svg_record(svg_id), width, height);
430}
431
432pub fn on_svg_failed(svg_id: u32, error: String) {
433    let record = get_svg_record(svg_id);
434    let url = record.borrow().url.clone();
435    let message = if error.is_empty() {
436        "unknown error".to_string()
437    } else {
438        error.clone()
439    };
440    mark_failed(&record, error);
441    warn(
442        "Assets",
443        &format!("SVG load failed for \"{url}\": {message}"),
444    );
445}
446
447pub fn on_texture_loaded(texture_id: u32, width: f32, height: f32) {
448    mark_loaded(&get_texture_record(texture_id), width, height);
449}
450
451pub fn on_texture_failed(texture_id: u32, error: String) {
452    let record = get_texture_record(texture_id);
453    let url = record.borrow().url.clone();
454    let message = if error.is_empty() {
455        "unknown error".to_string()
456    } else {
457        error.clone()
458    };
459    mark_failed(&record, error);
460    warn(
461        "Assets",
462        &format!("Texture load failed for \"{url}\": {message}"),
463    );
464}
465
466#[cfg(test)]
467pub(crate) fn test_reset() {
468    SVG_ASSETS.with(|slot| slot.borrow_mut().clear());
469    TEXTURE_ASSETS.with(|slot| slot.borrow_mut().clear());
470    SVG_IDS_BY_URL.with(|slot| slot.borrow_mut().clear());
471    TEXTURE_IDS_BY_URL.with(|slot| slot.borrow_mut().clear());
472    SVG_REF_COUNTS.with(|slot| slot.borrow_mut().clear());
473    TEXTURE_REF_COUNTS.with(|slot| slot.borrow_mut().clear());
474    PINNED_SVG_IDS.with(|slot| slot.borrow_mut().clear());
475    PINNED_TEXTURE_IDS.with(|slot| slot.borrow_mut().clear());
476    LOADED_FONT_IDS.with(|ids| {
477        *ids.borrow_mut() = default_loaded_font_ids();
478    });
479    NEXT_DYNAMIC_SVG_ID.with(|slot| slot.set(FIRST_DYNAMIC_SVG_ID));
480    NEXT_DYNAMIC_TEXTURE_ID.with(|slot| slot.set(FIRST_DYNAMIC_TEXTURE_ID));
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::ffi::{self, Call};
487    use std::cell::Cell;
488    use std::rc::Rc;
489
490    #[test]
491    fn asset_loaders_emit_host_calls() {
492        test_reset();
493        ffi::test::reset();
494
495        load_font(1, "/v2/fonts/NotoSans-Regular.ttf");
496        load_svg(2, "data:image/svg+xml,%3Csvg/%3E");
497        load_texture(3, "/v2/fui-as/demo/demo-texture.png");
498
499        let calls = ffi::test::take_calls();
500        assert!(calls.iter().any(|call| matches!(call, Call::LoadFont { font_id: 1, url } if url == "/v2/fonts/NotoSans-Regular.ttf")));
501        assert!(calls.iter().any(|call| matches!(call, Call::LoadSvg { svg_id: 2, url } if url == "data:image/svg+xml,%3Csvg/%3E")));
502        assert!(calls.iter().any(|call| matches!(call, Call::LoadTexture { texture_id: 3, url } if url == "/v2/fui-as/demo/demo-texture.png")));
503    }
504
505    #[test]
506    fn acquired_assets_share_url_and_release_when_last_ref_drops() {
507        test_reset();
508        ffi::test::reset();
509
510        let first = acquire_texture_asset("/img/a.png");
511        let second = acquire_texture_asset("/img/a.png");
512        assert_eq!(first, second);
513        let calls = ffi::test::take_calls();
514        let load_calls = calls
515            .iter()
516            .filter(|call| matches!(call, Call::LoadTexture { .. }))
517            .count();
518        assert_eq!(load_calls, 1);
519
520        release_texture_asset(first);
521        let calls = ffi::test::take_calls();
522        assert!(!calls.iter().any(
523            |call| matches!(call, Call::ReleaseTexture { texture_id } if *texture_id == first)
524        ));
525
526        release_texture_asset(second);
527        let calls = ffi::test::take_calls();
528        assert!(calls.iter().any(
529            |call| matches!(call, Call::ReleaseTexture { texture_id } if *texture_id == first)
530        ));
531    }
532
533    #[test]
534    fn pinned_assets_are_not_released_by_ref_count_drop() {
535        test_reset();
536        ffi::test::reset();
537
538        load_svg(55, "/img/pinned.svg");
539        let acquired = acquire_svg_asset("/img/pinned.svg");
540        assert_eq!(acquired, 55);
541        ffi::test::take_calls();
542
543        release_svg_asset(acquired);
544        let calls = ffi::test::take_calls();
545        assert!(!calls
546            .iter()
547            .any(|call| matches!(call, Call::ReleaseSvg { svg_id } if *svg_id == acquired)));
548    }
549
550    #[test]
551    fn asset_state_signal_notifies_and_tracks_ready_and_failed_payloads() {
552        test_reset();
553        let texture_id = acquire_texture_asset("/img/ready.png");
554        let state = get_texture_asset_state(texture_id);
555        assert_eq!(state.get(), AssetLoadState::Loading);
556
557        let hits = Rc::new(Cell::new(0));
558        let hits_clone = hits.clone();
559        let callback: Callback = Rc::new(move || hits_clone.set(hits_clone.get() + 1));
560        let _guard = state.subscribe(callback);
561
562        on_texture_loaded(texture_id, 64.0, 32.0);
563        assert_eq!(state.get(), AssetLoadState::Ready);
564        assert_eq!(get_texture_asset_width(texture_id), 64.0);
565        assert_eq!(get_texture_asset_height(texture_id), 32.0);
566
567        on_texture_failed(texture_id, "broken".to_string());
568        assert_eq!(state.get(), AssetLoadState::Failed);
569        assert_eq!(get_texture_asset_error(texture_id), "broken");
570        assert_eq!(hits.get(), 2);
571    }
572}