Skip to main content

slint_mapping/
cache.rs

1//! [`TileCache`] — abstract local storage for tiles.
2//!
3//! Every "fetching" tile source ([`crate::sources::OsmTileSource`] is
4//! the only one shipped today) writes through a `TileCache` rather
5//! than dealing with the filesystem / SQLite / whatever directly.
6//! That keeps the storage backend swappable: an embedded app can
7//! ship [`FileTileCache`] writing under the OS cache dir; a desktop
8//! app might prefer a single-file format (MBTiles, PMTiles); a test
9//! can use a `Vec` in memory.
10//!
11//! `FileTileCache` is the one concrete implementation right now and
12//! is what backs the bundled `sample-tiles/` directory.
13
14use crate::source::TileKey;
15use std::path::{Path, PathBuf};
16
17/// Local storage for downloaded tiles. Implementations decide whether
18/// the backing is a slippy-map directory tree, an MBTiles SQLite db,
19/// a single PMTiles archive, or an in-memory `HashMap` for tests.
20pub trait TileCache: Send + Sync {
21    /// Look up the cached tile, decoded as a `slint::Image`. Returns
22    /// `None` if the tile isn't cached (or if its bytes failed to
23    /// decode). Implementations should be cheap on the miss path.
24    fn get(&self, key: TileKey) -> Option<slint::Image>;
25
26    /// Store raw bytes (typically a PNG payload exactly as the source
27    /// served it — the cache is encoding-agnostic). Errors here are
28    /// returned but a fetching source will typically just log and
29    /// keep going.
30    fn put(&self, key: TileKey, bytes: &[u8]) -> Result<(), CacheError>;
31
32    /// Quick existence check that avoids the decode cost of `get`.
33    /// Default implementation falls back to `get(key).is_some()` —
34    /// override on backends where existence is cheaper to test.
35    fn contains(&self, key: TileKey) -> bool {
36        self.get(key).is_some()
37    }
38
39    /// Read the raw bytes for a tile, if cached. Used by sources that
40    /// want to do their own (off-UI-thread) decoding rather than the
41    /// in-thread decode that `get` performs. Default returns `None` —
42    /// backends that store raw bytes (FileTileCache) should override.
43    fn get_bytes(&self, _key: TileKey) -> Option<Vec<u8>> {
44        None
45    }
46}
47
48/// Error returned by [`TileCache::put`].
49#[derive(Debug)]
50pub enum CacheError {
51    Io(std::io::Error),
52    Other(String),
53}
54
55impl std::fmt::Display for CacheError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            CacheError::Io(e) => write!(f, "tile cache I/O error: {e}"),
59            CacheError::Other(s) => write!(f, "tile cache error: {s}"),
60        }
61    }
62}
63
64impl std::error::Error for CacheError {}
65
66impl From<std::io::Error> for CacheError {
67    fn from(e: std::io::Error) -> Self {
68        CacheError::Io(e)
69    }
70}
71
72/// Stores tiles in a slippy-map directory tree on disk
73/// (`{root}/{z}/{x}/{y}.{ext}`). Compatible with every common tile
74/// bundle layout (the OSM standard, MapTiler exports, Mapbox tile
75/// downloads). The bundled `sample-tiles/` is one of these.
76pub struct FileTileCache {
77    root: PathBuf,
78    extension: String,
79}
80
81impl FileTileCache {
82    pub fn new(root: impl Into<PathBuf>) -> Self {
83        Self {
84            root: root.into(),
85            extension: "png".to_string(),
86        }
87    }
88
89    /// Override the tile file extension (default `"png"`).
90    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
91        self.extension = ext.into();
92        self
93    }
94
95    /// Absolute on-disk path for the given tile.
96    pub fn path_for(&self, key: TileKey) -> PathBuf {
97        self.root
98            .join(key.z.to_string())
99            .join(key.x.to_string())
100            .join(format!("{}.{}", key.y, self.extension))
101    }
102
103    /// Filesystem root this cache writes under.
104    pub fn root(&self) -> &Path {
105        &self.root
106    }
107}
108
109impl TileCache for FileTileCache {
110    fn get(&self, key: TileKey) -> Option<slint::Image> {
111        let path = self.path_for(key);
112        if !path.exists() {
113            return None;
114        }
115        slint::Image::load_from_path(&path).ok()
116    }
117
118    fn put(&self, key: TileKey, bytes: &[u8]) -> Result<(), CacheError> {
119        let path = self.path_for(key);
120        if let Some(parent) = path.parent() {
121            std::fs::create_dir_all(parent)?;
122        }
123        std::fs::write(&path, bytes)?;
124        Ok(())
125    }
126
127    fn contains(&self, key: TileKey) -> bool {
128        self.path_for(key).exists()
129    }
130
131    fn get_bytes(&self, key: TileKey) -> Option<Vec<u8>> {
132        let path = self.path_for(key);
133        if !path.exists() {
134            return None;
135        }
136        std::fs::read(&path).ok()
137    }
138}
139
140/// Read-through composite cache. `get` / `contains` try each layer
141/// in order until one hits; `put` only writes to the first layer
142/// (which must be writable). Use to overlay a writable user cache
143/// over a read-only bundled cache: the bundled tiles serve instantly,
144/// new fetches accumulate in the user cache without polluting the
145/// bundle.
146pub struct LayeredTileCache {
147    /// First layer is the writable target for `put`; subsequent
148    /// layers are read-only fallbacks for `get` / `contains`.
149    layers: Vec<Box<dyn TileCache>>,
150}
151
152impl LayeredTileCache {
153    /// Build a layered cache. `writable` receives all puts; `fallbacks`
154    /// are tried in order on read misses.
155    pub fn new(writable: Box<dyn TileCache>, fallbacks: Vec<Box<dyn TileCache>>) -> Self {
156        let mut layers = Vec::with_capacity(1 + fallbacks.len());
157        layers.push(writable);
158        layers.extend(fallbacks);
159        Self { layers }
160    }
161}
162
163impl TileCache for LayeredTileCache {
164    fn get(&self, key: TileKey) -> Option<slint::Image> {
165        self.layers.iter().find_map(|l| l.get(key))
166    }
167    fn put(&self, key: TileKey, bytes: &[u8]) -> Result<(), CacheError> {
168        self.layers[0].put(key, bytes)
169    }
170    fn contains(&self, key: TileKey) -> bool {
171        self.layers.iter().any(|l| l.contains(key))
172    }
173    fn get_bytes(&self, key: TileKey) -> Option<Vec<u8>> {
174        self.layers.iter().find_map(|l| l.get_bytes(key))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::fs;
182
183    fn temp_root(name: &str) -> PathBuf {
184        // `process::id() + name + thread id` keeps tests in this
185        // module from racing on each other's roots (cargo runs them in
186        // parallel by default).
187        let tid = format!("{:?}", std::thread::current().id());
188        let p = std::env::temp_dir().join(format!(
189            "slint-mapping-cache-test-{}-{}-{}",
190            std::process::id(),
191            name,
192            tid.replace([' ', '(', ')'], "_"),
193        ));
194        let _ = fs::remove_dir_all(&p);
195        fs::create_dir_all(&p).unwrap();
196        p
197    }
198
199    #[test]
200    fn put_then_get_returns_the_same_bytes_via_image() {
201        let root = temp_root("put_then_get");
202        let cache = FileTileCache::new(&root);
203        let key = TileKey { x: 1, y: 2, z: 3 };
204        let png = include_bytes!("../sample-tiles/0/0/0.png"); // any real PNG
205        cache.put(key, png).unwrap();
206        assert!(cache.contains(key));
207        assert!(cache.get(key).is_some(), "round-trip should decode");
208        fs::remove_dir_all(&root).ok();
209    }
210
211    #[test]
212    fn missing_key_returns_none() {
213        let root = temp_root("missing");
214        let cache = FileTileCache::new(&root);
215        assert!(!cache.contains(TileKey { x: 9, y: 9, z: 9 }));
216        assert!(cache.get(TileKey { x: 9, y: 9, z: 9 }).is_none());
217        fs::remove_dir_all(&root).ok();
218    }
219
220    #[test]
221    fn put_creates_intermediate_directories() {
222        let root = temp_root("intermediate_dirs");
223        let cache = FileTileCache::new(&root);
224        let key = TileKey {
225            x: 1234,
226            y: 5678,
227            z: 12,
228        };
229        let png = include_bytes!("../sample-tiles/0/0/0.png");
230        cache.put(key, png).unwrap();
231        assert!(cache.path_for(key).exists());
232        fs::remove_dir_all(&root).ok();
233    }
234}