Skip to main content

teksilo_scene/
minimap.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SceneMinimap`] — a small thumbnail of a [`Scene`](crate::Scene)
5//! showing all items as dots / rects scaled down, with an overlay
6//! highlighting the currently visible viewport rectangle.
7//!
8//! ## Use
9//!
10//! ```
11//! use teksilo_scene::{Scene, SceneView, SceneMinimap};
12//! use teksilo_canvas::Rect;
13//! # use teksilo_widgets::VStack;
14//!
15//! let mut scene = Scene::new();
16//! /* …populate scene… */
17//! // Build the SceneView FIRST so we can read its reactive
18//! // viewport signal and its scene's snapshot of items.
19//! let view = SceneView::new(scene);
20//! let content = view
21//!     .scene_content_bounds()
22//!     .unwrap_or(Rect::new(0.0, 0.0, 1000.0, 1000.0));
23//! let viewport_signal = view.viewport_in_scene_signal();
24//! let item_thumbs = view.scene().item_thumbnails(); // Vec<(Rect, Color)>
25//!
26//! let _w = VStack::new()
27//!     .child(view)
28//!     .child(
29//!         SceneMinimap::new(content, viewport_signal)
30//!             .items(item_thumbs)
31//!             .size(200.0, 150.0),
32//!     );
33//! ```
34//!
35//! For a live "items as they move" minimap, re-call
36//! [`Scene::item_thumbnails`](crate::Scene::item_thumbnails) on
37//! scene mutations and rebuild the widget tree (or wire a
38//! `Signal<Vec<(Rect, Color)>>` if your app needs per-frame
39//! reactivity).
40//!
41//! ## Design
42//!
43//! Deliberately decoupled from `SceneView`: it doesn't reach into
44//! the scene model. Instead it consumes a content extent (the rect
45//! that maps to "the entire minimap area"), a static `Vec<(Rect, Color)>`
46//! of item thumbnails (refreshed by the app whenever items move),
47//! and a `Signal<Rect>` for the live viewport rectangle.
48//!
49//! Apps that want a live "items as they move" minimap rebuild their
50//! widget tree on scene mutations or wire a `Signal<Vec<...>>`. The
51//! viewport overlay is reactive on its own — the minimap re-paints
52//! whenever the SceneView's pan / zoom changes, with no manual
53//! plumbing.
54
55use std::rc::Rc;
56
57use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal, StrokeStyle};
58use teksilo_core::binding::BindingLevel;
59use teksilo_core::build_context::BuildContext;
60use teksilo_core::signal::Signal;
61use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, PaintContext, Widget};
62use teksilo_core::widget_builder::HandlerSet;
63use teksilo_core::widget_id::WidgetId;
64use teksilo_tokens::Color;
65
66/// A small thumbnail rendering of a [`Scene`](crate::Scene)'s
67/// content, with the live viewport rectangle highlighted.
68///
69/// Paint order: background fill → optional content-bounds outline
70/// → item thumbnails (dots / rects) → viewport overlay rect.
71pub struct SceneMinimap {
72    /// The scene-coord rect that maps to the full minimap drawing
73    /// area. Apps typically use `Scene::content_bounds()` or a
74    /// hand-picked extent (e.g. `Rect::new(0,0, 10_000, 10_000)`
75    /// for a known canvas).
76    content_bounds: Rect,
77    /// Live viewport rectangle in scene coords. The minimap binds
78    /// at `RepaintOnly` so it re-renders whenever the SceneView
79    /// pans / zooms.
80    viewport_in_scene: Signal<Rect>,
81    /// Static snapshot of items + their thumbnail color. Apps
82    /// refresh by rebuilding when items move.
83    items: Vec<(Rect, Color)>,
84    /// Minimap dimensions in widget-local pixels. Defaults to
85    /// 200×150.
86    size: Size,
87    /// Background fill color. Defaults to a translucent white.
88    background: Color,
89    /// Border around the minimap drawing area. Default 1px black.
90    border: Option<(Color, f32)>,
91    /// Color of the viewport overlay rectangle. Default semi-
92    /// transparent blue stroke + faint fill.
93    viewport_color: Color,
94    /// Optional outline of the content extent (gives users a sense
95    /// of "you're inside this much scene"). Default `None`.
96    content_outline: Option<(Color, f32)>,
97    /// Optional click handler: fires with the scene-coord
98    /// corresponding to the click, plus the standard
99    /// `EventContext`. Apps wire this to `SceneView::pan_to_center`
100    /// for click-to-recenter behavior.
101    on_click: Option<Rc<dyn Fn(Point, &mut EventContext)>>,
102}
103
104impl std::fmt::Debug for SceneMinimap {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("SceneMinimap")
107            .field("content_bounds", &self.content_bounds)
108            .field("size", &self.size)
109            .field("item_count", &self.items.len())
110            .field("on_click", &self.on_click.is_some())
111            .finish_non_exhaustive()
112    }
113}
114
115impl SceneMinimap {
116    /// Construct a minimap covering `content_bounds` (the scene-coord
117    /// extent that maps to the full minimap area), with `viewport`
118    /// driving the live overlay rectangle.
119    pub fn new(content_bounds: Rect, viewport: Signal<Rect>) -> Self {
120        Self {
121            content_bounds,
122            viewport_in_scene: viewport,
123            items: Vec::new(),
124            size: Size::new(200.0, 150.0),
125            background: Color::new(1.0, 1.0, 1.0, 0.85),
126            border: Some((Color::new(0.0, 0.0, 0.0, 0.5), 1.0)),
127            viewport_color: Color::new(0.2, 0.5, 1.0, 1.0),
128            content_outline: None,
129            on_click: None,
130        }
131    }
132
133    /// Override the minimap size. Default `200×150`.
134    pub fn size(mut self, width: f32, height: f32) -> Self {
135        self.size = Size::new(width.max(1.0), height.max(1.0));
136        self
137    }
138
139    /// Static list of item thumbnails: `(scene_rect, color)`. The
140    /// minimap projects each rect onto its drawing area and fills it
141    /// with `color`. Apps refresh by rebuilding the widget tree
142    /// when items move.
143    pub fn items(mut self, items: Vec<(Rect, Color)>) -> Self {
144        self.items = items;
145        self
146    }
147
148    /// Background fill color. Default semi-transparent white.
149    pub fn background(mut self, color: Color) -> Self {
150        self.background = color;
151        self
152    }
153
154    /// Border around the minimap drawing area. Pass `None` for no
155    /// border. Default 1px @ 50% black.
156    pub fn border(mut self, border: Option<(Color, f32)>) -> Self {
157        self.border = border;
158        self
159    }
160
161    /// Color of the viewport overlay rectangle. Default solid blue.
162    pub fn viewport_color(mut self, color: Color) -> Self {
163        self.viewport_color = color;
164        self
165    }
166
167    /// Outline the content extent inside the minimap (gives users a
168    /// "you're somewhere inside this much scene" cue when the
169    /// minimap is taller / wider than its content). Default `None`.
170    pub fn content_outline(mut self, outline: Option<(Color, f32)>) -> Self {
171        self.content_outline = outline;
172        self
173    }
174
175    /// Click handler: fires with the scene-coord corresponding to
176    /// the click, plus the standard `EventContext`. Apps wire this
177    /// to e.g. `SceneView::pan_to_center` for click-to-recenter.
178    pub fn on_click<F>(mut self, callback: F) -> Self
179    where
180        F: Fn(Point, &mut EventContext) + 'static,
181    {
182        self.on_click = Some(Rc::new(callback));
183        self
184    }
185
186    /// Map a scene-coord point through the minimap's projection to
187    /// minimap-local widget coords. Internal helper, exposed for
188    /// tests.
189    fn scene_to_minimap(&self, scene_pt: Point, area: Rect) -> Point {
190        let cb = self.content_bounds;
191        let nx = if cb.width > 0.0 {
192            (scene_pt.x - cb.x) / cb.width
193        } else {
194            0.5
195        };
196        let ny = if cb.height > 0.0 {
197            (scene_pt.y - cb.y) / cb.height
198        } else {
199            0.5
200        };
201        Point::new(area.x + nx * area.width, area.y + ny * area.height)
202    }
203
204    /// Map a minimap-local widget point back to scene coords.
205    /// Inverse of `scene_to_minimap`. Currently used only by tests
206    /// — kept as inherent (rather than test-only) so apps writing
207    /// custom minimap overlays can reuse the projection math.
208    #[allow(dead_code)]
209    fn minimap_to_scene(&self, mm_pt: Point, area: Rect) -> Point {
210        let cb = self.content_bounds;
211        let nx = if area.width > 0.0 {
212            (mm_pt.x - area.x) / area.width
213        } else {
214            0.5
215        };
216        let ny = if area.height > 0.0 {
217            (mm_pt.y - area.y) / area.height
218        } else {
219            0.5
220        };
221        Point::new(cb.x + nx * cb.width, cb.y + ny * cb.height)
222    }
223}
224
225impl Widget for SceneMinimap {
226    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
227        // The viewport signal drives our paint output: bind at
228        // RepaintOnly so SceneView pan/zoom flips re-render the
229        // overlay automatically.
230        self.viewport_in_scene.bind_to(
231            ctx.self_id(),
232            ctx.binding_registry(),
233            BindingLevel::RepaintOnly,
234        );
235
236        if let Some(callback) = self.on_click.clone() {
237            // `on_tap` hands us a widget-local `Point`; project
238            // through the minimap mapping into scene coords and
239            // dispatch.
240            let content = self.content_bounds;
241            let size = self.size;
242            let handlers = HandlerSet::new().on_tap(move |event, ev_ctx| {
243                let local = event.position;
244                let nx = if size.width > 0.0 {
245                    local.x / size.width
246                } else {
247                    0.5
248                };
249                let ny = if size.height > 0.0 {
250                    local.y / size.height
251                } else {
252                    0.5
253                };
254                let scene_pt = Point::new(
255                    content.x + nx * content.width,
256                    content.y + ny * content.height,
257                );
258                callback(scene_pt, ev_ctx);
259            });
260            ctx.apply_self_handlers(handlers);
261        }
262        Vec::new()
263    }
264
265    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
266        let w = proposal
267            .width
268            .unwrap_or(self.size.width)
269            .min(self.size.width);
270        let h = proposal
271            .height
272            .unwrap_or(self.size.height)
273            .min(self.size.height);
274        Size::new(w, h).into()
275    }
276
277    fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
278        // `paint` receives absolute bounds (the canvas is NOT pre-translated to
279        // the widget origin — unlike `on_tap`, which hands us widget-local
280        // coords). The minimap draws everything in its own local frame
281        // (origin 0,0), so translate the canvas to `bounds.origin` first — else
282        // it renders at the window's top-left regardless of where it's placed.
283        canvas.save();
284        canvas.translate(bounds.x, bounds.y);
285        let area = Rect::new(0.0, 0.0, bounds.width, bounds.height);
286        // Background fill.
287        canvas.fill_rect(area, self.background);
288        // Optional content outline (in minimap-local coords this is
289        // a sub-rect mapped from content_bounds; for the full-area
290        // mapping that's exactly `area`).
291        if let Some((color, width)) = self.content_outline {
292            canvas.stroke_rect(area, color, StrokeStyle::solid(width));
293        }
294        // Item thumbnails.
295        for (item_rect, color) in &self.items {
296            let tl = self.scene_to_minimap(Point::new(item_rect.x, item_rect.y), area);
297            let br = self.scene_to_minimap(
298                Point::new(
299                    item_rect.x + item_rect.width,
300                    item_rect.y + item_rect.height,
301                ),
302                area,
303            );
304            let r = Rect::new(tl.x, tl.y, (br.x - tl.x).max(1.0), (br.y - tl.y).max(1.0));
305            canvas.fill_rect(r, *color);
306        }
307        // Viewport overlay.
308        let vp = self.viewport_in_scene.get();
309        let tl = self.scene_to_minimap(Point::new(vp.x, vp.y), area);
310        let br = self.scene_to_minimap(Point::new(vp.x + vp.width, vp.y + vp.height), area);
311        let vp_rect = Rect::new(tl.x, tl.y, (br.x - tl.x).max(1.0), (br.y - tl.y).max(1.0));
312        canvas.stroke_rect(vp_rect, self.viewport_color, StrokeStyle::solid(2.0));
313        // Border last so it sits on top of everything.
314        if let Some((color, width)) = self.border {
315            canvas.stroke_rect(area, color, StrokeStyle::solid(width));
316        }
317        canvas.restore();
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use teksilo_core::widget_tree::WidgetTree;
325
326    #[test]
327    fn minimap_default_layout_response_is_capped() {
328        let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
329        let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport);
330        let theme = teksilo_core::presets::intui::light();
331        let ctx = LayoutContext::for_testing(&theme);
332        // Unspecified proposal → falls back to self.size = 200×150.
333        let lr = mm.layout_response(SizeProposal::unspecified(), &ctx);
334        assert_eq!(lr.size.width, 200.0);
335        assert_eq!(lr.size.height, 150.0);
336        // Larger proposal → still capped at self.size.
337        let lr = mm.layout_response(SizeProposal::exact(400.0, 300.0), &ctx);
338        assert_eq!(lr.size.width, 200.0);
339        assert_eq!(lr.size.height, 150.0);
340    }
341
342    #[test]
343    fn minimap_size_override_changes_layout_response() {
344        let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
345        let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport).size(120.0, 80.0);
346        let theme = teksilo_core::presets::intui::light();
347        let ctx = LayoutContext::for_testing(&theme);
348        let lr = mm.layout_response(SizeProposal::unspecified(), &ctx);
349        assert_eq!(lr.size.width, 120.0);
350        assert_eq!(lr.size.height, 80.0);
351        // Caps under a larger proposal.
352        let lr = mm.layout_response(SizeProposal::exact(400.0, 300.0), &ctx);
353        assert_eq!(lr.size.width, 120.0);
354        assert_eq!(lr.size.height, 80.0);
355    }
356
357    #[test]
358    fn minimap_can_be_added_to_widget_tree() {
359        // Smoke test that the widget actually integrates — build()
360        // doesn't panic, layout pass succeeds.
361        let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
362        let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport).size(120.0, 80.0);
363        let mut tree = WidgetTree::new();
364        let id = tree.add(mm);
365        tree.layout(SizeProposal::unspecified());
366        let bounds = tree.bounds(id);
367        // With unspecified proposal, the framework respects layout_response.
368        assert_eq!(bounds.width, 120.0);
369        assert_eq!(bounds.height, 80.0);
370    }
371
372    #[test]
373    fn scene_to_minimap_maps_corners_correctly() {
374        let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
375        let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport);
376        let area = Rect::new(0.0, 0.0, 200.0, 150.0);
377        // (0,0) scene → (0,0) minimap
378        let p0 = mm.scene_to_minimap(Point::new(0.0, 0.0), area);
379        assert!((p0.x - 0.0).abs() < 1e-5);
380        assert!((p0.y - 0.0).abs() < 1e-5);
381        // (1000, 750) scene → (200, 150) minimap
382        let p1 = mm.scene_to_minimap(Point::new(1000.0, 750.0), area);
383        assert!((p1.x - 200.0).abs() < 1e-5);
384        assert!((p1.y - 150.0).abs() < 1e-5);
385        // (500, 375) scene → (100, 75) minimap (center)
386        let pc = mm.scene_to_minimap(Point::new(500.0, 375.0), area);
387        assert!((pc.x - 100.0).abs() < 1e-5);
388        assert!((pc.y - 75.0).abs() < 1e-5);
389    }
390
391    #[test]
392    fn minimap_to_scene_is_inverse() {
393        let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
394        let mm = SceneMinimap::new(Rect::new(50.0, 100.0, 1000.0, 750.0), viewport);
395        let area = Rect::new(0.0, 0.0, 200.0, 150.0);
396        for (sx, sy) in [(50.0, 100.0), (1050.0, 850.0), (550.0, 475.0)] {
397            let mm_pt = mm.scene_to_minimap(Point::new(sx, sy), area);
398            let back = mm.minimap_to_scene(mm_pt, area);
399            assert!((back.x - sx).abs() < 1e-3);
400            assert!((back.y - sy).abs() < 1e-3);
401        }
402    }
403}