Skip to main content

rlvgl_core/
asset.rs

1//! Asset path, handle, registry, cache, and source implementations.
2//!
3//! This module is the LPAR-09 asset and filesystem source runtime. It layers
4//! above the lower-half traits defined in [`crate::fs`] — [`crate::fs::AssetSource`],
5//! [`crate::fs::AssetRead`], [`crate::fs::AssetError`] — and provides:
6//!
7//! - [`AssetPath`]: a typed source-kind + path enum (the `lv_fs_drv_t` drive-letter
8//!   analog in typed form).
9//! - [`AssetHandle`]: an opaque `u32` token returned by [`AssetRegistry::register`].
10//!   The registry owns the interned `AssetPath`; the handle is `Copy` so any widget
11//!   can hold one.
12//! - [`AssetRegistry`]: multi-source dispatcher; holds up to
13//!   [`ASSET_REGISTRY_MAX_SOURCES`] registered sources. `resolve_image` performs
14//!   cache lookup, source open, decode dispatch, and cache insertion in one call.
15//! - [`SlotCache`]: a const-generic bounded LRU cache keyed by [`crate::image::CacheHandle`].
16//! - [`EmbeddedAssetSource`]: `no_std + alloc`; static slice lookup.
17//! - [`MemoryAssetSource`]: `no_std + alloc`; runtime-populated heap table.
18//! - [`SimAssetSource`]: `std`-only; host filesystem lookup (gated behind
19//!   `cfg(not(target_os = "none"))` plus the `sim` feature).
20//!
21//! # `no_std` / `std` split
22//!
23//! Everything in this module except [`SimAssetSource`] compiles under
24//! `no_std + alloc`. Decode dispatch to the PNG/JPEG/GIF plugins additionally
25//! requires the `png`, `jpeg`, or `gif` feature (those plugins use `std::io::Cursor`
26//! today; making them `no_std` is deferred-Safe per LPAR-09 §14).
27//!
28//! # Determinism
29//!
30//! [`SlotCache`] uses a monotonic `u32` LRU counter. Given a fixed sequence of
31//! `get`/`insert` calls starting from a freshly constructed cache, the eviction
32//! order is fully deterministic. Tests that need reproducible cache state should
33//! use `SlotCache::new()` and drive calls in a fixed order.
34
35extern crate alloc;
36
37use alloc::borrow::ToOwned;
38use alloc::boxed::Box;
39use alloc::string::String;
40use alloc::vec::Vec;
41
42use crate::fs::{AssetError, AssetRead, AssetSource, FsError};
43use crate::image::{CacheHandle, ImageDescriptor, PixelFormat};
44
45// ── AssetPath ────────────────────────────────────────────────────────────────
46
47/// A typed value naming an asset within a specific source kind.
48///
49/// This is the LPAR-09 analog of LVGL's drive-letter prefix (`A:path`, `S:path`).
50/// Instead of a runtime string prefix, the enum variant encodes the source kind at
51/// the Rust type level: there is no string to parse and no possibility of silently
52/// routing a path to the wrong source.
53///
54/// # Source-kind set
55///
56/// The source-kind set is **frozen** (Standards Action per LPAR-09 §9).
57/// Adding a new variant requires a §15 amendment to LPAR-09.
58///
59/// # Path string format
60///
61/// Within each source kind paths are `/`-separated UTF-8 with no required leading
62/// `/`, consistent with [`AssetSource::open`]'s `"fonts/regular.bin"` convention.
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub enum AssetPath {
65    /// A static asset linked into the binary via `include_bytes!`.
66    ///
67    /// The `&'static str` is the symbol name used to look up the byte slice in
68    /// the [`EmbeddedAssetSource`] table. Lookup is infallible if the symbol
69    /// exists; otherwise `AssetError::Fs(FsError::NoSuchFile)` is returned.
70    Embedded(&'static str),
71    /// An asset on a FAT volume reached through a `BlockDevice`.
72    ///
73    /// Available behind the `fatfs` feature in `no_std + alloc` builds.
74    /// The `String` is the file path within the FAT volume (leading `/` stripped
75    /// before passing to the FAT driver).
76    Fatfs(String),
77    /// An asset on the host filesystem (simulator builds only).
78    ///
79    /// Only available when the `sim` feature is enabled and the target is not
80    /// `target_os = "none"`. The `String` is a relative or absolute host path.
81    Sim(String),
82    /// An asset in a runtime-populated in-RAM table.
83    ///
84    /// Useful for test asset injection and for content pre-loaded from FATFS or
85    /// another source before the widget tree starts. `no_std + alloc`.
86    Memory(String),
87}
88
89impl AssetPath {
90    /// Return the path string within this source kind.
91    ///
92    /// For [`AssetPath::Embedded`] this is the symbol name. For all others it is
93    /// the runtime path string.
94    pub fn path_str(&self) -> &str {
95        match self {
96            AssetPath::Embedded(s) => s,
97            AssetPath::Fatfs(s) | AssetPath::Sim(s) | AssetPath::Memory(s) => s.as_str(),
98        }
99    }
100
101    /// Return a string label identifying the source kind (for diagnostics).
102    pub fn source_kind(&self) -> &'static str {
103        match self {
104            AssetPath::Embedded(_) => "embedded",
105            AssetPath::Fatfs(_) => "fatfs",
106            AssetPath::Sim(_) => "sim",
107            AssetPath::Memory(_) => "memory",
108        }
109    }
110}
111
112// ── AssetHandle ───────────────────────────────────────────────────────────────
113
114/// Opaque token identifying a source-backed asset registered with an
115/// [`AssetRegistry`].
116///
117/// The token is a `Copy` `u32`; the registry owns the interned [`AssetPath`].
118/// Callers MUST call [`AssetRegistry::resolve_image`] to ensure decoded pixel
119/// data is present before blitting. The handle remains valid for the lifetime
120/// of the registry that issued it.
121///
122/// Handle value `0` is **reserved as the null/invalid handle**. Valid handles
123/// start at `1`.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub struct AssetHandle(u32);
126
127impl AssetHandle {
128    /// Return the raw `u32` token.
129    pub const fn as_u32(self) -> u32 {
130        self.0
131    }
132}
133
134// ── SlotCache ─────────────────────────────────────────────────────────────────
135
136/// Entry stored in a [`SlotCache`] slot.
137struct CacheEntry {
138    /// Cache key issued at insertion.
139    handle: CacheHandle,
140    /// Monotonic timestamp — higher is more recent.
141    ts: u32,
142    /// Decoded image descriptor (with `'static` pixel data).
143    desc: ImageDescriptor<'static>,
144}
145
146/// Bounded LRU image cache backed by a fixed-size array.
147///
148/// # Const-generic parameter
149///
150/// `N` is the number of slots. Choose a value appropriate to the platform's
151/// available RAM: on the STM32H747I-DISCO a value of 8–16 is typical.
152///
153/// # Eviction policy
154///
155/// On insertion when all `N` slots are occupied, the slot with the smallest
156/// (oldest) `ts` value is evicted. On a cache hit, the matching slot's `ts`
157/// is updated to the current counter value and the counter increments.
158///
159/// # Determinism
160///
161/// Given a fixed sequence of `get`/`insert` calls starting from a freshly
162/// constructed `SlotCache`, the eviction order is fully deterministic.
163/// Tests that require reproducible cache behaviour should create a fresh
164/// `SlotCache::new()` and drive calls in a known order.
165///
166/// # Handle numbering
167///
168/// [`CacheHandle`] values are assigned sequentially starting at `1` (0 is
169/// reserved). Within a session handles are never reused.
170pub struct SlotCache<const N: usize> {
171    slots: [Option<CacheEntry>; N],
172    /// Monotonic LRU timestamp counter. Starts at 1; advances on every hit
173    /// (`get`) and every insertion (`insert`).
174    ts_counter: u32,
175    /// Counter for issuing new [`CacheHandle`] values. Starts at 1.
176    handle_counter: u32,
177}
178
179impl<const N: usize> SlotCache<N> {
180    /// Create an empty cache with all slots vacant.
181    pub const fn new() -> Self {
182        // SAFETY: `None` is valid for `Option<CacheEntry>`. We use a manual
183        // array-init trick because `CacheEntry` is not `Copy`.
184        #[allow(clippy::declare_interior_mutable_const)]
185        const NONE_ENTRY: Option<CacheEntry> = None;
186        Self {
187            slots: [NONE_ENTRY; N],
188            ts_counter: 1,
189            handle_counter: 1,
190        }
191    }
192
193    /// Look up `handle` in the cache.
194    ///
195    /// On a hit the slot's timestamp is updated (LRU touch) and a reference to
196    /// the cached [`ImageDescriptor`] is returned. On a miss `None` is returned.
197    pub fn get(&mut self, handle: CacheHandle) -> Option<&ImageDescriptor<'static>> {
198        let ts = self.ts_counter;
199        self.ts_counter = self.ts_counter.wrapping_add(1);
200        for entry in self.slots.iter_mut().flatten() {
201            if entry.handle == handle {
202                entry.ts = ts;
203                // Re-borrow immutably to satisfy the borrow checker.
204                break;
205            }
206        }
207        // Second pass for immutable return.
208        for entry in self.slots.iter().flatten() {
209            if entry.handle == handle {
210                return Some(&entry.desc);
211            }
212        }
213        None
214    }
215
216    /// Insert `descriptor` into the cache and return its [`CacheHandle`].
217    ///
218    /// If all slots are occupied, the least-recently-used entry is evicted first.
219    /// The returned handle is unique within the session.
220    pub fn insert(&mut self, descriptor: ImageDescriptor<'static>) -> CacheHandle {
221        let handle = CacheHandle::new(self.handle_counter);
222        self.handle_counter = self.handle_counter.wrapping_add(1);
223        let ts = self.ts_counter;
224        self.ts_counter = self.ts_counter.wrapping_add(1);
225
226        // Find a vacant slot first.
227        for slot in &mut self.slots {
228            if slot.is_none() {
229                *slot = Some(CacheEntry {
230                    handle,
231                    ts,
232                    desc: descriptor,
233                });
234                return handle;
235            }
236        }
237
238        // All slots occupied — evict the LRU entry (smallest `ts`).
239        let evict_idx = self
240            .slots
241            .iter()
242            .enumerate()
243            .filter_map(|(i, s)| s.as_ref().map(|e| (i, e.ts)))
244            .min_by_key(|&(_, t)| t)
245            .map(|(i, _)| i)
246            .expect("N >= 1 guaranteed by construction");
247        self.slots[evict_idx] = Some(CacheEntry {
248            handle,
249            ts,
250            desc: descriptor,
251        });
252        handle
253    }
254
255    /// Evict the entry identified by `handle` if it is present.
256    ///
257    /// After eviction the decoded pixels are dropped. The next
258    /// `resolve_image` call for the same asset will re-decode from the source.
259    pub fn evict(&mut self, handle: CacheHandle) {
260        for slot in &mut self.slots {
261            if slot.as_ref().is_some_and(|e| e.handle == handle) {
262                *slot = None;
263                return;
264            }
265        }
266    }
267
268    /// Return the number of occupied slots.
269    pub fn len(&self) -> usize {
270        self.slots.iter().filter(|s| s.is_some()).count()
271    }
272
273    /// Return `true` if no slots are occupied.
274    pub fn is_empty(&self) -> bool {
275        self.len() == 0
276    }
277}
278
279impl<const N: usize> Default for SlotCache<N> {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285impl<const N: usize> crate::image::ImageCache<'static> for SlotCache<N> {
286    fn get(&self, _handle: CacheHandle) -> Option<&ImageDescriptor<'static>> {
287        // The `ImageCache` trait takes `&self` but `SlotCache::get` needs
288        // `&mut self` for the LRU timestamp update. Trait implementors that
289        // need mutability should use `AssetRegistry` which holds the cache
290        // behind interior mutability or mutable references.
291        for entry in self.slots.iter().flatten() {
292            if entry.handle == _handle {
293                return Some(&entry.desc);
294            }
295        }
296        None
297    }
298
299    fn put(&mut self, descriptor: ImageDescriptor<'static>) -> CacheHandle {
300        self.insert(descriptor)
301    }
302}
303
304// ── SourceKind tag ────────────────────────────────────────────────────────────
305
306/// Internal tag used by [`AssetRegistry`] to match a registered [`AssetSource`]
307/// to an [`AssetPath`] variant.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309enum SourceKind {
310    Embedded,
311    Fatfs,
312    Sim,
313    Memory,
314}
315
316impl AssetPath {
317    fn kind(&self) -> SourceKind {
318        match self {
319            AssetPath::Embedded(_) => SourceKind::Embedded,
320            AssetPath::Fatfs(_) => SourceKind::Fatfs,
321            AssetPath::Sim(_) => SourceKind::Sim,
322            AssetPath::Memory(_) => SourceKind::Memory,
323        }
324    }
325}
326
327// ── AssetRegistry ─────────────────────────────────────────────────────────────
328
329/// Maximum number of [`AssetSource`] implementations that can be registered
330/// in a single [`AssetRegistry`].
331///
332/// The value is `4` — one per source kind. Increasing this requires an Expert
333/// Review entry in LPAR-09 §9.
334pub const ASSET_REGISTRY_MAX_SOURCES: usize = 4;
335
336/// Registry entry pairing a source kind with its concrete [`AssetSource`]
337/// implementation.
338struct RegistrySource {
339    kind: SourceKind,
340    source: Box<dyn AssetSource>,
341}
342
343/// Inter-table record mapping an [`AssetHandle`] to its [`AssetPath`] and
344/// the optional [`CacheHandle`] for any currently-cached decoded pixels.
345struct HandleRecord {
346    asset_handle: AssetHandle,
347    path: AssetPath,
348    cache_handle: Option<CacheHandle>,
349}
350
351/// Multi-source asset dispatcher.
352///
353/// The registry holds up to [`ASSET_REGISTRY_MAX_SOURCES`] registered
354/// [`AssetSource`] implementations (one per source kind), an intern table
355/// mapping [`AssetHandle`] tokens to their [`AssetPath`]s, and a
356/// [`SlotCache`] for decoded [`ImageDescriptor`]s.
357///
358/// # Usage
359///
360/// ```ignore
361/// let mut registry = AssetRegistry::<8>::new();
362/// registry.register_source(AssetPath::Embedded(""),
363///     Box::new(EmbeddedAssetSource::new(&ASSET_TABLE)))?;
364/// let handle = registry.register(AssetPath::Embedded("icons/ok.raw"));
365/// let desc = registry.resolve_image(handle)?;
366/// ```
367pub struct AssetRegistry<const CACHE_SLOTS: usize = 8> {
368    sources: Vec<RegistrySource>,
369    handles: Vec<HandleRecord>,
370    cache: SlotCache<CACHE_SLOTS>,
371    next_asset_handle: u32,
372}
373
374/// Error produced when a source registration call fails.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum RegistryError {
377    /// The registry already holds [`ASSET_REGISTRY_MAX_SOURCES`] sources.
378    Full,
379    /// A source for this kind is already registered.
380    DuplicateKind,
381}
382
383impl<const CACHE_SLOTS: usize> AssetRegistry<CACHE_SLOTS> {
384    /// Create a new, empty registry.
385    pub fn new() -> Self {
386        Self {
387            sources: Vec::new(),
388            handles: Vec::new(),
389            cache: SlotCache::new(),
390            // Asset handle 0 is reserved as the null/invalid handle.
391            next_asset_handle: 1,
392        }
393    }
394
395    /// Register a concrete [`AssetSource`] implementation for the source kind
396    /// indicated by `kind_sentinel`.
397    ///
398    /// `kind_sentinel` is any [`AssetPath`] value whose variant determines which
399    /// source kind `source` will serve; the inner path string is ignored.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`RegistryError::Full`] if [`ASSET_REGISTRY_MAX_SOURCES`] sources
404    /// are already registered. Returns [`RegistryError::DuplicateKind`] if a
405    /// source for this kind is already present.
406    pub fn register_source(
407        &mut self,
408        kind_sentinel: &AssetPath,
409        source: Box<dyn AssetSource>,
410    ) -> Result<(), RegistryError> {
411        if self.sources.len() >= ASSET_REGISTRY_MAX_SOURCES {
412            return Err(RegistryError::Full);
413        }
414        let kind = kind_sentinel.kind();
415        if self.sources.iter().any(|s| s.kind == kind) {
416            return Err(RegistryError::DuplicateKind);
417        }
418        self.sources.push(RegistrySource { kind, source });
419        Ok(())
420    }
421
422    /// Intern an [`AssetPath`] and return an opaque [`AssetHandle`] token.
423    ///
424    /// If the same logical path is registered twice, two distinct handles are
425    /// issued (intern-by-value is not enforced in v1; deduplication is deferred).
426    pub fn register(&mut self, path: AssetPath) -> AssetHandle {
427        let handle = AssetHandle(self.next_asset_handle);
428        self.next_asset_handle = self.next_asset_handle.wrapping_add(1);
429        self.handles.push(HandleRecord {
430            asset_handle: handle,
431            path,
432            cache_handle: None,
433        });
434        handle
435    }
436
437    /// Resolve `handle` to a decoded [`ImageDescriptor`].
438    ///
439    /// The method checks the [`SlotCache`] first. On a hit the cached descriptor
440    /// is returned. On a miss the source for the path's kind is asked to `open`
441    /// the asset; the resulting bytes are drained and dispatched to the
442    /// appropriate decode plugin by file extension / magic bytes; the decoded
443    /// descriptor is inserted into the cache; and a reference to the cached
444    /// descriptor is returned.
445    ///
446    /// Decode dispatch is feature-gated:
447    ///
448    /// | Extension / magic | Feature required | Plugin |
449    /// |---|---|---|
450    /// | `.raw` / `.rle` | none (no_std-safe) | bytes wrapped in `ImageData::Owned` |
451    /// | `.png` / `\x89PNG` | `png` + non-`none` target | `crate::plugins::png::decode` |
452    /// | `.jpg` / `.jpeg` / `\xff\xd8` | `jpeg` + non-`none` target | `crate::plugins::jpeg::decode` |
453    /// | `.gif` / `GIF8` | `gif` | `crate::plugins::gif::decode` |
454    /// | other | none | bytes wrapped in `ImageData::Owned` (raw fallback) |
455    ///
456    /// # Errors
457    ///
458    /// - [`AssetError::Fs`]`(`[`FsError::NoSuchFile`]`)` — handle not found in
459    ///   registry, or no source registered for this path's kind.
460    /// - [`AssetError::Fs`]`(`[`FsError::Device`]`)` — source returned an I/O
461    ///   error during open/read.
462    /// - [`AssetError::Decode`] — source bytes were read but codec returned an
463    ///   error.
464    pub fn resolve_image(
465        &mut self,
466        handle: AssetHandle,
467    ) -> Result<&ImageDescriptor<'static>, AssetError> {
468        // Look up the handle record.
469        let record_idx = self
470            .handles
471            .iter()
472            .position(|r| r.asset_handle == handle)
473            .ok_or(AssetError::Fs(FsError::NoSuchFile))?;
474
475        // Determine the effective cache handle to look up at the end of the
476        // function.  We may need to insert a new entry (cache miss path) or
477        // re-use an existing one (cache hit path).  Both paths converge to a
478        // single immutable slot scan at the bottom of the function so that the
479        // borrow checker sees only one immutable borrow of `self.cache.slots`.
480        let final_cache_handle: CacheHandle;
481
482        // Check whether we already have a valid cache entry for this asset.
483        let existing_cache_handle: Option<CacheHandle> = self.handles[record_idx]
484            .cache_handle
485            .filter(|&ch| self.cache.slots.iter().flatten().any(|e| e.handle == ch));
486
487        if let Some(ch) = existing_cache_handle {
488            // Cache hit — update the LRU timestamp.
489            let _ = self.cache.get(ch);
490            final_cache_handle = ch;
491        } else {
492            // Cache entry was evicted (or never existed) — clear stale record.
493            self.handles[record_idx].cache_handle = None;
494
495            // Read bytes from the source.
496            let path_str = self.handles[record_idx].path.path_str().to_owned();
497            let kind = self.handles[record_idx].path.kind();
498
499            let source_idx = self
500                .sources
501                .iter()
502                .position(|s| s.kind == kind)
503                .ok_or(AssetError::Fs(FsError::NoSuchFile))?;
504
505            let bytes = {
506                let source = &self.sources[source_idx].source;
507                let mut reader = source.open(&path_str)?;
508                let len = reader.len();
509                let mut buf = alloc::vec![0u8; len];
510                let mut off = 0usize;
511                while off < len {
512                    let n = reader.read(&mut buf[off..])?;
513                    if n == 0 {
514                        break;
515                    }
516                    off += n;
517                }
518                buf
519            };
520
521            // Decode by extension / magic.
522            let desc = decode_bytes(&path_str, bytes)?;
523
524            // Insert into the cache and record the handle.
525            let new_ch = self.cache.insert(desc);
526            self.handles[record_idx].cache_handle = Some(new_ch);
527            final_cache_handle = new_ch;
528        }
529
530        // Single immutable borrow at the end: return a reference to the slot.
531        for e in self.cache.slots.iter().flatten() {
532            if e.handle == final_cache_handle {
533                return Ok(&e.desc);
534            }
535        }
536        // Unreachable: either the existing slot is present or insert placed it.
537        Err(AssetError::Fs(FsError::Device))
538    }
539}
540
541impl<const CACHE_SLOTS: usize> Default for AssetRegistry<CACHE_SLOTS> {
542    fn default() -> Self {
543        Self::new()
544    }
545}
546
547// ── Decode dispatch ───────────────────────────────────────────────────────────
548
549/// Detect the format of `bytes` by file `path` extension then magic, and
550/// dispatch to the appropriate decode plugin.
551///
552/// Returns an [`ImageDescriptor`] with `'static` pixel data (owned bytes).
553/// All codec calls are guarded behind the same feature flags that guard the
554/// plugin modules themselves, preventing `std`-only codec calls in `no_std`
555/// builds.
556fn decode_bytes(path: &str, bytes: Vec<u8>) -> Result<ImageDescriptor<'static>, AssetError> {
557    let lower = path.to_ascii_lowercase();
558
559    // PNG: by extension or magic.
560    #[cfg(all(feature = "png", not(target_os = "none")))]
561    if lower.ends_with(".png") || bytes.get(..8).is_some_and(|h| h.starts_with(b"\x89PNG")) {
562        return decode_png(bytes);
563    }
564
565    // JPEG: by extension or magic.
566    #[cfg(all(feature = "jpeg", not(target_os = "none")))]
567    if lower.ends_with(".jpg")
568        || lower.ends_with(".jpeg")
569        || bytes.get(..2).is_some_and(|h| h == b"\xff\xd8")
570    {
571        return decode_jpeg(bytes);
572    }
573
574    // GIF: by extension or magic.
575    #[cfg(feature = "gif")]
576    if lower.ends_with(".gif") || bytes.get(..4).is_some_and(|h| h == b"GIF8") {
577        return decode_gif(bytes);
578    }
579
580    // Raw / RLE / unknown: return bytes as-is in Owned storage.
581    // This is the no_std-safe fast path for `.raw`/`.rle` and for formats
582    // whose codec feature is not enabled.
583    let _ = lower; // suppress unused-variable warning when no codec features enabled
584    Ok(ImageDescriptor {
585        format: PixelFormat::Rgb565,
586        width: 0,
587        height: 0,
588        data: crate::image::ImageData::Owned(bytes),
589        stride: None,
590    })
591}
592
593#[cfg(all(feature = "png", not(target_os = "none")))]
594fn decode_png(bytes: Vec<u8>) -> Result<ImageDescriptor<'static>, AssetError> {
595    let (colors, w, h) = crate::plugins::png::decode(&bytes)
596        .map_err(|e| AssetError::Decode(alloc::format!("png: {e:?}")))?;
597    // Convert Vec<Color> → Vec<u8> as packed ARGB8888.
598    let mut pixels = Vec::with_capacity(colors.len() * 4);
599    for c in &colors {
600        pixels.push(c.0); // R
601        pixels.push(c.1); // G
602        pixels.push(c.2); // B
603        pixels.push(c.3); // A
604    }
605    Ok(ImageDescriptor {
606        format: PixelFormat::Argb8888,
607        width: w as u16,
608        height: h as u16,
609        data: crate::image::ImageData::Owned(pixels),
610        stride: None,
611    })
612}
613
614#[cfg(all(feature = "jpeg", not(target_os = "none")))]
615fn decode_jpeg(bytes: Vec<u8>) -> Result<ImageDescriptor<'static>, AssetError> {
616    let (colors, w, h) = crate::plugins::jpeg::decode(&bytes)
617        .map_err(|e| AssetError::Decode(alloc::format!("jpeg: {e:?}")))?;
618    let mut pixels = Vec::with_capacity(colors.len() * 4);
619    for c in &colors {
620        pixels.push(c.0);
621        pixels.push(c.1);
622        pixels.push(c.2);
623        pixels.push(c.3);
624    }
625    Ok(ImageDescriptor {
626        format: PixelFormat::Argb8888,
627        width: w,
628        height: h,
629        data: crate::image::ImageData::Owned(pixels),
630        stride: None,
631    })
632}
633
634#[cfg(feature = "gif")]
635fn decode_gif(bytes: Vec<u8>) -> Result<ImageDescriptor<'static>, AssetError> {
636    let (frames, w, h) = crate::plugins::gif::decode(&bytes)
637        .map_err(|e| AssetError::Decode(alloc::format!("gif: {e:?}")))?;
638    // Return the first frame's pixels; multi-frame animation is out of LPAR-09 scope.
639    let frame = frames
640        .into_iter()
641        .next()
642        .ok_or_else(|| AssetError::Decode(alloc::string::String::from("gif: no frames")))?;
643    let mut pixels = Vec::with_capacity(frame.pixels.len() * 4);
644    for c in &frame.pixels {
645        pixels.push(c.0);
646        pixels.push(c.1);
647        pixels.push(c.2);
648        pixels.push(c.3);
649    }
650    Ok(ImageDescriptor {
651        format: PixelFormat::Argb8888,
652        width: w,
653        height: h,
654        data: crate::image::ImageData::Owned(pixels),
655        stride: None,
656    })
657}
658
659// ── EmbeddedAssetSource ───────────────────────────────────────────────────────
660
661/// Byte reader wrapping a `&'static [u8]` slice.
662struct StaticSliceReader {
663    data: &'static [u8],
664    pos: usize,
665}
666
667impl AssetRead for StaticSliceReader {
668    fn read(&mut self, out: &mut [u8]) -> Result<usize, AssetError> {
669        let remaining = self.data.len().saturating_sub(self.pos);
670        let n = out.len().min(remaining);
671        out[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
672        self.pos += n;
673        Ok(n)
674    }
675
676    fn len(&self) -> usize {
677        self.data.len()
678    }
679
680    fn is_empty(&self) -> bool {
681        self.data.is_empty()
682    }
683
684    fn seek(&mut self, pos: u64) -> Result<u64, AssetError> {
685        self.pos = (pos as usize).min(self.data.len());
686        Ok(self.pos as u64)
687    }
688}
689
690/// Asset source backed by a static `(name, bytes)` table generated at build time.
691///
692/// The table is typically generated by a `build.rs` script following the pattern in
693/// `examples/stm32h747i-disco/assets/disco-assets/build.rs`. Lookup is a linear
694/// scan over the table for an exact string match on the name.
695///
696/// Infallible after construction: `AssetError::Fs(FsError::Device)` MUST NOT
697/// occur. The only expected failure is [`FsError::NoSuchFile`] when the name is
698/// absent from the table.
699///
700/// # `no_std + alloc`
701///
702/// Yes. The table and all byte slices are `'static`; no heap allocation occurs
703/// on the `open` path.
704pub struct EmbeddedAssetSource {
705    table: &'static [(&'static str, &'static [u8])],
706}
707
708impl EmbeddedAssetSource {
709    /// Create a source from a static `(name, bytes)` table.
710    ///
711    /// The table is typically a `static` generated by a build script; it is
712    /// `&'static` so it can be referenced from multiple places without copying.
713    pub const fn new(table: &'static [(&'static str, &'static [u8])]) -> Self {
714        Self { table }
715    }
716}
717
718impl AssetSource for EmbeddedAssetSource {
719    fn open<'a>(&'a self, path: &str) -> Result<Box<dyn AssetRead + 'a>, AssetError> {
720        for &(name, bytes) in self.table {
721            if name == path {
722                return Ok(Box::new(StaticSliceReader {
723                    data: bytes,
724                    pos: 0,
725                }));
726            }
727        }
728        Err(AssetError::Fs(FsError::NoSuchFile))
729    }
730
731    fn exists(&self, path: &str) -> bool {
732        self.table.iter().any(|&(name, _)| name == path)
733    }
734
735    fn list(&self, _dir: &str) -> Result<crate::fs::AssetIter, AssetError> {
736        Ok(crate::fs::AssetIter)
737    }
738}
739
740// ── MemoryAssetSource ─────────────────────────────────────────────────────────
741
742/// Byte reader wrapping an owned `Vec<u8>`.
743struct VecReader {
744    data: Vec<u8>,
745    pos: usize,
746}
747
748impl AssetRead for VecReader {
749    fn read(&mut self, out: &mut [u8]) -> Result<usize, AssetError> {
750        let remaining = self.data.len().saturating_sub(self.pos);
751        let n = out.len().min(remaining);
752        out[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
753        self.pos += n;
754        Ok(n)
755    }
756
757    fn len(&self) -> usize {
758        self.data.len()
759    }
760
761    fn is_empty(&self) -> bool {
762        self.data.is_empty()
763    }
764
765    fn seek(&mut self, pos: u64) -> Result<u64, AssetError> {
766        self.pos = (pos as usize).min(self.data.len());
767        Ok(self.pos as u64)
768    }
769}
770
771/// Asset source backed by a runtime-populated in-RAM table.
772///
773/// Useful for test asset injection and for assets pre-loaded from FATFS or
774/// another source before the widget tree starts. The table entries are owned
775/// `String` names paired with owned `Vec<u8>` byte buffers.
776///
777/// # `no_std + alloc`
778///
779/// Yes. No static storage required.
780pub struct MemoryAssetSource {
781    entries: Vec<(String, Vec<u8>)>,
782}
783
784impl MemoryAssetSource {
785    /// Create an empty memory source.
786    pub fn new() -> Self {
787        Self {
788            entries: Vec::new(),
789        }
790    }
791
792    /// Insert a named asset with its byte contents.
793    ///
794    /// If an entry with the same name already exists it is replaced.
795    pub fn insert(&mut self, name: impl Into<String>, data: Vec<u8>) {
796        let name = name.into();
797        for entry in &mut self.entries {
798            if entry.0 == name {
799                entry.1 = data;
800                return;
801            }
802        }
803        self.entries.push((name, data));
804    }
805}
806
807impl Default for MemoryAssetSource {
808    fn default() -> Self {
809        Self::new()
810    }
811}
812
813impl AssetSource for MemoryAssetSource {
814    fn open<'a>(&'a self, path: &str) -> Result<Box<dyn AssetRead + 'a>, AssetError> {
815        for (name, data) in &self.entries {
816            if name == path {
817                return Ok(Box::new(VecReader {
818                    data: data.clone(),
819                    pos: 0,
820                }));
821            }
822        }
823        Err(AssetError::Fs(FsError::NoSuchFile))
824    }
825
826    fn exists(&self, path: &str) -> bool {
827        self.entries.iter().any(|(name, _)| name == path)
828    }
829
830    fn list(&self, _dir: &str) -> Result<crate::fs::AssetIter, AssetError> {
831        Ok(crate::fs::AssetIter)
832    }
833}
834
835// ── SimAssetSource ────────────────────────────────────────────────────────────
836
837/// Asset source backed by the host filesystem.
838///
839/// This source is **`std`-only** and MUST NOT be compiled into `target_os = "none"`
840/// builds. It is gated behind `cfg(not(target_os = "none"))` in addition to the
841/// `sim` or `std` feature requirement.
842///
843/// `SimAssetSource` is complementary to `fs-sim`'s `SimBlockDevice`: the latter
844/// provides a `BlockDevice` over a FAT image file (for testing the FATFS path);
845/// this source bypasses FAT entirely and reads host files directly.
846#[cfg(all(feature = "sim", not(target_os = "none")))]
847pub struct SimAssetSource {
848    prefix: std::path::PathBuf,
849}
850
851#[cfg(all(feature = "sim", not(target_os = "none")))]
852impl SimAssetSource {
853    /// Create a source that resolves paths relative to `prefix`.
854    ///
855    /// Pass `"."` or an empty string for the current working directory.
856    pub fn new(prefix: impl Into<std::path::PathBuf>) -> Self {
857        Self {
858            prefix: prefix.into(),
859        }
860    }
861}
862
863#[cfg(all(feature = "sim", not(target_os = "none")))]
864struct StdFileReader {
865    data: Vec<u8>,
866    pos: usize,
867}
868
869#[cfg(all(feature = "sim", not(target_os = "none")))]
870impl AssetRead for StdFileReader {
871    fn read(&mut self, out: &mut [u8]) -> Result<usize, AssetError> {
872        let remaining = self.data.len().saturating_sub(self.pos);
873        let n = out.len().min(remaining);
874        out[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
875        self.pos += n;
876        Ok(n)
877    }
878
879    fn len(&self) -> usize {
880        self.data.len()
881    }
882
883    fn is_empty(&self) -> bool {
884        self.data.is_empty()
885    }
886
887    fn seek(&mut self, pos: u64) -> Result<u64, AssetError> {
888        self.pos = (pos as usize).min(self.data.len());
889        Ok(self.pos as u64)
890    }
891}
892
893#[cfg(all(feature = "sim", not(target_os = "none")))]
894impl AssetSource for SimAssetSource {
895    fn open<'a>(&'a self, path: &str) -> Result<Box<dyn AssetRead + 'a>, AssetError> {
896        use std::io::Read as _;
897        let full = self.prefix.join(path);
898        let mut file = std::fs::File::open(&full).map_err(|e| {
899            if e.kind() == std::io::ErrorKind::NotFound {
900                AssetError::Fs(FsError::NoSuchFile)
901            } else {
902                AssetError::Fs(FsError::Device)
903            }
904        })?;
905        let mut data = Vec::new();
906        file.read_to_end(&mut data)
907            .map_err(|_| AssetError::Fs(FsError::Device))?;
908        Ok(Box::new(StdFileReader { data, pos: 0 }))
909    }
910
911    fn exists(&self, path: &str) -> bool {
912        self.prefix.join(path).exists()
913    }
914
915    fn list(&self, _dir: &str) -> Result<crate::fs::AssetIter, AssetError> {
916        Ok(crate::fs::AssetIter)
917    }
918}
919
920// ── Tests ─────────────────────────────────────────────────────────────────────
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use crate::image::{ImageData, PixelFormat};
926
927    // ── EmbeddedAssetSource tests ─────────────────────────────────────────
928
929    static PIXEL_2X1: &[u8] = &[0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF]; // 2×1 ARGB8888
930    static EMBED_TABLE: &[(&str, &[u8])] = &[("icons/red_green.raw", PIXEL_2X1)];
931
932    #[test]
933    fn embedded_source_open_hit() {
934        let src = EmbeddedAssetSource::new(EMBED_TABLE);
935        assert!(src.exists("icons/red_green.raw"));
936        let mut reader = src.open("icons/red_green.raw").unwrap();
937        assert_eq!(reader.len(), 8);
938        let mut buf = [0u8; 8];
939        let n = reader.read(&mut buf).unwrap();
940        assert_eq!(n, 8);
941        assert_eq!(&buf, PIXEL_2X1);
942    }
943
944    #[test]
945    fn embedded_source_open_miss() {
946        let src = EmbeddedAssetSource::new(EMBED_TABLE);
947        assert!(!src.exists("missing.raw"));
948        let result = src.open("missing.raw");
949        assert!(matches!(result, Err(AssetError::Fs(FsError::NoSuchFile))));
950    }
951
952    // ── MemoryAssetSource tests ───────────────────────────────────────────
953
954    #[test]
955    fn memory_source_round_trip() {
956        let mut src = MemoryAssetSource::new();
957        src.insert("test.raw", vec![1, 2, 3, 4]);
958        assert!(src.exists("test.raw"));
959        let mut reader = src.open("test.raw").unwrap();
960        let mut buf = [0u8; 4];
961        reader.read(&mut buf).unwrap();
962        assert_eq!(buf, [1, 2, 3, 4]);
963    }
964
965    #[test]
966    fn memory_source_replace() {
967        let mut src = MemoryAssetSource::new();
968        src.insert("a.raw", vec![1]);
969        src.insert("a.raw", vec![2, 3]);
970        let mut reader = src.open("a.raw").unwrap();
971        assert_eq!(reader.len(), 2);
972        let mut buf = [0u8; 2];
973        reader.read(&mut buf).unwrap();
974        assert_eq!(buf, [2, 3]);
975    }
976
977    // ── SlotCache tests ───────────────────────────────────────────────────
978
979    fn make_desc(marker: u8) -> ImageDescriptor<'static> {
980        ImageDescriptor {
981            format: PixelFormat::Rgb565,
982            width: 1,
983            height: 1,
984            data: ImageData::Owned(alloc::vec![marker]),
985            stride: None,
986        }
987    }
988
989    #[test]
990    fn slot_cache_basic_insert_and_get() {
991        let mut cache: SlotCache<4> = SlotCache::new();
992        let h = cache.insert(make_desc(42));
993        let desc = cache.get(h).unwrap();
994        assert_eq!(desc.data.as_bytes().unwrap(), &[42u8]);
995    }
996
997    #[test]
998    fn slot_cache_evicts_lru() {
999        let mut cache: SlotCache<2> = SlotCache::new();
1000        let h1 = cache.insert(make_desc(1)); // slot 0, ts=1
1001        let h2 = cache.insert(make_desc(2)); // slot 1, ts=3
1002        // Touch h1 to make it the MRU.
1003        let _ = cache.get(h1); // h1 ts=5, h2 ts=3 → h2 is LRU
1004        // Insert a third entry — should evict h2 (oldest ts).
1005        let h3 = cache.insert(make_desc(3));
1006        assert!(cache.get(h2).is_none(), "h2 should have been evicted");
1007        assert!(cache.get(h1).is_some(), "h1 should still be cached");
1008        assert!(cache.get(h3).is_some(), "h3 should be cached");
1009    }
1010
1011    #[test]
1012    fn slot_cache_evict_explicit() {
1013        let mut cache: SlotCache<2> = SlotCache::new();
1014        let h = cache.insert(make_desc(7));
1015        assert!(cache.get(h).is_some());
1016        cache.evict(h);
1017        assert!(cache.get(h).is_none());
1018    }
1019
1020    #[test]
1021    fn slot_cache_touch_updates_recency() {
1022        let mut cache: SlotCache<2> = SlotCache::new();
1023        let h1 = cache.insert(make_desc(1));
1024        let h2 = cache.insert(make_desc(2));
1025        // h1 was inserted first; without touching it, it would be LRU.
1026        // Touch h1 to promote it above h2.
1027        let _ = cache.get(h1);
1028        // Insert a new entry — h2 should be evicted (older ts).
1029        let h3 = cache.insert(make_desc(3));
1030        assert!(cache.get(h2).is_none(), "h2 evicted after h1 was touched");
1031        assert!(cache.get(h1).is_some());
1032        assert!(cache.get(h3).is_some());
1033    }
1034
1035    // ── AssetRegistry tests ───────────────────────────────────────────────
1036
1037    #[test]
1038    fn registry_register_and_resolve_embedded() {
1039        let mut reg: AssetRegistry<4> = AssetRegistry::new();
1040        reg.register_source(
1041            &AssetPath::Embedded(""),
1042            Box::new(EmbeddedAssetSource::new(EMBED_TABLE)),
1043        )
1044        .unwrap();
1045        let handle = reg.register(AssetPath::Embedded("icons/red_green.raw"));
1046        let desc = reg.resolve_image(handle).unwrap();
1047        // Raw path: bytes wrapped as-is with Rgb565 format and 0×0 dimensions.
1048        assert!(!desc.data.is_empty());
1049    }
1050
1051    #[test]
1052    fn registry_resolve_unknown_handle_errors() {
1053        let mut reg: AssetRegistry<4> = AssetRegistry::new();
1054        let fake_handle = AssetHandle(99);
1055        let err = reg.resolve_image(fake_handle).unwrap_err();
1056        assert!(matches!(err, AssetError::Fs(FsError::NoSuchFile)));
1057    }
1058
1059    #[test]
1060    fn registry_resolve_cache_hit_on_second_call() {
1061        let mut reg: AssetRegistry<4> = AssetRegistry::new();
1062        reg.register_source(
1063            &AssetPath::Embedded(""),
1064            Box::new(EmbeddedAssetSource::new(EMBED_TABLE)),
1065        )
1066        .unwrap();
1067        let handle = reg.register(AssetPath::Embedded("icons/red_green.raw"));
1068        // First call — cache miss, decodes from source.
1069        let _ = reg.resolve_image(handle).unwrap();
1070        // Second call — should return from cache.
1071        let desc = reg.resolve_image(handle).unwrap();
1072        assert!(!desc.data.is_empty());
1073    }
1074
1075    #[test]
1076    fn registry_no_source_for_kind_errors() {
1077        let mut reg: AssetRegistry<4> = AssetRegistry::new();
1078        // Register a Memory source but ask for an Embedded path.
1079        reg.register_source(
1080            &AssetPath::Memory(String::new()),
1081            Box::new(MemoryAssetSource::new()),
1082        )
1083        .unwrap();
1084        let handle = reg.register(AssetPath::Embedded("foo.raw"));
1085        let err = reg.resolve_image(handle).unwrap_err();
1086        assert!(matches!(err, AssetError::Fs(FsError::NoSuchFile)));
1087    }
1088
1089    #[test]
1090    fn registry_memory_source_round_trip() {
1091        let mut src = MemoryAssetSource::new();
1092        src.insert("logo.raw", vec![0xAA, 0xBB, 0xCC, 0xDD]);
1093
1094        let mut reg: AssetRegistry<4> = AssetRegistry::new();
1095        reg.register_source(&AssetPath::Memory(String::new()), Box::new(src))
1096            .unwrap();
1097        let handle = reg.register(AssetPath::Memory("logo.raw".into()));
1098        let desc = reg.resolve_image(handle).unwrap();
1099        let bytes = desc.data.as_bytes().unwrap();
1100        assert_eq!(bytes, &[0xAA, 0xBB, 0xCC, 0xDD]);
1101    }
1102
1103    #[test]
1104    fn registry_evict_then_reload() {
1105        let mut src = MemoryAssetSource::new();
1106        src.insert("img.raw", vec![0x01, 0x02]);
1107
1108        let mut reg: AssetRegistry<1> = AssetRegistry::new(); // cache of 1 slot
1109        reg.register_source(&AssetPath::Memory(String::new()), Box::new(src))
1110            .unwrap();
1111        let h1 = reg.register(AssetPath::Memory("img.raw".into()));
1112        let h2 = reg.register(AssetPath::Memory("img.raw".into()));
1113
1114        // Resolve h1 — populates the single cache slot.
1115        let _ = reg.resolve_image(h1).unwrap();
1116        // Resolve h2 — evicts h1's entry (cache size = 1).
1117        let _ = reg.resolve_image(h2).unwrap();
1118        // Resolve h1 again — should re-decode from source (cache miss + reload).
1119        let desc = reg.resolve_image(h1).unwrap();
1120        assert!(!desc.data.is_empty());
1121    }
1122
1123    // ── ImageData::Asset variant tests ────────────────────────────────────
1124
1125    #[test]
1126    fn image_data_asset_variant_constructs_and_matches() {
1127        let handle = AssetHandle(1);
1128        let data = ImageData::Asset(handle);
1129        // byte_len returns 0 for the Asset variant (not in-memory yet).
1130        assert_eq!(data.byte_len(), 0);
1131        assert!(data.is_empty());
1132        assert!(data.as_bytes().is_none());
1133        assert!(data.as_color_slice().is_none());
1134
1135        match &data {
1136            ImageData::Asset(h) => assert_eq!(h.as_u32(), 1),
1137            _ => panic!("expected Asset variant"),
1138        }
1139    }
1140
1141    #[test]
1142    fn existing_image_data_variants_unaffected() {
1143        // Verify that existing variants still match and behave as before.
1144        let borrowed_data = ImageData::Borrowed(&[1u8, 2, 3]);
1145        assert_eq!(borrowed_data.byte_len(), 3);
1146        assert!(borrowed_data.as_bytes().is_some());
1147
1148        let owned_data: ImageData<'_> = ImageData::Owned(vec![4, 5]);
1149        assert_eq!(owned_data.byte_len(), 2);
1150
1151        // A match that covers all known variants (the `#[non_exhaustive]`
1152        // attribute requires a wildcard arm only for external crates; within
1153        // `rlvgl-core` itself all variants are exhaustively known).
1154        let check = match owned_data {
1155            ImageData::Borrowed(_) => "borrowed",
1156            ImageData::BorrowedColors(_) => "colors",
1157            ImageData::Owned(_) => "owned",
1158            ImageData::Asset(_) => "asset",
1159        };
1160        assert_eq!(check, "owned");
1161    }
1162
1163    // ── AssetPath helpers ─────────────────────────────────────────────────
1164
1165    #[test]
1166    fn asset_path_helpers() {
1167        let p = AssetPath::Embedded("icons/ok.raw");
1168        assert_eq!(p.path_str(), "icons/ok.raw");
1169        assert_eq!(p.source_kind(), "embedded");
1170
1171        let p2 = AssetPath::Memory("fonts/mono.bin".into());
1172        assert_eq!(p2.source_kind(), "memory");
1173        assert_eq!(p2.path_str(), "fonts/mono.bin");
1174    }
1175
1176    // ── SimAssetSource test (std only) ────────────────────────────────────
1177
1178    #[cfg(all(feature = "sim", not(target_os = "none")))]
1179    #[test]
1180    fn sim_source_reads_real_file() {
1181        use std::io::Write as _;
1182        let dir = std::env::temp_dir();
1183        let path = dir.join("rlvgl_sim_test.raw");
1184        {
1185            let mut f = std::fs::File::create(&path).unwrap();
1186            f.write_all(&[0xDE, 0xAD]).unwrap();
1187        }
1188        let src = SimAssetSource::new(dir);
1189        let mut reader = src.open("rlvgl_sim_test.raw").unwrap();
1190        let mut buf = [0u8; 2];
1191        reader.read(&mut buf).unwrap();
1192        assert_eq!(buf, [0xDE, 0xAD]);
1193        std::fs::remove_file(path).ok();
1194    }
1195}