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//! the minimap is guaranteed to show), 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//!
55//! ## The projection, and why it expands
56//!
57//! `content_bounds` is a *floor*, not a frame. The rect actually
58//! projected onto the drawing area is the **effective extent**:
59//!
60//! ```text
61//! effective_extent = content_bounds ∪ every item rect ∪ viewport_in_scene
62//! ```
63//!
64//! This matters because the inputs are unrelated. `content_bounds`
65//! is typically the union of item rects
66//! (`SceneView::scene_content_bounds`), while `viewport_in_scene` is
67//! the viewport projected through the inverse view transform — and
68//! pan is **unbounded by default** (`Scene::current_pan_bounds()` is
69//! `None`). A user who zooms in and pans away, or who zooms out past
70//! the content, puts the viewport wholly outside `content_bounds`. A
71//! minimap that mapped `content_bounds` alone would then draw the
72//! indicator outside its own frame.
73//!
74//! Three answers were possible; this is the one that keeps the
75//! minimap *useful*:
76//!
77//! - **Clamp** the indicator to the frame — it would lie about both
78//! position and size, and stop moving while the user is still
79//! panning.
80//! - **Crop** it — honest, but the indicator vanishes exactly when
81//! the user most needs to know where they are.
82//! - **Expand the extent** — position and size stay truthful and the
83//! content visibly shrinks as you wander off, which *is* the
84//! "you are out here, the content is over there" signal. This is
85//! what tldraw does (`Box.Expand(contentBounds, viewportBounds)`).
86//!
87//! The projection is a **uniform** fit: one scale for both axes,
88//! centred in the drawing area, so a square item reads as a square
89//! and the picture shrinks evenly as the extent grows. Expect
90//! letterbox margins when the widget's aspect ratio differs from the
91//! extent's.
92//!
93//! ## The clip
94//!
95//! `paint` clips **everything it emits** — background, outline,
96//! thumbnails, indicator and the widget's own border — to the widget's
97//! bounds. Not one stroke is drawn outside it, so "nothing the minimap
98//! paints leaves its frame" holds without qualification and for every
99//! caller-supplied width.
100//!
101//! That is a structural backstop, not a duplicate of the extent
102//! expansion: the expansion is a *policy* a future edit could regress,
103//! while the clip is a guarantee.
104//!
105//! It is also the only mechanism available here.
106//! `Widget::clips_children` does **not** clip a widget's own
107//! `paint()`: the render walker emits a plain clipping node's
108//! `SetClip` *after* that node's paint (see
109//! `teksilo-core/src/widget_tree/rendering_impl.rs`), so for a
110//! self-painting leaf with no children it is a complete no-op. The
111//! supported primitive is `Canvas::set_clip` / `clear_clip` inside
112//! `paint`, as `ListView`, `TableView`, `CodeEditor` and `Terminal`
113//! already do.
114//!
115//! The clip is set **after** the canvas has been translated to
116//! `bounds.origin`, and is given the widget-*local* area — so it lands
117//! on the widget wherever the parent placed it. At the root of a tree
118//! that translation is the identity and the ordering is unobservable,
119//! which is why the tests exercise a nested, offset minimap too.
120//!
121//! ## The border is inside the frame
122//!
123//! `Canvas::stroke_rect` centres each edge line on the rect boundary,
124//! so stroking the widget area directly would hang half of the width
125//! outside the widget — 12 px on every side for a 24 px border, over
126//! whatever sibling is underneath. The border is therefore stroked on
127//! the area **inset by half its width**, which puts the band wholly
128//! inside: the CSS `border-box` convention, and the one every desktop
129//! toolkit uses. A wide border eats into the picture rather than into
130//! the neighbours; the clip then bounds even a pathological width.
131//!
132//! Clamping the width instead was rejected: it would silently render
133//! something other than what the caller asked for, and it would still
134//! leave the last half-pixel straddling the edge.
135//!
136//! ## Accessibility
137//!
138//! The minimap's whole job is to say *where you are* and let you go
139//! somewhere else. Both halves are on the AT node, and the second half
140//! has three input routes that all end in the same callback.
141//!
142//! **The node.** `Role::Group`, named `"Scene minimap"` by default,
143//! carrying the position as its **value**. A 2-D position has no ARIA
144//! role — there is no `slider2d` — and `numeric_value` holds one number
145//! where this control has four, so the reading is text, exactly as
146//! `HsvCanvas` (`teksilo-widgets/src/color_picker/hsv_canvas.rs`, the
147//! workspace's other 2-D picker) does it. It is deliberately not a property-less
148//! `Role::GenericContainer`: the walker prunes those, and a pruned node
149//! announces nothing.
150//!
151//! **The value** is recomputed at every AT walk from the same
152//! `effective_extent` the picture is drawn from, so it is the picture in
153//! words: *"Viewport at 42% across, 17% down; showing 25% of the width and
154//! 33% of the height"*. The viewport signal is bound **twice** — at
155//! `RepaintOnly` for the drawing and at `AccessibilityOnly` for this — so a
156//! pan updates the announcement as well as the overlay, with no rebuild.
157//! Pass [`SceneMinimap::access_readout`] to phrase (and translate) it
158//! yourself; the numbers arrive as a [`MinimapReadout`].
159//!
160//! **The routes.** With an [`on_click`](SceneMinimap::on_click) callback
161//! installed the node is focusable and offers, all landing in that one
162//! callback:
163//!
164//! | Route | Pointer | Keyboard | Assistive technology |
165//! | --- | --- | --- | --- |
166//! | move the view | tap a point | arrows (`Shift` = a whole viewport) | `ScrollLeft` / `Right` / `Up` / `Down` |
167//! | centre on the content | — | `Home`, `Enter`, `Space` | `Click` |
168//!
169//! `Action::Click` is the position-free half of the tap: a click needs a
170//! point and an AT client has none, so the primary action is the one
171//! well-defined destination the widget can name on its own — the centre of
172//! the content. `Home` / `Enter` / `Space` are bound to it so the keyboard
173//! and the AT client do the same thing.
174//!
175//! Arrow steps are a fraction of the **viewport**, not of the scene: a
176//! tenth of it for a nudge and a whole one for `Shift` — the line-vs-page
177//! pair a scroll view uses, scaled to what the user is actually looking at
178//! rather than to a count of scene units that would be a screenful at one
179//! zoom and a hair at another. They are **not** mirrored under RTL, for
180//! the reason `HsvCanvas` gives: the picture is not mirrored either, so an
181//! arrow that flipped would disagree with what the user sees.
182//!
183//! **Who speaks.** The keyboard and assistive-technology routes announce the
184//! move through [`EventContext::announce`]. The pointer route does not.
185//!
186//! An arrow press on an unannotated graphic tells a screen-reader user
187//! nothing at all, and an AT client that invokes `Click` was never told where
188//! "the content" is — for those two routes the utterance is the entire
189//! feedback. A tap already has some: the user picked the destination by
190//! aiming at the picture. And a minimap invites the gesture *repeatedly* —
191//! click, look, click again — so speaking every one of them turns a polite
192//! live region into a metronome talking over whatever the user was reading.
193//!
194//! That is the rule the rest of the workspace already follows, from both
195//! directions. `HsvCanvas` — the other 2-D manipulator, and the widget this
196//! one is modelled on — announces from its arrows and its custom actions and
197//! not from its drag or its tap. The five data views' row reorder announces
198//! from `teksilo_widgets::common::ordered_move`, which is the **non-drag**
199//! alternative; the drop itself is silent. The one pointer route in the
200//! workspace that does speak is the charts' readout
201//! (`teksilo_charts::hit::drive_readout`), and only for a **coarse** pointer
202//! that pressed and released without travelling — because that tap is an
203//! *inspection* standing in for a hover a finger cannot perform, so the
204//! utterance is its whole product, and a scrub is deliberately coalesced into
205//! nothing. A minimap tap is a *command* whose destination the user chose, so
206//! that exception does not reach it.
207//!
208//! Silence here is not lost information. The node's **value** is the same
209//! sentence, and the `AccessibilityOnly` binding refreshes it the moment the
210//! app applies the pan — so a client that re-reads the control after a click
211//! gets the new position, it simply is not interrupted with it.
212//!
213//! What *is* announced describes the viewport the minimap **asked for**, not
214//! one read back afterwards: the app owns the move (a `with_widget_mut` pan
215//! lands after the handler returns, an animated one later still), so
216//! re-reading the signal here would announce the position we just left. That
217//! is why [`on_click`](SceneMinimap::on_click)'s contract is specifically
218//! *"centre the view on this scene point"* rather than "here is a point, do
219//! as you like".
220//!
221//! **Without `on_click`** the minimap is a read-out, not a control: it still
222//! emits the named, valued node — the useful half — but takes no focus and
223//! advertises no action.
224//!
225//! That gate is worth arguing rather than assuming, because a minimap
226//! *displays* something and a display is worth reaching. The answer is that
227//! reaching it and focusing it are separate questions. The node is in the
228//! accessibility tree carrying a name and a value, so a screen reader's
229//! object / browse navigation and its rotor all arrive at it and read the
230//! position out; what the gate withholds is the **Tab** order, and Tab is for
231//! things you can operate. A read-only minimap answers Tab with nothing: no
232//! arrow does anything, `Enter` does nothing, no action is advertised, and
233//! the focus ring has parked on a picture. That is the dead stop the ARIA
234//! practices warn about, and it costs every keyboard user a press on the way
235//! past.
236//!
237//! An app that wants it anyway is not blocked — `.focusable(true)` from the
238//! framework's ordinary
239//! [`WidgetBuilder`](teksilo_core::widget_builder::WidgetBuilder) chain puts
240//! any widget into the Tab order, this one included. The default is the
241//! answer that is right without knowing the app.
242//!
243//! **Overrides** come from the framework chain, not from a second set of
244//! builders here: `.access_label(tr!(…))` renames the node (re-resolved on
245//! every AT walk, so it follows a locale change),
246//! `.access_description(…)` adds long-form context, and the rest of
247//! `docs/accessibility-overrides.md` applies unchanged.
248
249use std::cell::Cell;
250use std::rc::Rc;
251
252use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal, StrokeStyle};
253use teksilo_core::accessibility::AccessNodeBuilder;
254use teksilo_core::accesskit::{Action, Role};
255use teksilo_core::binding::BindingLevel;
256use teksilo_core::build_context::BuildContext;
257use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
258use teksilo_core::signal::Signal;
259use teksilo_core::widget::{
260 CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget,
261};
262use teksilo_core::widget_builder::HandlerSet;
263use teksilo_core::widget_id::WidgetId;
264use teksilo_i18n::{LocalizedString, lit};
265use teksilo_tokens::Color;
266
267/// Stroke width of the viewport indicator, in minimap-local pixels.
268/// `stroke_rect` centres each edge line on the boundary, so the
269/// projection area is inset by half of this — otherwise an indicator
270/// sitting exactly on the extent edge (the zoomed-all-the-way-out
271/// case) would have its outer half shaved by the clip.
272const VIEWPORT_STROKE_WIDTH: f32 = 2.0;
273
274/// Default minimap size in widget-local pixels, as `(width, height)`.
275const DEFAULT_SIZE: (f32, f32) = (200.0, 150.0);
276
277/// The AT name a minimap carries unless the app replaces it with
278/// `.access_label(...)`. A `lit!` default rather than a `tr!` one because
279/// this crate ships no Fluent bundle; the override is the translation seam.
280fn default_access_label() -> LocalizedString {
281 lit!("Scene minimap")
282}
283
284/// One arrow press, as a fraction of the viewport's own extent along the
285/// axis it moves — a scroll view's *line* step, scaled to what the user is
286/// looking at instead of to a fixed count of scene units.
287const NUDGE_FRACTION: f32 = 0.1;
288
289/// `Shift` + arrow: a whole viewport — the *page* step.
290const PAGE_FRACTION: f32 = 1.0;
291
292/// True when every component of `r` is finite. A non-finite rect
293/// would poison the projection (and, through `SetClip`, the
294/// renderer's scissor stack), so such rects are dropped rather than
295/// projected.
296fn rect_is_finite(r: Rect) -> bool {
297 r.x.is_finite() && r.y.is_finite() && r.width.is_finite() && r.height.is_finite()
298}
299
300/// True when `width` is a stroke worth emitting.
301///
302/// Both stroke widths here are caller-supplied (`border`,
303/// `content_outline`) and reach `Canvas::draw_line`, which bakes the
304/// width straight into a decoration rect: `NaN` would put a non-finite
305/// rect in the frame, and a negative width an inside-out one. Every
306/// other input to this widget is screened by [`rect_is_finite`]; these
307/// two are the numbers that screening missed. A zero width draws
308/// nothing anyway, so it is dropped with them rather than emitting four
309/// invisible lines.
310fn is_drawable_width(width: f32) -> bool {
311 width.is_finite() && width > 0.0
312}
313
314/// AABB union of two rects, skipping a non-finite operand.
315fn union_extent(a: Rect, b: Rect) -> Rect {
316 if !rect_is_finite(b) {
317 return a;
318 }
319 if !rect_is_finite(a) {
320 return b;
321 }
322 let x = a.x.min(b.x);
323 let y = a.y.min(b.y);
324 let right = a.right().max(b.right());
325 let bottom = a.bottom().max(b.bottom());
326 Rect::new(x, y, right - x, bottom - y)
327}
328
329/// The similarity transform mapping scene coordinates onto the
330/// minimap's drawing area: one uniform `scale` plus a translation.
331///
332/// Uniform rather than per-axis so the thumbnails and the viewport
333/// indicator keep their real proportions, and so a growing extent
334/// reads as an even zoom-out rather than an anisotropic warp.
335#[derive(Clone, Copy, Debug, PartialEq)]
336struct MinimapProjection {
337 scale: f32,
338 offset_x: f32,
339 offset_y: f32,
340}
341
342impl MinimapProjection {
343 /// Fit `extent` (scene coords) inside `area` (minimap-local
344 /// coords), centred, preserving aspect ratio.
345 ///
346 /// Total: a degenerate axis (zero extent, or zero area)
347 /// contributes no scale rather than a division by zero, a wholly
348 /// degenerate extent falls back to 1:1 about the area centre, and
349 /// a non-finite extent is treated as `Rect::ZERO`. The result's
350 /// `scale` is always finite and strictly positive, so
351 /// [`Self::invert`] is always well-defined.
352 fn fit(extent: Rect, area: Rect) -> Self {
353 let extent = if rect_is_finite(extent) {
354 extent
355 } else {
356 Rect::ZERO
357 };
358 let sx = if extent.width > 0.0 && area.width > 0.0 {
359 Some(area.width / extent.width)
360 } else {
361 None
362 };
363 let sy = if extent.height > 0.0 && area.height > 0.0 {
364 Some(area.height / extent.height)
365 } else {
366 None
367 };
368 let scale = match (sx, sy) {
369 (Some(a), Some(b)) => a.min(b),
370 (Some(a), None) | (None, Some(a)) => a,
371 (None, None) => 1.0,
372 };
373 let scale = if scale.is_finite() && scale > 0.0 {
374 scale
375 } else {
376 1.0
377 };
378 let centre = extent.center();
379 let target = area.center();
380 Self {
381 scale,
382 offset_x: target.x - centre.x * scale,
383 offset_y: target.y - centre.y * scale,
384 }
385 }
386
387 /// Scene point → minimap-local point.
388 fn point(&self, p: Point) -> Point {
389 Point::new(
390 p.x * self.scale + self.offset_x,
391 p.y * self.scale + self.offset_y,
392 )
393 }
394
395 /// Minimap-local point → scene point. Exact inverse of
396 /// [`Self::point`].
397 fn invert(&self, p: Point) -> Point {
398 Point::new(
399 (p.x - self.offset_x) / self.scale,
400 (p.y - self.offset_y) / self.scale,
401 )
402 }
403
404 /// Scene rect → minimap-local rect, floored at one pixel per axis
405 /// so a sub-pixel item still shows as a dot instead of vanishing.
406 fn rect(&self, r: Rect) -> Rect {
407 let tl = self.point(Point::new(r.x, r.y));
408 Rect::new(
409 tl.x,
410 tl.y,
411 (r.width * self.scale).max(1.0),
412 (r.height * self.scale).max(1.0),
413 )
414 }
415}
416
417/// A small thumbnail rendering of a [`Scene`](crate::Scene)'s
418/// content, with the live viewport rectangle highlighted.
419///
420/// Paint order: **clip on** → background fill → optional
421/// content-bounds outline → item thumbnails (dots / rects) →
422/// viewport overlay rect → inset border → **clip off**. The clip
423/// covers every one of those, the border included.
424///
425/// The projected extent is `content_bounds` expanded to contain every
426/// item thumbnail and the live viewport — see this module's
427/// documentation ("The projection, and why it expands") for why, and
428/// `docs/teksilo-scene.md` § Minimap for the same story in prose.
429pub struct SceneMinimap {
430 /// The scene-coord rect the minimap is guaranteed to show. Apps
431 /// typically use `SceneView::scene_content_bounds()` or a
432 /// hand-picked extent (e.g. `Rect::new(0,0, 10_000, 10_000)`
433 /// for a known canvas).
434 ///
435 /// This is a **floor**: the minimap expands it to contain the
436 /// item thumbnails and the live viewport, so nothing it draws can
437 /// fall outside the picture. Pass a larger rect to keep the scale
438 /// steady while the user pans inside it.
439 content_bounds: Rect,
440 /// `content_bounds` unioned with every item rect. Constant for
441 /// the widget's lifetime (both inputs are set at construction),
442 /// so it is folded once instead of per paint.
443 static_extent: Rect,
444 /// Live viewport rectangle in scene coords. Bound **twice** in
445 /// `build`: at `RepaintOnly` so the overlay re-renders whenever the
446 /// SceneView pans / zooms, and at `AccessibilityOnly` so the node's
447 /// announced position follows the same move.
448 viewport_in_scene: Signal<Rect>,
449 /// Static snapshot of items + their thumbnail color. Apps
450 /// refresh by rebuilding when items move.
451 items: Vec<(Rect, Color)>,
452 /// Minimap dimensions in widget-local pixels. Defaults to
453 /// 200×150.
454 size: Size,
455 /// The projection the most recent `paint` used — **the** single
456 /// source of truth shared by paint and tap.
457 ///
458 /// A tap inverts this exact transform rather than rebuilding one
459 /// from the extent and the drawing area, so paint and click cannot
460 /// disagree: there is nothing to disagree about. (Recomputing it
461 /// meant two copies of the extent formula and two of the area, and
462 /// a drift between them is invisible to any test that checks only
463 /// one side.) It also makes the tap answer the right question — it
464 /// inverts the frame the user is looking at.
465 ///
466 /// Seeded in `build`; `paint` takes `&self`, hence the `Cell`, and
467 /// the tap closure is `'static`, hence the `Rc`.
468 painted_projection: Rc<Cell<MinimapProjection>>,
469 /// Background fill color. Defaults to a translucent white.
470 background: Color,
471 /// Border around the minimap drawing area, drawn **inside** the
472 /// widget (see the module docs). Default 1px black.
473 border: Option<(Color, f32)>,
474 /// Color of the viewport overlay rectangle. Default solid blue,
475 /// drawn as a stroke.
476 viewport_color: Color,
477 /// Optional outline of the content extent (gives users a sense
478 /// of "you're inside this much scene"). Default `None`.
479 content_outline: Option<(Color, f32)>,
480 /// Optional navigation handler: the app is asked to centre the view
481 /// on this scene point. Reached by the pointer, the keyboard and
482 /// assistive technology alike; its presence is also what makes the
483 /// widget focusable and action-bearing.
484 on_click: Option<Rc<dyn Fn(Point, &mut EventContext)>>,
485 /// App-supplied phrasing for the accessible readout. `None` uses
486 /// [`default_readout_text`].
487 readout: Option<Rc<dyn Fn(MinimapReadout) -> LocalizedString>>,
488}
489
490impl std::fmt::Debug for SceneMinimap {
491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 f.debug_struct("SceneMinimap")
493 .field("content_bounds", &self.content_bounds)
494 .field("static_extent", &self.static_extent)
495 .field("size", &self.size)
496 .field("item_count", &self.items.len())
497 .field("on_click", &self.on_click.is_some())
498 .field("access_readout", &self.readout.is_some())
499 .finish_non_exhaustive()
500 }
501}
502
503/// Where the viewport sits inside the picture the minimap is drawing — the
504/// numbers behind the AT node's announced value, as plain fractions.
505///
506/// Handed to an [`access_readout`](SceneMinimap::access_readout) closure so an
507/// app can phrase and translate the announcement itself. Everything here is
508/// derived from the same `effective_extent` the picture is projected through,
509/// so a readout can never describe a frame other than the one on screen.
510///
511/// `#[non_exhaustive]`: the crate hands this *to* consumer code — it is the
512/// argument of an `access_readout` closure — and a readout may learn to carry
513/// another number. Build one with [`new`](Self::new).
514#[derive(Clone, Copy, Debug, PartialEq)]
515#[non_exhaustive]
516pub struct MinimapReadout {
517 /// The scene-space rect actually projected onto the drawing area:
518 /// `content_bounds ∪ every item rect ∪ viewport`.
519 pub extent: Rect,
520 /// The viewport rect, in scene coordinates.
521 pub viewport: Rect,
522 /// The viewport centre's position inside [`extent`](Self::extent), per
523 /// axis, in `0.0..=1.0` (leading/top → trailing/bottom). A degenerate
524 /// axis reads `0.5` — dead centre of a scene with no extent along it.
525 pub position: Point,
526 /// How much of [`extent`](Self::extent) the viewport covers, per axis, in
527 /// `0.0..=1.0`. A degenerate axis reads `1.0`: you are seeing all there
528 /// is of it.
529 pub coverage: Size,
530}
531
532impl MinimapReadout {
533 /// A readout stated field by field — the constructor
534 /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
535 /// The minimap derives its own; this is for a consumer testing its own
536 /// phrasing closure.
537 pub fn new(extent: Rect, viewport: Rect, position: Point, coverage: Size) -> Self {
538 Self {
539 extent,
540 viewport,
541 position,
542 coverage,
543 }
544 }
545}
546
547/// Derive the readout for a viewport inside an extent.
548///
549/// Total by construction: a non-finite extent collapses to `Rect::ZERO`, a
550/// non-finite viewport falls back to the extent (you are looking at all of
551/// it), and a degenerate axis answers `0.5` / `1.0` rather than dividing by
552/// zero. Every field it returns is finite and in range.
553fn readout_of(extent: Rect, viewport: Rect) -> MinimapReadout {
554 let extent = if rect_is_finite(extent) {
555 extent
556 } else {
557 Rect::ZERO
558 };
559 let viewport = if rect_is_finite(viewport) {
560 viewport
561 } else {
562 extent
563 };
564 // `(centre fraction, coverage fraction)` for one axis.
565 let axis = |centre: f32, span: f32, origin: f32, total: f32| -> (f32, f32) {
566 if total > 0.0 {
567 (
568 ((centre - origin) / total).clamp(0.0, 1.0),
569 (span / total).clamp(0.0, 1.0),
570 )
571 } else {
572 (0.5, 1.0)
573 }
574 };
575 let centre = viewport.center();
576 let (px, cw) = axis(centre.x, viewport.width, extent.x, extent.width);
577 let (py, ch) = axis(centre.y, viewport.height, extent.y, extent.height);
578 MinimapReadout {
579 extent,
580 viewport,
581 position: Point::new(px, py),
582 coverage: Size::new(cw, ch),
583 }
584}
585
586/// The default English phrasing of a [`MinimapReadout`].
587///
588/// Position first because that is what an arrow press changes; coverage
589/// second because it is what a zoom changes. Percentages rather than scene
590/// units: the units are the app's and may be metres, characters or nothing
591/// at all, while "42% across" is true in every scene.
592fn default_readout_text(r: MinimapReadout) -> String {
593 let pct = |f: f32| (f * 100.0).round() as i32;
594 format!(
595 "Viewport at {}% across, {}% down; showing {}% of the width and {}% of the height",
596 pct(r.position.x),
597 pct(r.position.y),
598 pct(r.coverage.width),
599 pct(r.coverage.height)
600 )
601}
602
603/// One navigation step, in the direction the arrows name.
604#[derive(Clone, Copy, PartialEq, Eq, Debug)]
605enum MinimapStep {
606 Leading,
607 Trailing,
608 Up,
609 Down,
610}
611
612impl MinimapStep {
613 /// The arrow that means this step.
614 ///
615 /// Deliberately **not** mirrored under RTL: the minimap paints its scene
616 /// left-to-right whatever the reading direction, so an arrow that flipped
617 /// would disagree with the picture the user is looking at. Same reasoning
618 /// as `HsvCanvas`, and the opposite of `Slider`, which mirrors because
619 /// its paint does.
620 fn from_key(key: Key) -> Option<Self> {
621 match key {
622 Key::ArrowLeft => Some(Self::Leading),
623 Key::ArrowRight => Some(Self::Trailing),
624 Key::ArrowUp => Some(Self::Up),
625 Key::ArrowDown => Some(Self::Down),
626 _ => None,
627 }
628 }
629
630 /// The AccessKit scroll action that means this step. Real actions rather
631 /// than four custom ones: every platform adapter already maps
632 /// `ScrollLeft` and friends, and "scroll the view left" is exactly what
633 /// this does — there is nothing here AccessKit has no word for.
634 fn from_action(action: Action) -> Option<Self> {
635 match action {
636 Action::ScrollLeft => Some(Self::Leading),
637 Action::ScrollRight => Some(Self::Trailing),
638 Action::ScrollUp => Some(Self::Up),
639 Action::ScrollDown => Some(Self::Down),
640 _ => None,
641 }
642 }
643}
644
645/// The one place a navigation request becomes a scene point and reaches the
646/// app — shared by the pointer, the keyboard and assistive technology, so
647/// the three routes cannot drift into meaning different things.
648///
649/// Holds copies rather than a borrow of the widget: the handlers it feeds are
650/// `'static`, and everything it needs is either `Copy` (`static_extent`) or a
651/// shared handle (`Signal`, `Rc`).
652struct MinimapNav {
653 /// `content_bounds ∪ every item rect`, as the widget folded it once.
654 static_extent: Rect,
655 /// The live viewport, read afresh on every request.
656 viewport: Signal<Rect>,
657 /// App-supplied phrasing for the announcement, if any.
658 readout: Option<Rc<dyn Fn(MinimapReadout) -> LocalizedString>>,
659 /// The app's navigation callback — the destination of every route.
660 on_click: Rc<dyn Fn(Point, &mut EventContext)>,
661}
662
663impl MinimapNav {
664 /// The live viewport, screened: a non-finite signal value would poison
665 /// every number downstream of it.
666 fn live_viewport(&self) -> Rect {
667 let v = self.viewport.get();
668 if rect_is_finite(v) { v } else { Rect::ZERO }
669 }
670
671 /// The announcement for a viewport inside an extent.
672 fn text_for(&self, extent: Rect, viewport: Rect) -> String {
673 let r = readout_of(extent, viewport);
674 match &self.readout {
675 Some(f) => f(r).resolve_now(),
676 None => default_readout_text(r),
677 }
678 }
679
680 /// Ask the app to centre the view on `target`. **Silent** — this is the
681 /// pointer route's door.
682 ///
683 /// Returns whether the request reached the app: a non-finite target is a
684 /// poisoned projection rather than a destination, and is dropped instead
685 /// of handed on.
686 fn go_to(&self, target: Point, ctx: &mut EventContext) -> bool {
687 if !target.x.is_finite() || !target.y.is_finite() {
688 return false;
689 }
690 (self.on_click)(target, ctx);
691 true
692 }
693
694 /// [`Self::go_to`], plus the utterance the keyboard and
695 /// assistive-technology routes owe — see the module's "Accessibility" for
696 /// why the pointer route does not come through here.
697 ///
698 /// The announcement is computed from the request, not read back from the
699 /// signal afterwards. The app owns the move and may not have made it yet
700 /// — `ctx.with_widget_mut` applies after the handler returns, and an
701 /// animated pan later still — so reading the signal here would announce
702 /// the position the user just left, on every single keypress.
703 ///
704 /// A request that was dropped says nothing: "moved" when nothing moved is
705 /// worse than silence.
706 fn go_to_announcing(&self, target: Point, ctx: &mut EventContext) {
707 if !self.go_to(target, ctx) {
708 return;
709 }
710 let mut moved = self.live_viewport();
711 moved.x = target.x - moved.width * 0.5;
712 moved.y = target.y - moved.height * 0.5;
713 let extent = union_extent(self.static_extent, moved);
714 ctx.announce(self.text_for(extent, moved));
715 }
716
717 /// Move the view one step. `coarse` is `Shift` — a whole viewport
718 /// instead of a tenth of one.
719 fn step(&self, step: MinimapStep, coarse: bool, ctx: &mut EventContext) {
720 let viewport = self.live_viewport();
721 let extent = union_extent(self.static_extent, viewport);
722 let fraction = if coarse {
723 PAGE_FRACTION
724 } else {
725 NUDGE_FRACTION
726 };
727 // A viewport with no extent of its own (before the first layout, or
728 // a degenerate view) steps by the scene instead, so the arrows still
729 // move rather than silently doing nothing.
730 let unit = |viewport_span: f32, extent_span: f32| {
731 (if viewport_span > 0.0 {
732 viewport_span
733 } else {
734 extent_span
735 })
736 .max(0.0)
737 * fraction
738 };
739 let (dx, dy) = match step {
740 MinimapStep::Leading => (-unit(viewport.width, extent.width), 0.0),
741 MinimapStep::Trailing => (unit(viewport.width, extent.width), 0.0),
742 MinimapStep::Up => (0.0, -unit(viewport.height, extent.height)),
743 MinimapStep::Down => (0.0, unit(viewport.height, extent.height)),
744 };
745 let centre = viewport.center();
746 self.go_to_announcing(Point::new(centre.x + dx, centre.y + dy), ctx);
747 }
748
749 /// Centre the view on the content: `content_bounds` unioned with the
750 /// items, which is what the picture calls "the scene" — deliberately not
751 /// the *effective* extent, which follows the viewport and would make
752 /// "go home" a no-op once you had wandered off.
753 fn centre_on_content(&self, ctx: &mut EventContext) {
754 let home = if rect_is_finite(self.static_extent) {
755 self.static_extent
756 } else {
757 Rect::ZERO
758 };
759 self.go_to_announcing(home.center(), ctx);
760 }
761}
762
763impl SceneMinimap {
764 /// Construct a minimap that shows at least `content_bounds` (the
765 /// scene-coord extent guaranteed to be visible), with `viewport`
766 /// driving the live overlay rectangle.
767 pub fn new(content_bounds: Rect, viewport: Signal<Rect>) -> Self {
768 let size = Size::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1);
769 Self {
770 content_bounds,
771 static_extent: content_bounds,
772 viewport_in_scene: viewport,
773 items: Vec::new(),
774 size,
775 painted_projection: Rc::new(Cell::new(Self::seed_projection(content_bounds, size))),
776 background: Color::new(1.0, 1.0, 1.0, 0.85),
777 border: Some((Color::new(0.0, 0.0, 0.0, 0.5), 1.0)),
778 viewport_color: Color::new(0.2, 0.5, 1.0, 1.0),
779 content_outline: None,
780 on_click: None,
781 readout: None,
782 }
783 }
784
785 /// Override the minimap size. Default `200×150`.
786 pub fn size(mut self, width: f32, height: f32) -> Self {
787 self.size = Size::new(width.max(1.0), height.max(1.0));
788 self
789 }
790
791 /// Static list of item thumbnails: `(scene_rect, color)`. The
792 /// minimap projects each rect onto its drawing area and fills it
793 /// with `color`. Apps refresh by rebuilding the widget tree
794 /// when items move.
795 ///
796 /// Item rects widen the projected extent, so a thumbnail outside
797 /// a stale or hand-picked `content_bounds` shrinks the picture
798 /// instead of painting outside the frame.
799 pub fn items(mut self, items: Vec<(Rect, Color)>) -> Self {
800 self.static_extent = items
801 .iter()
802 .fold(self.content_bounds, |acc, (r, _)| union_extent(acc, *r));
803 self.items = items;
804 self
805 }
806
807 /// Background fill color. Default semi-transparent white.
808 pub fn background(mut self, color: Color) -> Self {
809 self.background = color;
810 self
811 }
812
813 /// Border around the minimap drawing area. Pass `None` for no
814 /// border. Default 1px @ 50% black.
815 ///
816 /// The band is drawn **inside** the widget — `width` is consumed
817 /// from the drawing area, never from the neighbours (the CSS
818 /// `border-box` convention). A wide border therefore covers the
819 /// outer rim of the picture instead of the sibling next door, and
820 /// the width is honoured as given rather than clamped. A width that
821 /// is not finite and positive draws nothing.
822 pub fn border(mut self, border: Option<(Color, f32)>) -> Self {
823 self.border = border;
824 self
825 }
826
827 /// Color of the viewport overlay rectangle. Default solid blue.
828 pub fn viewport_color(mut self, color: Color) -> Self {
829 self.viewport_color = color;
830 self
831 }
832
833 /// Outline the content extent inside the minimap (gives users a
834 /// "you're somewhere inside this much scene" cue when the
835 /// minimap is taller / wider than its content). Default `None`.
836 ///
837 /// The outline tracks `content_bounds` *through the projection*,
838 /// so once the viewport wanders off it the outline visibly
839 /// shrinks and offsets — that is the cue. A width that is not
840 /// finite and positive draws nothing.
841 pub fn content_outline(mut self, outline: Option<(Color, f32)>) -> Self {
842 self.content_outline = outline;
843 self
844 }
845
846 /// Navigation handler: **centre the view on this scene point.**
847 ///
848 /// The contract is that specific on purpose. It is not "here is a point,
849 /// do as you like": the keyboard and assistive-technology routes compute
850 /// their destination from the current viewport and announce where the
851 /// move leaves it, and both of those are only true if the app centres.
852 ///
853 /// `SceneView` has no single "centre on this point" call, so the app
854 /// drives the camera it already owns. Either move the view's pan directly
855 /// — `SceneView::pan_x_signal` / `pan_y_signal`, or the app-owned pair
856 /// handed to `SceneView::view_state` — or, when the view is wrapped in a
857 /// `SceneScrollView`, move its `scroll_pos_x_signal` /
858 /// `scroll_pos_y_signal`: the same pan through the door the scroll bars
859 /// use, already expressed against the scrollable extent so a target can be
860 /// clamped to it. `examples/scene_showcase` wires the second.
861 ///
862 /// This route is the silent one — see this module's "Accessibility" for
863 /// which routes speak and why the tap is not among them.
864 ///
865 /// Installing it is also what turns the minimap from a read-out into a
866 /// control: the node becomes focusable and starts advertising its
867 /// actions (see this module's "Accessibility"). Without it the widget
868 /// still announces where the viewport is, but takes no focus — a Tab
869 /// stop with nothing behind it is worse than none.
870 ///
871 /// A pointer tap inverts the **exact** projection the last paint used —
872 /// not a recomputed one — so click-to-recentre lands on what the user is
873 /// looking at, even while the viewport is off the content or the parent
874 /// handed the minimap less room than it asked for.
875 pub fn on_click<F>(mut self, callback: F) -> Self
876 where
877 F: Fn(Point, &mut EventContext) + 'static,
878 {
879 self.on_click = Some(Rc::new(callback));
880 self
881 }
882
883 /// Phrase the accessible readout yourself.
884 ///
885 /// The closure receives the geometry as a [`MinimapReadout`] and returns
886 /// the string the AT node announces as its value and every non-pointer
887 /// route announces after a move. Use it to translate the reading
888 /// (`tr!(minimap_position(across = …, down = …))`) or to say something
889 /// the widget cannot know — page numbers, a chapter name, map
890 /// coordinates. The default is English, built by the same numbers.
891 ///
892 /// One closure for both the value and the announcement on purpose: they
893 /// are the same sentence read at two moments, and two hooks would let
894 /// them disagree.
895 pub fn access_readout<F>(mut self, phrasing: F) -> Self
896 where
897 F: Fn(MinimapReadout) -> LocalizedString + 'static,
898 {
899 self.readout = Some(Rc::new(phrasing));
900 self
901 }
902
903 /// The scene-space rect projected onto the drawing area this
904 /// frame: the static extent (content ∪ items) expanded to contain
905 /// the live viewport.
906 ///
907 /// The **only** place this formula lives. `paint` folds it into
908 /// [`Self::painted_projection`]; the tap handler reads that
909 /// projection rather than re-deriving the extent, so there is no
910 /// second copy to drift.
911 fn effective_extent(&self) -> Rect {
912 union_extent(self.static_extent, self.viewport_in_scene.get())
913 }
914
915 /// The string the AT node announces as its value: where the viewport
916 /// sits inside the picture, read off the same extent the picture is
917 /// projected through so the words and the drawing cannot disagree.
918 fn readout_text(&self) -> String {
919 let r = readout_of(self.effective_extent(), self.viewport_in_scene.get());
920 match &self.readout {
921 Some(f) => f(r).resolve_now(),
922 None => default_readout_text(r),
923 }
924 }
925
926 /// The sub-rect of `area` the projection maps onto: inset by half
927 /// the viewport indicator's stroke so that indicator is fully
928 /// inside the widget even when the viewport *is* the extent.
929 fn projection_area(area: Rect) -> Rect {
930 let margin = VIEWPORT_STROKE_WIDTH * 0.5;
931 area.inset(margin, margin, margin, margin)
932 }
933
934 /// The projection `paint` would produce for `extent` in a drawing
935 /// area of `size` — used to seed [`Self::painted_projection`] so a
936 /// tap arriving before the first paint still lands somewhere sane
937 /// instead of on a default transform.
938 fn seed_projection(extent: Rect, size: Size) -> MinimapProjection {
939 MinimapProjection::fit(
940 extent,
941 Self::projection_area(Rect::new(0.0, 0.0, size.width, size.height)),
942 )
943 }
944}
945
946impl Widget for SceneMinimap {
947 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
948 // The viewport signal drives our paint output: bind at
949 // RepaintOnly so SceneView pan/zoom flips re-render the
950 // overlay automatically.
951 self.viewport_in_scene.bind_to(
952 ctx.self_id(),
953 ctx.binding_registry(),
954 BindingLevel::RepaintOnly,
955 );
956 // …and again for the AT tree. The announced value IS the viewport
957 // position, so a pan that repaints the overlay has to re-walk
958 // accessibility too — `RepaintOnly` does not, and the two levels live
959 // in separate buckets of the binding registry, so a widget can hold
960 // one of each on the same source. Without this the node keeps
961 // announcing wherever the viewport was when the minimap was built.
962 self.viewport_in_scene.bind_to(
963 ctx.self_id(),
964 ctx.binding_registry(),
965 BindingLevel::AccessibilityOnly,
966 );
967 // Seed the projection cache so a tap arriving before the first
968 // paint still maps against a sane transform.
969 self.painted_projection
970 .set(Self::seed_projection(self.effective_extent(), self.size));
971
972 if let Some(callback) = self.on_click.clone() {
973 // One navigator behind all three routes — see `MinimapNav`.
974 let nav = Rc::new(MinimapNav {
975 static_extent: self.static_extent,
976 viewport: self.viewport_in_scene.clone(),
977 readout: self.readout.clone(),
978 on_click: callback,
979 });
980 let painted_projection = Rc::clone(&self.painted_projection);
981 let tap_nav = Rc::clone(&nav);
982 let key_nav = Rc::clone(&nav);
983 let action_nav = Rc::clone(&nav);
984 let handlers = HandlerSet::new()
985 // Focusable because the tap is a pointer gesture and the
986 // widget's only function is behind it (WCAG 2.2 SC 2.1.1).
987 // The arrows below are the keyboard route; the actions are
988 // the one for a client with no keyboard either.
989 .focusable(true)
990 .cursor(CursorIcon::Pointer)
991 // `on_tap` hands us a widget-local `Point`. Invert the
992 // projection `paint` recorded — the same object, not a
993 // re-derivation of it — so the click lands on exactly what
994 // the user saw. Nothing about the extent or the drawing
995 // area is recomputed here, so nothing can drift out of
996 // step with paint.
997 //
998 // `go_to`, not `go_to_announcing`: the user aimed at this
999 // point, and click-to-recentre is a gesture people repeat.
1000 // The node's value carries the new position for anyone who
1001 // asks for it.
1002 .on_tap(move |event, ev_ctx| {
1003 let scene_pt = painted_projection.get().invert(event.position);
1004 tap_nav.go_to(scene_pt, ev_ctx);
1005 })
1006 .on_key(move |event, ev_ctx| {
1007 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1008 return EventResponse::Ignored;
1009 };
1010 // Shift is ours (the page step); every other modifier
1011 // belongs to whatever the app bound it to, so a chord
1012 // passes through instead of being eaten here.
1013 if modifiers.without(Modifiers::SHIFT) != Modifiers::NONE {
1014 return EventResponse::Ignored;
1015 }
1016 if let Some(step) = MinimapStep::from_key(*key) {
1017 key_nav.step(step, modifiers.shift(), ev_ctx);
1018 return EventResponse::Handled;
1019 }
1020 if matches!(key, Key::Home | Key::Enter | Key::Space) {
1021 key_nav.centre_on_content(ev_ctx);
1022 return EventResponse::Handled;
1023 }
1024 EventResponse::Ignored
1025 })
1026 .on_access_action(move |action, ev_ctx| {
1027 if let Some(step) = MinimapStep::from_action(action) {
1028 // No `Shift` to read: an AT scroll request is one
1029 // unit, and the nudge is the safer of the two.
1030 action_nav.step(step, false, ev_ctx);
1031 return EventResponse::Handled;
1032 }
1033 if action == Action::Click {
1034 action_nav.centre_on_content(ev_ctx);
1035 return EventResponse::Handled;
1036 }
1037 EventResponse::Ignored
1038 });
1039 ctx.apply_self_handlers(handlers);
1040 }
1041 Vec::new()
1042 }
1043
1044 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1045 // A 2-D position has no ARIA role — there is no `slider2d` — and
1046 // `numeric_value` holds one number where this reading has four, so
1047 // the node is a named group carrying its value as text. That is what
1048 // `HsvCanvas` does for the same reason. It is emphatically NOT a
1049 // property-less `Role::GenericContainer`: the walker prunes those,
1050 // and a pruned node announces nothing and advertises nothing.
1051 builder.set_role(Role::Group);
1052 builder.set_name(default_access_label().resolve_now());
1053 builder.set_value(self.readout_text());
1054 // Actions only where there is something behind them. A minimap with
1055 // no navigation callback is a read-out: it still says where the
1056 // viewport is, which is the useful half, but it does not promise an
1057 // AT client a move it cannot make.
1058 if self.on_click.is_some() {
1059 for action in [
1060 Action::Click,
1061 Action::ScrollLeft,
1062 Action::ScrollRight,
1063 Action::ScrollUp,
1064 Action::ScrollDown,
1065 ] {
1066 builder.add_action(action);
1067 }
1068 }
1069 }
1070
1071 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1072 let w = proposal
1073 .width
1074 .unwrap_or(self.size.width)
1075 .min(self.size.width);
1076 let h = proposal
1077 .height
1078 .unwrap_or(self.size.height)
1079 .min(self.size.height);
1080 Size::new(w, h).into()
1081 }
1082
1083 fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1084 // `paint` receives absolute bounds (the canvas is NOT pre-translated to
1085 // the widget origin — unlike `on_tap`, which hands us widget-local
1086 // coords). The minimap draws everything in its own local frame
1087 // (origin 0,0), so translate the canvas to `bounds.origin` first — else
1088 // it renders at the window's top-left regardless of where it's placed.
1089 canvas.save();
1090 canvas.translate(bounds.x, bounds.y);
1091 let area = Rect::new(0.0, 0.0, bounds.width, bounds.height);
1092
1093 // Everything from here to `clear_clip` — the border included —
1094 // is bounded by the widget. The extent expansion is what keeps
1095 // *projected* geometry inside the frame; this clip is the
1096 // structural backstop that holds even if a future edit
1097 // regresses that policy, and it is the ONLY one available —
1098 // `clips_children` does not clip a widget's own paint (see the
1099 // module docs). Note the order: `set_clip` takes the
1100 // widget-LOCAL area and must come after the translate above, or
1101 // it lands at the window origin instead of on the widget.
1102 canvas.set_clip(area);
1103 // Background fill: exactly the widget, border included.
1104 canvas.fill_rect(area, self.background);
1105
1106 let projection =
1107 MinimapProjection::fit(self.effective_extent(), Self::projection_area(area));
1108 // Record the transform `on_tap` inverts. Storing the projection
1109 // itself — rather than the inputs it was built from — is what
1110 // makes paint and click structurally incapable of disagreeing.
1111 self.painted_projection.set(projection);
1112
1113 // Optional content outline: `content_bounds` through the same
1114 // projection, so it shrinks away from the frame exactly when
1115 // the extent grew past it.
1116 if let Some((color, width)) = self.content_outline
1117 && rect_is_finite(self.content_bounds)
1118 && is_drawable_width(width)
1119 {
1120 canvas.stroke_rect(
1121 projection.rect(self.content_bounds),
1122 color,
1123 StrokeStyle::solid(width),
1124 );
1125 }
1126 // Item thumbnails.
1127 for (item_rect, color) in &self.items {
1128 if !rect_is_finite(*item_rect) {
1129 continue;
1130 }
1131 canvas.fill_rect(projection.rect(*item_rect), *color);
1132 }
1133 // Viewport overlay.
1134 let viewport = self.viewport_in_scene.get();
1135 if rect_is_finite(viewport) {
1136 canvas.stroke_rect(
1137 projection.rect(viewport),
1138 self.viewport_color,
1139 StrokeStyle::solid(VIEWPORT_STROKE_WIDTH),
1140 );
1141 }
1142 // Border last so it sits on top of everything — and, unlike
1143 // every other stroke here, on a rect the caller never supplied:
1144 // `stroke_rect` centres each edge line on the boundary, so
1145 // stroking `area` itself would hang `width / 2` outside the
1146 // widget on all four sides (12 px for a 24 px border, over
1147 // whatever sibling is under it). Stroking the area inset by
1148 // half the width puts the band wholly inside instead — see the
1149 // module docs, "The border is inside the frame".
1150 if let Some((color, width)) = self.border
1151 && is_drawable_width(width)
1152 {
1153 let half = width * 0.5;
1154 canvas.stroke_rect(
1155 area.inset(half, half, half, half),
1156 color,
1157 StrokeStyle::solid(width),
1158 );
1159 }
1160 canvas.clear_clip();
1161 canvas.restore();
1162 }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168 use teksilo_core::widget_tree::WidgetTree;
1169
1170 fn assert_close(actual: f32, expected: f32, what: &str) {
1171 assert!(
1172 (actual - expected).abs() < 1e-3,
1173 "{what}: expected {expected}, got {actual}"
1174 );
1175 }
1176
1177 #[test]
1178 fn minimap_default_layout_response_is_capped() {
1179 let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
1180 let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport);
1181 let theme = teksilo_core::presets::intui::light();
1182 let ctx = LayoutContext::for_testing(&theme);
1183 // Unspecified proposal → falls back to self.size = 200×150.
1184 let lr = mm.layout_response(SizeProposal::unspecified(), &ctx);
1185 assert_eq!(lr.size.width, 200.0);
1186 assert_eq!(lr.size.height, 150.0);
1187 // Larger proposal → still capped at self.size.
1188 let lr = mm.layout_response(SizeProposal::exact(400.0, 300.0), &ctx);
1189 assert_eq!(lr.size.width, 200.0);
1190 assert_eq!(lr.size.height, 150.0);
1191 }
1192
1193 #[test]
1194 fn minimap_size_override_changes_layout_response() {
1195 let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
1196 let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport).size(120.0, 80.0);
1197 let theme = teksilo_core::presets::intui::light();
1198 let ctx = LayoutContext::for_testing(&theme);
1199 let lr = mm.layout_response(SizeProposal::unspecified(), &ctx);
1200 assert_eq!(lr.size.width, 120.0);
1201 assert_eq!(lr.size.height, 80.0);
1202 // Caps under a larger proposal.
1203 let lr = mm.layout_response(SizeProposal::exact(400.0, 300.0), &ctx);
1204 assert_eq!(lr.size.width, 120.0);
1205 assert_eq!(lr.size.height, 80.0);
1206 }
1207
1208 #[test]
1209 fn minimap_can_be_added_to_widget_tree() {
1210 // Smoke test that the widget actually integrates — build()
1211 // doesn't panic, layout pass succeeds.
1212 let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 75.0));
1213 let mm = SceneMinimap::new(Rect::new(0.0, 0.0, 1000.0, 750.0), viewport).size(120.0, 80.0);
1214 let mut tree = WidgetTree::new();
1215 let id = tree.add(mm);
1216 tree.layout(SizeProposal::unspecified());
1217 let bounds = tree.bounds(id);
1218 // With unspecified proposal, the framework respects layout_response.
1219 assert_eq!(bounds.width, 120.0);
1220 assert_eq!(bounds.height, 80.0);
1221 }
1222
1223 #[test]
1224 fn projection_maps_extent_corners_onto_the_drawing_area() {
1225 // Matched aspect (4:3 into 4:3) — the fit uses the whole area.
1226 let extent = Rect::new(0.0, 0.0, 1000.0, 750.0);
1227 let area = Rect::new(0.0, 0.0, 200.0, 150.0);
1228 let p = MinimapProjection::fit(extent, area);
1229 let tl = p.point(Point::new(0.0, 0.0));
1230 assert_close(tl.x, 0.0, "top-left x");
1231 assert_close(tl.y, 0.0, "top-left y");
1232 let br = p.point(Point::new(1000.0, 750.0));
1233 assert_close(br.x, 200.0, "bottom-right x");
1234 assert_close(br.y, 150.0, "bottom-right y");
1235 let centre = p.point(Point::new(500.0, 375.0));
1236 assert_close(centre.x, 100.0, "centre x");
1237 assert_close(centre.y, 75.0, "centre y");
1238 }
1239
1240 #[test]
1241 fn projection_invert_is_the_exact_inverse() {
1242 let extent = Rect::new(50.0, 100.0, 1000.0, 750.0);
1243 let area = Rect::new(0.0, 0.0, 200.0, 150.0);
1244 let p = MinimapProjection::fit(extent, area);
1245 for (sx, sy) in [
1246 (50.0, 100.0),
1247 (1050.0, 850.0),
1248 (550.0, 475.0),
1249 (-400.0, 9.0),
1250 ] {
1251 let back = p.invert(p.point(Point::new(sx, sy)));
1252 assert_close(back.x, sx, "round-trip x");
1253 assert_close(back.y, sy, "round-trip y");
1254 }
1255 }
1256
1257 #[test]
1258 fn projection_is_uniform_and_centres_the_letterbox() {
1259 // A 2:1 extent in a square area: the fit is limited by width,
1260 // and the picture is centred vertically.
1261 let extent = Rect::new(0.0, 0.0, 1000.0, 500.0);
1262 let area = Rect::new(0.0, 0.0, 200.0, 200.0);
1263 let p = MinimapProjection::fit(extent, area);
1264 assert_close(p.scale, 0.2, "scale is the tighter of the two axes");
1265 let tl = p.point(Point::new(0.0, 0.0));
1266 assert_close(tl.x, 0.0, "no horizontal letterbox");
1267 assert_close(tl.y, 50.0, "vertical letterbox is half the slack");
1268 let br = p.point(Point::new(1000.0, 500.0));
1269 assert_close(br.x, 200.0, "fills the width");
1270 assert_close(br.y, 150.0, "and stops short of the height");
1271 // A square scene rect stays square on the minimap.
1272 let square = p.rect(Rect::new(100.0, 100.0, 200.0, 200.0));
1273 assert_close(square.width, square.height, "aspect preserved");
1274 }
1275
1276 #[test]
1277 fn projection_of_a_degenerate_extent_is_finite_and_centred() {
1278 let area = Rect::new(0.0, 0.0, 200.0, 150.0);
1279 // Wholly degenerate (an empty scene at rest): 1:1 about the centre.
1280 let p = MinimapProjection::fit(Rect::ZERO, area);
1281 assert_close(p.scale, 1.0, "degenerate extent falls back to 1:1");
1282 let origin = p.point(Point::ZERO);
1283 assert_close(origin.x, 100.0, "origin lands at the area centre x");
1284 assert_close(origin.y, 75.0, "origin lands at the area centre y");
1285 // One degenerate axis: the other still sets the scale.
1286 let p = MinimapProjection::fit(Rect::new(0.0, 0.0, 400.0, 0.0), area);
1287 assert_close(p.scale, 0.5, "the live axis sets the scale");
1288 let mid = p.point(Point::new(200.0, 0.0));
1289 assert_close(mid.x, 100.0, "centred horizontally");
1290 assert_close(mid.y, 75.0, "the flat axis collapses to the centre");
1291 }
1292
1293 #[test]
1294 fn projection_of_a_non_finite_extent_stays_finite() {
1295 let area = Rect::new(0.0, 0.0, 200.0, 150.0);
1296 for bad in [
1297 Rect::new(f32::NAN, 0.0, 10.0, 10.0),
1298 Rect::new(0.0, 0.0, f32::INFINITY, 10.0),
1299 ] {
1300 let p = MinimapProjection::fit(bad, area);
1301 assert!(p.scale.is_finite() && p.scale > 0.0, "scale stays usable");
1302 let q = p.point(Point::new(1.0, 1.0));
1303 assert!(q.x.is_finite() && q.y.is_finite(), "output stays finite");
1304 }
1305 }
1306
1307 #[test]
1308 fn projection_rect_floors_at_one_pixel() {
1309 let p = MinimapProjection::fit(
1310 Rect::new(0.0, 0.0, 10_000.0, 10_000.0),
1311 Rect::new(0.0, 0.0, 100.0, 100.0),
1312 );
1313 // A 1-unit item at a 0.01 scale would be 0.01 px — still drawn.
1314 let r = p.rect(Rect::new(0.0, 0.0, 1.0, 1.0));
1315 assert_close(r.width, 1.0, "floored width");
1316 assert_close(r.height, 1.0, "floored height");
1317 }
1318
1319 #[test]
1320 fn effective_extent_expands_to_contain_the_live_viewport() {
1321 let content = Rect::new(0.0, 0.0, 1000.0, 1000.0);
1322 let viewport = Signal::new(Rect::new(3000.0, 3000.0, 200.0, 200.0));
1323 let mm = SceneMinimap::new(content, viewport.clone());
1324 assert_eq!(mm.effective_extent(), Rect::new(0.0, 0.0, 3200.0, 3200.0));
1325 // …and it follows the signal, with no rebuild.
1326 viewport.set(Rect::new(-500.0, -500.0, 100.0, 100.0));
1327 assert_eq!(
1328 mm.effective_extent(),
1329 Rect::new(-500.0, -500.0, 1500.0, 1500.0)
1330 );
1331 }
1332
1333 #[test]
1334 fn effective_extent_expands_to_contain_items_outside_content_bounds() {
1335 let content = Rect::new(0.0, 0.0, 100.0, 100.0);
1336 let viewport = Signal::new(Rect::new(0.0, 0.0, 100.0, 100.0));
1337 let mm = SceneMinimap::new(content, viewport).items(vec![
1338 (Rect::new(500.0, 20.0, 50.0, 50.0), Color::RED),
1339 (Rect::new(-60.0, -10.0, 10.0, 10.0), Color::BLUE),
1340 ]);
1341 assert_eq!(mm.static_extent, Rect::new(-60.0, -10.0, 610.0, 110.0));
1342 assert_eq!(mm.effective_extent(), Rect::new(-60.0, -10.0, 610.0, 110.0));
1343 }
1344
1345 #[test]
1346 fn effective_extent_ignores_non_finite_inputs() {
1347 let content = Rect::new(0.0, 0.0, 100.0, 100.0);
1348 let viewport = Signal::new(Rect::new(f32::NAN, 0.0, 10.0, 10.0));
1349 let mm = SceneMinimap::new(content, viewport).items(vec![(
1350 Rect::new(0.0, f32::INFINITY, 10.0, 10.0),
1351 Color::RED,
1352 )]);
1353 assert_eq!(mm.static_extent, content, "a non-finite item is skipped");
1354 assert_eq!(
1355 mm.effective_extent(),
1356 content,
1357 "a non-finite viewport is skipped"
1358 );
1359 }
1360
1361 #[test]
1362 fn projection_area_never_goes_negative() {
1363 // A 1×1 widget: the inset would take the area past zero.
1364 let a = SceneMinimap::projection_area(Rect::new(0.0, 0.0, 1.0, 1.0));
1365 assert!(a.width >= 0.0 && a.height >= 0.0);
1366 // …and the projection built from it is still usable.
1367 let p = MinimapProjection::fit(Rect::new(0.0, 0.0, 10.0, 10.0), a);
1368 assert!(p.scale.is_finite() && p.scale > 0.0);
1369 }
1370
1371 #[test]
1372 fn readout_reports_position_and_coverage_as_fractions() {
1373 let r = readout_of(
1374 Rect::new(0.0, 0.0, 1000.0, 500.0),
1375 Rect::new(400.0, 0.0, 200.0, 250.0),
1376 );
1377 assert_close(r.position.x, 0.5, "centred horizontally");
1378 assert_close(r.position.y, 0.25, "top quarter vertically");
1379 assert_close(r.coverage.width, 0.2, "a fifth of the width");
1380 assert_close(r.coverage.height, 0.5, "half the height");
1381 }
1382
1383 #[test]
1384 fn readout_of_a_degenerate_or_non_finite_input_is_still_a_reading() {
1385 // A degenerate axis is "dead centre, seeing all of it" rather than a
1386 // division by zero — an empty scene must not announce NaN.
1387 let r = readout_of(Rect::ZERO, Rect::ZERO);
1388 assert_close(r.position.x, 0.5, "degenerate x position");
1389 assert_close(r.coverage.height, 1.0, "degenerate y coverage");
1390 // A non-finite viewport falls back to the extent: you are looking at
1391 // all of it.
1392 let extent = Rect::new(10.0, 10.0, 100.0, 100.0);
1393 let r = readout_of(extent, Rect::new(f32::NAN, 0.0, 10.0, 10.0));
1394 assert_eq!(r.viewport, extent);
1395 assert_close(r.coverage.width, 1.0, "fallback coverage");
1396 // …and a non-finite extent collapses to zero rather than propagating.
1397 let r = readout_of(Rect::new(0.0, f32::INFINITY, 1.0, 1.0), Rect::ZERO);
1398 assert_eq!(r.extent, Rect::ZERO);
1399 assert!(r.position.x.is_finite() && r.coverage.width.is_finite());
1400 }
1401
1402 #[test]
1403 fn a_viewport_outside_the_content_still_reads_inside_the_picture() {
1404 // The extent expands to contain the viewport, so the fractions are
1405 // bounded by construction — the words cannot describe a position the
1406 // picture does not draw.
1407 let mm = SceneMinimap::new(
1408 Rect::new(0.0, 0.0, 1000.0, 1000.0),
1409 Signal::new(Rect::new(5000.0, -5000.0, 100.0, 100.0)),
1410 );
1411 let r = readout_of(mm.effective_extent(), mm.viewport_in_scene.get());
1412 assert!((0.0..=1.0).contains(&r.position.x));
1413 assert!((0.0..=1.0).contains(&r.position.y));
1414 assert!((0.0..=1.0).contains(&r.coverage.width));
1415 }
1416
1417 #[test]
1418 fn the_default_readout_text_rounds_to_whole_percents() {
1419 let text = default_readout_text(readout_of(
1420 Rect::new(0.0, 0.0, 1000.0, 1000.0),
1421 Rect::new(400.0, 400.0, 200.0, 200.0),
1422 ));
1423 assert_eq!(
1424 text,
1425 "Viewport at 50% across, 50% down; showing 20% of the width and 20% of the height"
1426 );
1427 }
1428
1429 #[test]
1430 fn items_builder_recomputes_the_static_extent_from_scratch() {
1431 // Calling `.items()` twice must not accumulate the first call's
1432 // rects into the extent — it replaces them.
1433 let content = Rect::new(0.0, 0.0, 100.0, 100.0);
1434 let viewport = Signal::new(Rect::ZERO);
1435 let mm = SceneMinimap::new(content, viewport)
1436 .items(vec![(Rect::new(900.0, 900.0, 10.0, 10.0), Color::RED)])
1437 .items(vec![(Rect::new(50.0, 50.0, 10.0, 10.0), Color::BLUE)]);
1438 assert_eq!(mm.static_extent, content);
1439 }
1440}