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}