1use 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
66pub struct SceneMinimap {
72 content_bounds: Rect,
77 viewport_in_scene: Signal<Rect>,
81 items: Vec<(Rect, Color)>,
84 size: Size,
87 background: Color,
89 border: Option<(Color, f32)>,
91 viewport_color: Color,
94 content_outline: Option<(Color, f32)>,
97 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 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 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 pub fn items(mut self, items: Vec<(Rect, Color)>) -> Self {
144 self.items = items;
145 self
146 }
147
148 pub fn background(mut self, color: Color) -> Self {
150 self.background = color;
151 self
152 }
153
154 pub fn border(mut self, border: Option<(Color, f32)>) -> Self {
157 self.border = border;
158 self
159 }
160
161 pub fn viewport_color(mut self, color: Color) -> Self {
163 self.viewport_color = color;
164 self
165 }
166
167 pub fn content_outline(mut self, outline: Option<(Color, f32)>) -> Self {
171 self.content_outline = outline;
172 self
173 }
174
175 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 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 #[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 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 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 canvas.save();
284 canvas.translate(bounds.x, bounds.y);
285 let area = Rect::new(0.0, 0.0, bounds.width, bounds.height);
286 canvas.fill_rect(area, self.background);
288 if let Some((color, width)) = self.content_outline {
292 canvas.stroke_rect(area, color, StrokeStyle::solid(width));
293 }
294 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 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 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 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 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 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 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 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 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 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 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}