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//! 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}