Skip to main content

tessera_ui/
asset.rs

1//! On-demand resources owned by a UI session.
2//!
3//! Pass generated assets to components or retain a resource and observe its loading state.
4
5use std::{
6    cell::RefCell,
7    collections::HashMap,
8    hash::{Hash, Hasher},
9    io,
10    path::{Path, PathBuf},
11    sync::{Arc, Weak},
12};
13
14use crate::{State, StateOwner, local_runtime_resource};
15
16#[cfg(not(target_family = "wasm"))]
17use crate::runtime_resource;
18
19#[cfg(target_os = "android")]
20mod android;
21#[cfg(not(any(target_os = "android", target_family = "wasm")))]
22mod native;
23#[cfg(target_family = "wasm")]
24mod web;
25
26/// A resource that may still be loading.
27#[derive(Clone, Debug)]
28pub enum LoadState<T> {
29    /// The resource has been requested.
30    Loading,
31    /// The resource is available.
32    Ready(T),
33    /// Loading or decoding failed.
34    Failed(Arc<io::Error>),
35}
36
37/// A generated, immutable asset reference.
38#[derive(Clone, Copy)]
39pub struct Asset {
40    path: &'static str,
41    bytes: Option<&'static [u8]>,
42}
43
44/// Where a resource's bytes come from.
45#[derive(Clone)]
46pub enum Source {
47    /// A generated asset.
48    Asset(Asset),
49    /// A file path, or a URL on the web.
50    Path(Arc<str>),
51    /// Bytes in memory. Equal bytes share one resource.
52    Bytes(Arc<[u8]>),
53}
54
55/// A font resource and the Unicode ranges that request it as a fallback.
56#[derive(Clone, Copy)]
57pub struct FontAsset {
58    /// Font data.
59    pub asset: Asset,
60    /// Family selected by text styles.
61    pub family: &'static str,
62    /// Inclusive Unicode ranges that trigger automatic fallback loading.
63    pub fallback: &'static [(u32, u32)],
64    /// Restricts requests to these supported ranges; empty means unrestricted.
65    pub coverage: &'static [(u32, u32)],
66}
67
68/// A shared resource lifetime. Retain it while observing its loading state.
69#[derive(Clone)]
70pub struct Resource<T: Send + Sync + 'static>(Arc<ResourceEntry<T>>);
71
72struct ResourceEntry<T: Send + Sync + 'static> {
73    source: Source,
74    owner: StateOwner,
75    state: State<LoadState<T>>,
76}
77
78impl<T: Send + Sync + 'static> std::fmt::Debug for Resource<T> {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_tuple("Resource").field(&self.0.source).finish()
81    }
82}
83
84impl<T: Send + Sync + 'static> PartialEq for Resource<T> {
85    fn eq(&self, other: &Self) -> bool {
86        Arc::ptr_eq(&self.0, &other.0)
87    }
88}
89impl<T: Send + Sync + 'static> Eq for Resource<T> {}
90
91impl<T: Clone + Send + Sync + 'static> Resource<T> {
92    /// Requests a decoded resource in the active UI session.
93    ///
94    /// Reading and decoding run off the UI thread. Retain the returned handle
95    /// while using the resource; live requests for the same source and decoder
96    /// share one result, and completion invalidates the builds and measurements
97    /// that observed it.
98    pub fn load(source: impl Into<Source>, decode: fn(Arc<[u8]>) -> io::Result<T>) -> Self {
99        let source = source.into();
100        let cache = local_runtime_resource::<ResourceCache<T>>();
101        let key = (source.clone(), decode as usize);
102        let resource = {
103            let mut entries = cache.0.borrow_mut();
104            if let Some(entry) = entries.get(&key).and_then(Weak::upgrade) {
105                return Resource(entry);
106            }
107            entries.retain(|_, entry| entry.strong_count() > 0);
108            let owner = StateOwner::new();
109            let state = owner.state_with_key((), || LoadState::Loading);
110            let entry = Arc::new(ResourceEntry {
111                source: source.clone(),
112                owner,
113                state,
114            });
115            entries.insert(key, Arc::downgrade(&entry));
116            Resource(entry)
117        };
118        let state = resource.0.state;
119        let label = format!("{source:?}");
120        let finish = move |result: io::Result<T>| {
121            state.set(match result {
122                Ok(value) => LoadState::Ready(value),
123                Err(error) => {
124                    tracing::warn!(source = label, %error, "resource loading failed");
125                    LoadState::Failed(Arc::new(error))
126                }
127            });
128        };
129        let dispatcher = resource.0.owner.dispatcher();
130        #[cfg(target_family = "wasm")]
131        wasm_bindgen_futures::spawn_local(async move {
132            let result = source.read().await;
133            if dispatcher.is_alive() {
134                let result = result.and_then(decode);
135                dispatcher.dispatch(move || finish(result));
136            }
137        });
138        #[cfg(not(target_family = "wasm"))]
139        {
140            let in_flight = runtime_resource::<InFlight>();
141            *in_flight.0.lock() += 1;
142            let loading = Loading(in_flight);
143            let _ = WORKERS.send(Box::new(move || {
144                let _loading = loading;
145                if dispatcher.is_alive() {
146                    let result = source.read().and_then(decode);
147                    dispatcher.dispatch(move || finish(result));
148                }
149            }));
150        }
151        resource
152    }
153
154    /// Observes loading state on the owning UI thread and tracks build or measurement.
155    pub fn get(&self) -> LoadState<T> {
156        self.0.state.get()
157    }
158}
159
160struct ResourceCache<T: Send + Sync + 'static>(
161    RefCell<HashMap<(Source, usize), Weak<ResourceEntry<T>>>>,
162);
163
164impl<T: Send + Sync + 'static> Default for ResourceCache<T> {
165    fn default() -> Self {
166        Self(RefCell::default())
167    }
168}
169
170#[cfg(not(target_family = "wasm"))]
171type Work = Box<dyn FnOnce() + Send>;
172
173#[cfg(not(target_family = "wasm"))]
174static WORKERS: std::sync::LazyLock<std::sync::mpsc::Sender<Work>> =
175    std::sync::LazyLock::new(|| {
176        let (sender, receiver) = std::sync::mpsc::channel::<Work>();
177        let receiver = Arc::new(parking_lot::Mutex::new(receiver));
178        let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4));
179        for _ in 0..threads {
180            let receiver = Arc::clone(&receiver);
181            std::thread::spawn(move || {
182                loop {
183                    let work = receiver.lock().recv();
184                    match work {
185                        Ok(work) => work(),
186                        Err(_) => break,
187                    }
188                }
189            });
190        }
191        sender
192    });
193
194#[cfg(not(target_family = "wasm"))]
195#[derive(Default)]
196struct InFlight(parking_lot::Mutex<usize>, parking_lot::Condvar);
197
198#[cfg(not(target_family = "wasm"))]
199struct Loading(Arc<InFlight>);
200
201#[cfg(not(target_family = "wasm"))]
202impl Drop for Loading {
203    fn drop(&mut self) {
204        *self.0.0.lock() -= 1;
205        self.0.1.notify_all();
206    }
207}
208
209#[cfg(all(any(test, feature = "testing"), not(target_family = "wasm")))]
210pub(crate) fn in_flight() -> bool {
211    *runtime_resource::<InFlight>().0.lock() > 0
212}
213
214/// Blocks until this session's requested resources have been delivered.
215#[cfg(all(
216    any(test, feature = "testing", feature = "headless"),
217    not(target_family = "wasm")
218))]
219pub(crate) fn settle() {
220    let in_flight = runtime_resource::<InFlight>();
221    let mut count = in_flight.0.lock();
222    while *count > 0 {
223        in_flight.1.wait(&mut count);
224    }
225}
226
227impl std::fmt::Debug for Asset {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        self.path.fmt(f)
230    }
231}
232
233impl PartialEq for Asset {
234    fn eq(&self, other: &Self) -> bool {
235        self.path == other.path
236    }
237}
238impl Eq for Asset {}
239
240impl Hash for Asset {
241    fn hash<H: Hasher>(&self, state: &mut H) {
242        self.path.hash(state);
243    }
244}
245
246impl Asset {
247    /// Creates an asset backed by static bytes.
248    #[doc(hidden)]
249    pub const fn embedded(path: &'static str, bytes: &'static [u8]) -> Self {
250        Self {
251            path,
252            bytes: Some(bytes),
253        }
254    }
255
256    /// Creates an asset delivered with the application.
257    #[doc(hidden)]
258    pub const fn platform(path: &'static str) -> Self {
259        Self { path, bytes: None }
260    }
261
262    /// Returns the asset's content-addressed package path.
263    pub fn path(self) -> &'static str {
264        self.path
265    }
266}
267
268impl std::fmt::Debug for Source {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::Asset(asset) => asset.fmt(f),
272            Self::Path(path) => path.fmt(f),
273            Self::Bytes(bytes) => write!(f, "{} bytes", bytes.len()),
274        }
275    }
276}
277
278impl PartialEq for Source {
279    fn eq(&self, other: &Self) -> bool {
280        match (self, other) {
281            (Self::Asset(a), Self::Asset(b)) => a == b,
282            (Self::Path(a), Self::Path(b)) => a == b,
283            (Self::Bytes(a), Self::Bytes(b)) => Arc::ptr_eq(a, b) || a == b,
284            _ => false,
285        }
286    }
287}
288impl Eq for Source {}
289
290impl Hash for Source {
291    fn hash<H: Hasher>(&self, state: &mut H) {
292        std::mem::discriminant(self).hash(state);
293        match self {
294            Self::Asset(asset) => asset.hash(state),
295            Self::Path(path) => path.hash(state),
296            Self::Bytes(bytes) => bytes.hash(state),
297        }
298    }
299}
300
301impl Source {
302    #[cfg(not(target_family = "wasm"))]
303    fn read(&self) -> io::Result<Arc<[u8]>> {
304        match self {
305            Self::Asset(Asset {
306                bytes: Some(bytes), ..
307            }) => Ok(Arc::from(*bytes)),
308            #[cfg(target_os = "android")]
309            Self::Asset(asset) => android::read(asset.path),
310            #[cfg(not(target_os = "android"))]
311            Self::Asset(asset) => native::path(asset.path)
312                .and_then(std::fs::read)
313                .map(Arc::from),
314            Self::Path(path) => std::fs::read(&**path).map(Arc::from),
315            Self::Bytes(bytes) => Ok(Arc::clone(bytes)),
316        }
317    }
318
319    #[cfg(target_family = "wasm")]
320    async fn read(&self) -> io::Result<Arc<[u8]>> {
321        match self {
322            Self::Asset(Asset {
323                bytes: Some(bytes), ..
324            }) => Ok(Arc::from(*bytes)),
325            Self::Asset(asset) => web::read(asset.path).await,
326            Self::Path(path) => web::read(path).await,
327            Self::Bytes(bytes) => Ok(Arc::clone(bytes)),
328        }
329    }
330}
331
332macro_rules! source_from {
333    ($($ty:ty => |$value:ident| $source:expr),* $(,)?) => {$(
334        impl From<$ty> for Source {
335            fn from($value: $ty) -> Self {
336                $source
337            }
338        }
339    )*};
340}
341
342source_from! {
343    Asset => |asset| Self::Asset(asset),
344    &str => |path| Self::Path(path.into()),
345    String => |path| Self::Path(path.into()),
346    Arc<str> => |path| Self::Path(path),
347    &Path => |path| Self::Path(path.to_string_lossy().into()),
348    PathBuf => |path| Self::Path(path.to_string_lossy().into()),
349    &[u8] => |bytes| Self::Bytes(bytes.into()),
350    Vec<u8> => |bytes| Self::Bytes(bytes.into()),
351    Arc<[u8]> => |bytes| Self::Bytes(bytes),
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn delivered<T: Clone + Send + Sync + 'static>(resource: &Resource<T>) -> LoadState<T> {
359        assert!(matches!(resource.get(), LoadState::Loading));
360        settle();
361        crate::dispatcher::flush();
362        resource.get()
363    }
364
365    fn upper(bytes: Arc<[u8]>) -> io::Result<Arc<[u8]>> {
366        assert!(std::thread::current().name().is_none());
367        Ok(Arc::from(bytes.to_ascii_uppercase()))
368    }
369
370    #[cfg(not(any(target_family = "wasm", target_os = "android")))]
371    #[test]
372    fn sources_decode_off_the_ui_thread_and_share_live_results() {
373        let file = tempfile::NamedTempFile::new().unwrap();
374        std::fs::write(file.path(), b"file").unwrap();
375        crate::RuntimeSession::new().run(|| {
376            let asset = Asset::embedded("test/shared", b"original");
377            let decoded = Resource::load(asset, upper);
378            let LoadState::Ready(bytes) = delivered(&decoded) else {
379                panic!("decoded asset")
380            };
381            assert_eq!(&*bytes, b"ORIGINAL");
382            assert_eq!(decoded, Resource::load(asset, upper));
383            let memory = Resource::load(b"bytes".to_vec(), upper);
384            assert_eq!(memory, Resource::load(&b"bytes"[..], upper));
385            assert!(matches!(delivered(&memory), LoadState::Ready(bytes) if &*bytes == b"BYTES"));
386            let LoadState::Ready(bytes) = delivered(&Resource::load(file.path(), Ok)) else {
387                panic!("file")
388            };
389            assert_eq!(&*bytes, b"file");
390            assert!(matches!(
391                delivered(&Resource::<()>::load(asset, |_| Err(io::Error::other(
392                    "invalid"
393                )))),
394                LoadState::Failed(_)
395            ));
396        });
397    }
398}