Skip to main content

slint_mapping/sources/
file.rs

1//! [`FileTileSource`] — read raster tiles from a slippy-map directory tree
2//! laid out on disk as `{root}/{z}/{x}/{y}.{ext}`.
3//!
4//! This is the standard "OSM tile cache" layout — any tile bundle you
5//! pull from a slippy-map provider (Stamen, Stadia, an MBTiles export
6//! flattened to PNGs, your own pre-rendered set) will already match
7//! this shape, or be trivially `mv`-able into it.
8
9use crate::source::{TileKey, TileSource};
10use std::path::PathBuf;
11
12/// Reads raster tiles from a slippy-map directory tree on disk.
13///
14/// ```ignore
15/// use slint_mapping::sources::FileTileSource;
16/// let src = FileTileSource::new("/data/tiles")
17///     .with_extension("png")
18///     .with_tile_size(256)
19///     .with_zoom_range(0, 14);
20/// ```
21pub struct FileTileSource {
22    root: PathBuf,
23    extension: String,
24    tile_size: u32,
25    min_zoom: u8,
26    max_zoom: u8,
27}
28
29impl FileTileSource {
30    pub fn new(root: impl Into<PathBuf>) -> Self {
31        Self {
32            root: root.into(),
33            extension: "png".to_string(),
34            tile_size: 256,
35            min_zoom: 0,
36            max_zoom: 22,
37        }
38    }
39
40    /// Override the tile file extension (default `"png"`).
41    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
42        self.extension = ext.into();
43        self
44    }
45
46    /// Override the tile edge length (default 256).
47    pub fn with_tile_size(mut self, size: u32) -> Self {
48        self.tile_size = size;
49        self
50    }
51
52    /// Override the available zoom range. Tiles outside this range
53    /// won't be requested by the controller, even if the user zooms
54    /// past it.
55    pub fn with_zoom_range(mut self, min: u8, max: u8) -> Self {
56        self.min_zoom = min;
57        self.max_zoom = max;
58        self
59    }
60}
61
62impl TileSource for FileTileSource {
63    fn tile(&self, key: TileKey) -> Option<slint::Image> {
64        // `{root}/{z}/{x}/{y}.{ext}`
65        let path = self
66            .root
67            .join(key.z.to_string())
68            .join(key.x.to_string())
69            .join(format!("{}.{}", key.y, self.extension));
70        // Cheap existence check first — `Image::load_from_path` would
71        // otherwise log a noisy "Error loading image" line to stderr
72        // on every absent tile (e.g. tests that zoom past the
73        // available range, or any source still being populated).
74        if !path.exists() {
75            return None;
76        }
77        slint::Image::load_from_path(&path).ok()
78    }
79
80    fn tile_size(&self) -> u32 {
81        self.tile_size
82    }
83
84    fn min_zoom(&self) -> u8 {
85        self.min_zoom
86    }
87
88    fn max_zoom(&self) -> u8 {
89        self.max_zoom
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn missing_tile_returns_none() {
99        let src = FileTileSource::new("/definitely-does-not-exist-9b3a2c");
100        assert!(src.tile(TileKey { x: 0, y: 0, z: 0 }).is_none());
101    }
102
103    #[test]
104    fn sample_bundle_serves_world_tile() {
105        // The bundled OSM sample includes zoom 0-3; the (0,0,0) tile
106        // (the whole-world view) must load if the bundle is present.
107        let src = FileTileSource::new(crate::SAMPLE_TILES_DIR);
108        assert!(
109            src.tile(TileKey { x: 0, y: 0, z: 0 }).is_some(),
110            "sample bundle should serve the (0,0,0) world tile — \
111             did the bundle move or is the SAMPLE_TILES_DIR path wrong?"
112        );
113    }
114
115    #[test]
116    fn sample_bundle_serves_every_zoom_3_tile() {
117        // Spot-check the full zoom-3 grid (64 tiles) — they were all
118        // downloaded by the script.
119        let src = FileTileSource::new(crate::SAMPLE_TILES_DIR);
120        for x in 0..8u32 {
121            for y in 0..8u32 {
122                assert!(
123                    src.tile(TileKey { x, y, z: 3 }).is_some(),
124                    "missing sample tile (x={x}, y={y}, z=3)"
125                );
126            }
127        }
128    }
129
130    #[test]
131    fn zoom_range_overridable() {
132        let src = FileTileSource::new("/tmp").with_zoom_range(5, 14);
133        assert_eq!(src.min_zoom(), 5);
134        assert_eq!(src.max_zoom(), 14);
135    }
136}