Skip to main content

ravenrs_widgets/
imaging.rs

1//! Instruments for looking at and adjusting pictures: histograms, rulers, curves, color
2//! wheels, zoom and pan. None of them knows photos, engines or file formats: every number
3//! comes from whoever uses them.
4
5use std::cell::{Cell, RefCell};
6use std::collections::HashMap;
7use std::fmt;
8use std::rc::Rc;
9
10use ravenrs_animation::Spring;
11use ravenrs_core::{
12    Color, CursorIcon, ImageData, ImageQuad, Key, PathElement, PathFill, Point, Quad, Rect, Role, Scene,
13    Size, monotone_slopes,
14};
15use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
16use ravenrs_reactive::{Binding, Observer, State};
17use ravenrs_style::{Semantic, TextStyle};
18
19use crate::View;
20use crate::color_picker::Hsv;
21use crate::context::{BuildCx, Drag, DragPhase, NodeId, Paint, PaintCx, QuadPaint, Semantics};
22use crate::util;
23use crate::{Button, ButtonSize, ButtonStyle, IconButton, IconName};
24
25mod overlays;
26mod transform;
27mod viewing;
28
29pub use overlays::{
30    CompositionGuide, ContentSpace, Eyedropper, EyedropperArea, EyedropperState, Marquee, RubberBand,
31    RuleOfThirdsGrid,
32};
33pub use transform::{BoxHandle, BoxTransform, CropArea, CropOverlay, Protractor, TransformHandles};
34pub use viewing::{CompareMode, CompareSide, CompareView, ImageLightbox, LightboxItem};
35
36// ---------------------------------------------------------------------------------------------
37// Histogram
38// ---------------------------------------------------------------------------------------------
39
40/// How a [`Histogram`] turns counts into heights.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42#[non_exhaustive]
43#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
44pub enum HistogramScale {
45    /// Height follows the count.
46    #[default]
47    Linear,
48    /// Height follows the logarithm of the count, which brings small hills into view next
49    /// to a tall peak.
50    Logarithmic,
51}
52
53/// One series of a [`Histogram`]: ready-made bins and the color that shows them.
54#[derive(Clone, Debug, PartialEq)]
55#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
56pub struct HistogramChannel {
57    label: String,
58    color: Color,
59    bins: Rc<[f32]>,
60    line: bool,
61}
62
63#[cfg_attr(feature = "live", ravenrs_live::expose)]
64impl HistogramChannel {
65    /// A filled channel. `color` is data: it is drawn as given.
66    pub fn new(
67        label: impl Into<String>,
68        color: Color,
69        bins: impl IntoIterator<Item = impl Into<f64>>,
70    ) -> Self {
71        let bins = bins.into_iter().map(|v| v.into().max(0.0) as f32).collect();
72        Self { label: label.into(), color, bins, line: false }
73    }
74
75    /// Draws this channel as a line over the filled ones, such as luminance.
76    pub fn line(mut self) -> Self {
77        self.line = true;
78        self
79    }
80
81    /// The share of the channel in one bin, or zero for an empty channel.
82    fn share(&self, bin: Option<&f32>) -> f32 {
83        let total: f32 = self.bins.iter().sum();
84        if total > 0.0 { bin.copied().unwrap_or(0.0) / total } else { 0.0 }
85    }
86}
87
88/// Adds colors of light, as overlapping channels of a picture do: red and green make yellow,
89/// all three make gray. On a light theme a pure sum would melt into the page, and it goes
90/// down in brightness instead.
91fn light_sum(colors: impl Iterator<Item = Color>, dim: f32) -> Color {
92    let (mut r, mut g, mut b, mut count) = (0.0_f32, 0.0_f32, 0.0_f32, 0);
93    for c in colors {
94        (r, g, b, count) = (r + c.r, g + c.g, b + c.b, count + 1);
95    }
96    let shade = if count > 1 { dim } else { 1.0 };
97    Color::rgb(r.min(1.0) * shade, g.min(1.0) * shade, b.min(1.0) * shade)
98}
99
100/// How much a sum of channels is held back, by how the background looks.
101fn overlap_dim(background: Color) -> f32 {
102    let brightness = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
103    if brightness > 0.5 { 0.62 } else { 0.86 }
104}
105
106/// What it takes to draw a set of channels, apart from any widget.
107#[derive(Clone, Debug)]
108struct HistogramPicture {
109    channels: Rc<[HistogramChannel]>,
110    scale: HistogramScale,
111    dim: f32,
112    opacity: f32,
113}
114
115#[cfg_attr(feature = "live", ravenrs_live::expose)]
116impl HistogramPicture {
117    /// The tallest count, leaving out the two ends: a pile of clipped pixels there would
118    /// otherwise flatten everything else. The clip marks tell about those.
119    fn peak(&self) -> f32 {
120        let top = |inner: bool| {
121            self.channels
122                .iter()
123                .flat_map(|c| {
124                    let bins = &c.bins[..];
125                    if inner && bins.len() > 2 { &bins[1..bins.len() - 1] } else { bins }
126                })
127                .copied()
128                .fold(0.0, f32::max)
129        };
130        let inner = top(true);
131        if inner > 0.0 { inner } else { top(false) }
132    }
133
134    fn height(&self, value: f32, peak: f32) -> f32 {
135        if peak <= 0.0 {
136            return 0.0;
137        }
138        let h = match self.scale {
139            HistogramScale::Linear => value / peak,
140            HistogramScale::Logarithmic => (1.0 + value).ln() / (1.0 + peak).ln(),
141        };
142        h.clamp(0.0, 1.0)
143    }
144
145    /// The tallest bin of `bins` under column `column` of `columns`, as a height.
146    fn column(&self, bins: &[f32], column: usize, columns: usize, peak: f32) -> f32 {
147        let from = column * bins.len() / columns;
148        let to = ((column + 1) * bins.len()).div_ceil(columns).clamp(from + 1, bins.len().max(from + 1));
149        let top = bins.get(from..to).map_or(0.0, |part| part.iter().copied().fold(0.0, f32::max));
150        self.height(top, peak)
151    }
152
153    fn paint(&self, scene: &mut Scene, area: Rect) {
154        let widest = self.channels.iter().map(|c| c.bins.len()).max().unwrap_or(0);
155        if widest == 0 || area.is_empty() {
156            return;
157        }
158        // Never more columns than pixels: thin ones would only cost and blur.
159        let columns = widest.min(area.width().floor().max(1.0) as usize);
160        let peak = self.peak();
161        let edge = |column: usize| area.x() + (area.width() * column as f32 / columns as f32).round();
162        let mut stack: Vec<(f32, Color)> = Vec::with_capacity(self.channels.len());
163        for column in 0..columns {
164            stack.clear();
165            stack.extend(
166                self.channels
167                    .iter()
168                    .filter(|c| !c.line && !c.bins.is_empty())
169                    .map(|c| (self.column(&c.bins, column, columns, peak), c.color)),
170            );
171            stack.sort_by(|a, b| b.0.total_cmp(&a.0));
172            let (x, right) = (edge(column), edge(column + 1));
173            // Top down: one channel alone, then two together, then all of them.
174            for i in 0..stack.len() {
175                let (top, bottom) = (stack[i].0, stack.get(i + 1).map_or(0.0, |s| s.0));
176                if top - bottom <= 0.0 {
177                    continue;
178                }
179                let color = light_sum(stack[..=i].iter().map(|s| s.1), self.dim);
180                let y = area.max_y() - area.height() * top;
181                let h = area.height() * (top - bottom);
182                scene.push_quad(Quad::new(
183                    Rect::new(x, y, (right - x).max(1.0), h),
184                    color.with_alpha(self.opacity),
185                ));
186            }
187        }
188        for channel in self.channels.iter().filter(|c| c.line && !c.bins.is_empty()) {
189            let line: Vec<Point> = (0..columns)
190                .map(|column| {
191                    let h = self.column(&channel.bins, column, columns, peak);
192                    let x = area.x() + area.width() * (column as f32 + 0.5) / columns as f32;
193                    Point::new(x, area.max_y() - area.height() * h)
194                })
195                .collect();
196            let color = channel.color.with_alpha(self.opacity);
197            scene.push_path(PathFill::solid(stroke_outline(&line, 1.25), color));
198        }
199    }
200}
201
202/// The outline of a line through `points`: a stroke is a fill of its expanded outline.
203fn stroke_outline(points: &[Point], width: f32) -> Vec<PathElement> {
204    let half = width / 2.0;
205    let normal = |i: usize| {
206        let (a, b) = (points[i.saturating_sub(1)], points[(i + 1).min(points.len() - 1)]);
207        let (dx, dy) = (b.x - a.x, b.y - a.y);
208        let length = dx.hypot(dy).max(f32::EPSILON);
209        (-dy / length * half, dx / length * half)
210    };
211    let mut outline = Vec::with_capacity(points.len() * 2 + 2);
212    for (i, p) in points.iter().enumerate() {
213        let (nx, ny) = normal(i);
214        let at = Point::new(p.x + nx, p.y + ny);
215        outline.push(if i == 0 { PathElement::MoveTo(at) } else { PathElement::LineTo(at) });
216    }
217    for (i, p) in points.iter().enumerate().rev() {
218        let (nx, ny) = normal(i);
219        outline.push(PathElement::LineTo(Point::new(p.x - nx, p.y - ny)));
220    }
221    outline.push(PathElement::Close);
222    outline
223}
224
225/// Overlapping channels from ready-made bins, in a linear or logarithmic scale, with a mark
226/// at each end when too much of a channel piles up there. Where channels overlap, their
227/// colors add up, as light does, so each stays readable.
228///
229/// ```
230/// use ravenrs_core::Color;
231/// use ravenrs_widgets::{Histogram, HistogramChannel, HistogramScale};
232///
233/// let red: Vec<u32> = (0..256).map(|i| (i * (256 - i)) as u32).collect();
234/// let view = Histogram::new([HistogramChannel::new("Red", Color::rgb(0.9, 0.2, 0.2), red)])
235///     .scale(HistogramScale::Logarithmic)
236///     .zones(5);
237/// ```
238#[derive(Clone, Debug)]
239#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
240pub struct Histogram {
241    channels: Rc<[HistogramChannel]>,
242    scale: HistogramScale,
243    clip_share: f32,
244    zones: u32,
245    width: Option<f32>,
246    height: f32,
247    label: Option<String>,
248}
249
250#[cfg_attr(feature = "live", ravenrs_live::expose)]
251impl Histogram {
252    /// A histogram of these channels, 96 logical pixels tall, across its parent.
253    pub fn new(channels: impl IntoIterator<Item = HistogramChannel>) -> Self {
254        Self {
255            channels: channels.into_iter().collect(),
256            scale: HistogramScale::Linear,
257            clip_share: 0.001,
258            zones: 0,
259            width: None,
260            height: 96.0,
261            label: None,
262        }
263    }
264
265    /// How counts turn into heights (`Linear` by default).
266    pub fn scale(mut self, scale: HistogramScale) -> Self {
267        self.scale = scale;
268        self
269    }
270
271    /// The share of a channel that must sit in an end bin to light that end's mark, from 0
272    /// to 1 (0.001 by default, which ignores a stray reflection).
273    pub fn clip_share(mut self, share: f32) -> Self {
274        self.clip_share = share;
275        self
276    }
277
278    /// Divides the width in this many equal zones, with light lines (none by default).
279    pub fn zones(mut self, zones: u32) -> Self {
280        self.zones = zones;
281        self
282    }
283
284    /// Fixed size in logical pixels.
285    pub fn size(mut self, width: f32, height: f32) -> Self {
286        self.width = Some(width);
287        self.height = height;
288        self
289    }
290
291    /// Height in logical pixels (96 by default).
292    pub fn height(mut self, height: f32) -> Self {
293        self.height = height;
294        self
295    }
296
297    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
298    pub fn label(mut self, label: impl Into<String>) -> Self {
299        self.label = Some(label.into());
300        self
301    }
302
303    /// Which ends have a channel piled up: the sums of their colors, low end first.
304    fn clipped(&self, dim: f32) -> (Option<Color>, Option<Color>) {
305        let end = |pick: fn(&[f32]) -> Option<&f32>| {
306            let over: Vec<Color> = self
307                .channels
308                .iter()
309                .filter(|c| !c.line && c.share(pick(&c.bins)) > self.clip_share)
310                .map(|c| c.color)
311                .collect();
312            (!over.is_empty()).then(|| light_sum(over.into_iter(), dim))
313        };
314        (end(<[f32]>::first), end(<[f32]>::last))
315    }
316}
317
318impl View for Histogram {
319    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
320        let theme = *cx.theme();
321        let labels = cx.labels();
322        let background = theme.color(Semantic::Canvas);
323        let dim = overlap_dim(background);
324        let picture =
325            HistogramPicture { channels: self.channels.clone(), scale: self.scale, dim, opacity: 1.0 };
326        let (low, high) = self.clipped(dim);
327        let (border, radius, zones) = (theme.color(Semantic::Border), theme.radii.sm, self.zones);
328        let node = cx.container(
329            LayoutStyle {
330                width: self.width,
331                height: Some(self.height),
332                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
333                ..LayoutStyle::leaf()
334            },
335            &[],
336            Paint::Custom(Rc::new(move |frame: Rect, scene| {
337                scene.push_quad(Quad::new(frame, background).radius(radius).border(1.0, border));
338                let area = frame.inflate(-1.0);
339                for zone in 1..zones {
340                    let x = area.x() + (area.width() * zone as f32 / zones as f32).round();
341                    let line = Rect::new(x, area.y(), 1.0, area.height());
342                    scene.push_quad(Quad::new(line, border.with_alpha(border.a * 0.5)));
343                }
344                picture.paint(scene, area);
345                paint_clip_marks(scene, area, low, high);
346            })),
347        );
348        let mut value: Vec<&str> = self.channels.iter().map(|c| c.label.as_str()).collect();
349        if low.is_some() {
350            value.push(&labels.histogram_clipped_low);
351        }
352        if high.is_some() {
353            value.push(&labels.histogram_clipped_high);
354        }
355        let name = self.label.clone().unwrap_or_else(|| labels.histogram.clone());
356        cx.semantics(node, Semantics::new(Role::Image).name(name).value(value.join(", ")));
357        node
358    }
359}
360
361/// A triangle in each top corner that has clipping. Its presence says it, whatever the color.
362fn paint_clip_marks(scene: &mut Scene, area: Rect, low: Option<Color>, high: Option<Color>) {
363    const SIDE: f32 = 9.0;
364    let (top, left, right) = (area.y() + 2.0, area.x() + 2.0, area.max_x() - 2.0);
365    if let Some(color) = low {
366        let corner = [(left, top), (left + SIDE, top), (left, top + SIDE)];
367        scene.push_path(PathFill::solid(triangle(corner), color));
368    }
369    if let Some(color) = high {
370        let corner = [(right, top), (right - SIDE, top), (right, top + SIDE)];
371        scene.push_path(PathFill::solid(triangle(corner), color));
372    }
373}
374
375fn triangle(corners: [(f32, f32); 3]) -> Vec<PathElement> {
376    let at = |(x, y): (f32, f32)| Point::new(x, y);
377    vec![
378        PathElement::MoveTo(at(corners[0])),
379        PathElement::LineTo(at(corners[1])),
380        PathElement::LineTo(at(corners[2])),
381        PathElement::Close,
382    ]
383}
384
385// ---------------------------------------------------------------------------------------------
386// Ruler
387// ---------------------------------------------------------------------------------------------
388
389/// Largest number of tick marks a ruler draws per logical pixel of length: a guard for
390/// nonsense scales, since the step already keeps real ones far below this.
391const RULER_TICKS_PER_PIXEL: f32 = 0.5;
392
393/// The round number at or above `least`: 1, 2 or 5 times a power of ten.
394fn round_step(least: f32) -> f32 {
395    if !least.is_finite() || least <= 0.0 {
396        return 1.0;
397    }
398    let power = 10_f32.powf(least.log10().floor());
399    [1.0, 2.0, 5.0, 10.0].into_iter().map(|m| m * power).find(|s| *s >= least * 0.999).unwrap_or(10.0 * power)
400}
401
402/// How many parts a step divides into, the most that keeps marks at least 4 pixels apart.
403fn ruler_subdivisions(step: f32, pixels_per_unit: f32, whole_units: bool) -> u32 {
404    [10_u32, 5, 2]
405        .into_iter()
406        .find(|&n| {
407            let part = step / n as f32;
408            part * pixels_per_unit >= 4.0 && (!whole_units || part >= 1.0)
409        })
410        .unwrap_or(1)
411}
412
413/// A number on a ruler, with only the decimals its step needs.
414fn ruler_number(value: f32, step: f32) -> String {
415    let decimals = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize };
416    let text = format!("{value:.decimals$}");
417    // A value a rounding error away from zero must not read "-0".
418    if text.starts_with('-') && text[1..].chars().all(|c| c == '0' || c == '.') {
419        text[1..].to_string()
420    } else {
421        text
422    }
423}
424
425/// A horizontal or vertical ruler in the caller's units. It picks a round step for the scale,
426/// so marks never crowd as the zoom changes. Numbers on a vertical ruler stack digit over
427/// digit, since text does not rotate.
428///
429/// ```
430/// use ravenrs_widgets::Ruler;
431///
432/// // 2.5 screen pixels per unit, with unit 120 at the start and the pointer at unit 180.
433/// let top = Ruler::horizontal().scale(2.5).origin(120.0).marker(180.0);
434/// let side = Ruler::vertical().scale(2.5).whole_units();
435/// ```
436#[derive(Clone, Debug)]
437#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
438pub struct Ruler {
439    axis: Axis,
440    scale: f32,
441    origin: f32,
442    marker: Option<f32>,
443    thickness: f32,
444    min_spacing: f32,
445    whole_units: bool,
446    label: Option<String>,
447}
448
449#[cfg_attr(feature = "live", ravenrs_live::expose)]
450impl Ruler {
451    /// A ruler across its parent, with one pixel per unit and zero at the start.
452    pub fn horizontal() -> Self {
453        Self::along(Axis::Horizontal)
454    }
455
456    /// A ruler down its parent, with one pixel per unit and zero at the top.
457    pub fn vertical() -> Self {
458        Self::along(Axis::Vertical)
459    }
460
461    fn along(axis: Axis) -> Self {
462        Self {
463            axis,
464            scale: 1.0,
465            origin: 0.0,
466            marker: None,
467            thickness: 18.0,
468            min_spacing: 64.0,
469            whole_units: false,
470            label: None,
471        }
472    }
473
474    /// Logical pixels per unit: the zoom.
475    pub fn scale(mut self, pixels_per_unit: f32) -> Self {
476        self.scale = pixels_per_unit;
477        self
478    }
479
480    /// The value in units at the ruler's start, which panning moves.
481    pub fn origin(mut self, units: f32) -> Self {
482        self.origin = units;
483        self
484    }
485
486    /// Marks this value in units, such as where the pointer is.
487    pub fn marker(mut self, units: f32) -> Self {
488        self.marker = Some(units);
489        self
490    }
491
492    /// Thickness in logical pixels (18 by default).
493    pub fn thickness(mut self, thickness: f32) -> Self {
494        self.thickness = thickness;
495        self
496    }
497
498    /// The least distance between numbered marks, in logical pixels (64 by default).
499    pub fn min_spacing(mut self, pixels: f32) -> Self {
500        self.min_spacing = pixels;
501        self
502    }
503
504    /// Never divides below one unit, for what comes in whole pieces, such as pixels.
505    pub fn whole_units(mut self) -> Self {
506        self.whole_units = true;
507        self
508    }
509
510    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
511    pub fn label(mut self, label: impl Into<String>) -> Self {
512        self.label = Some(label.into());
513        self
514    }
515
516    /// The step between numbered marks at this scale.
517    fn step(&self) -> f32 {
518        let least = self.min_spacing.max(1.0) / self.scale.max(f32::EPSILON);
519        round_step(if self.whole_units { least.max(1.0) } else { least })
520    }
521}
522
523impl View for Ruler {
524    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
525        let theme = *cx.theme();
526        let labels = cx.labels();
527        let horizontal = self.axis == Axis::Horizontal;
528        let (surface, tick, ink, accent) = (
529            theme.color(Semantic::Surface),
530            theme.color(Semantic::Border),
531            theme.color(Semantic::InkMuted),
532            theme.color(Semantic::Accent),
533        );
534        let attrs = {
535            let mut attrs = util::attrs(&theme, TextStyle::Caption, None);
536            attrs.size = 9.0;
537            attrs.line_height = 10.0;
538            attrs
539        };
540        let ruler = self.clone();
541        let step = self.step();
542        let node = cx.container(
543            LayoutStyle {
544                width: (!horizontal).then_some(self.thickness),
545                height: horizontal.then_some(self.thickness),
546                align_self: Some(Align::Stretch),
547                overflow_hidden: true,
548                ..LayoutStyle::leaf()
549            },
550            &[],
551            Paint::Rich(Rc::new(move |frame: Rect, paint: &mut PaintCx<'_>| {
552                if frame.is_empty() {
553                    return;
554                }
555                let scene = &mut *paint.scene;
556                scene.push_quad(Quad::new(frame, surface));
557                // The edge next to what it measures.
558                let edge = if horizontal {
559                    Rect::new(frame.x(), frame.max_y() - 1.0, frame.width(), 1.0)
560                } else {
561                    Rect::new(frame.max_x() - 1.0, frame.y(), 1.0, frame.height())
562                };
563                scene.push_quad(Quad::new(edge, tick));
564                scene.push_clip(frame);
565                let length = if horizontal { frame.width() } else { frame.height() };
566                let across = if horizontal { frame.height() } else { frame.width() };
567                let place = |units: f32| (units - ruler.origin) * ruler.scale;
568                let parts = ruler_subdivisions(step, ruler.scale, ruler.whole_units);
569                let part = step / parts as f32;
570                let first = (ruler.origin / part).floor() as i64;
571                let limit = (length * RULER_TICKS_PER_PIXEL) as i64 + 2;
572                for index in first..first.saturating_add(limit) {
573                    let units = index as f32 * part;
574                    let at = place(units);
575                    if at > length {
576                        break;
577                    }
578                    let major = index.rem_euclid(i64::from(parts)) == 0;
579                    let half =
580                        parts.is_multiple_of(2) && index.rem_euclid(i64::from(parts)) == i64::from(parts / 2);
581                    let size = if major {
582                        across
583                    } else if half {
584                        across * 0.45
585                    } else {
586                        across * 0.25
587                    };
588                    let mark = if horizontal {
589                        Rect::new(frame.x() + at.round(), frame.max_y() - size, 1.0, size)
590                    } else {
591                        Rect::new(frame.max_x() - size, frame.y() + at.round(), size, 1.0)
592                    };
593                    paint.scene.push_quad(Quad::new(mark, tick));
594                    if !major {
595                        continue;
596                    }
597                    let number = ruler_number(units, step);
598                    if horizontal {
599                        let origin = Point::new(frame.x() + at.round() + 3.0, frame.y() + 1.0);
600                        let mut layout = paint.text.layout(&number, &attrs);
601                        layout.measure(None);
602                        layout.paint(origin, ink, paint.scene);
603                    } else {
604                        // One character per line, each centered on its own width.
605                        let mut y = frame.y() + at.round() + 2.0;
606                        for digit in number.chars() {
607                            let mut buffer = [0_u8; 4];
608                            let mut layout = paint.text.layout(digit.encode_utf8(&mut buffer), &attrs);
609                            let width = layout.measure(None).width;
610                            let x = frame.x() + ((across - 3.0 - width) / 2.0).max(0.0);
611                            layout.paint(Point::new(x, y), ink, paint.scene);
612                            y += attrs.line_height - 1.0;
613                        }
614                    }
615                }
616                if let Some(marker) = ruler.marker {
617                    let at = place(marker);
618                    let line = if horizontal {
619                        Rect::new(frame.x() + at - 0.5, frame.y(), 1.0, frame.height())
620                    } else {
621                        Rect::new(frame.x(), frame.y() + at - 0.5, frame.width(), 1.0)
622                    };
623                    paint.scene.push_quad(Quad::new(line, accent));
624                    let corners = if horizontal {
625                        let (x, y) = (frame.x() + at, frame.max_y());
626                        [(x - 4.0, y - 5.0), (x + 4.0, y - 5.0), (x, y)]
627                    } else {
628                        let (x, y) = (frame.max_x(), frame.y() + at);
629                        [(x - 5.0, y - 4.0), (x - 5.0, y + 4.0), (x, y)]
630                    };
631                    paint.scene.push_path(PathFill::solid(triangle(corners), accent));
632                }
633                paint.scene.pop_clip();
634            })),
635        );
636        let name = self.label.clone().unwrap_or_else(|| labels.ruler.clone());
637        let mut semantics = Semantics::new(Role::Image).name(name);
638        if let Some(marker) = self.marker {
639            semantics = semantics.value(ruler_number(marker, step));
640        }
641        cx.semantics(node, semantics);
642        node
643    }
644}
645
646// ---------------------------------------------------------------------------------------------
647// ColorWheel
648// ---------------------------------------------------------------------------------------------
649
650/// A color as a [`ColorWheel`] holds it, without brightness.
651#[derive(Clone, Copy, Debug, Default, PartialEq)]
652#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
653pub struct HueSaturation {
654    /// Degrees, from 0 up to 360. Red is 0.
655    pub hue: f32,
656    /// From 0 (gray, at the center of the wheel) to 1 (pure, at its edge).
657    pub saturation: f32,
658}
659
660#[cfg_attr(feature = "live", ravenrs_live::expose)]
661impl HueSaturation {
662    /// A hue in degrees and a saturation from 0 to 1, brought within range.
663    pub fn new(hue: f32, saturation: f32) -> Self {
664        Self { hue: hue.rem_euclid(360.0), saturation: saturation.clamp(0.0, 1.0) }
665    }
666
667    /// The color this is, at this brightness.
668    fn color(self, value: f32) -> Color {
669        Hsv { hue: self.hue, saturation: self.saturation, value, alpha: 1.0 }.color()
670    }
671
672    /// Where this color sits, relative to the center of a wheel of this radius: red to the
673    /// right and hue growing counterclockwise, as color tools lay it out.
674    fn offset(self, radius: f32) -> Point {
675        let (sin, cos) = self.hue.to_radians().sin_cos();
676        Point::new(cos * self.saturation * radius, -sin * self.saturation * radius)
677    }
678
679    /// The color at an offset from the center of a wheel of this radius. Beyond the edge it
680    /// is the edge, so a drag keeps working outside the disc.
681    fn at(offset: Point, radius: f32) -> Self {
682        let distance = offset.x.hypot(offset.y);
683        let hue = (-offset.y).atan2(offset.x).to_degrees();
684        Self::new(hue, distance / radius.max(1.0))
685    }
686}
687
688/// Room around the disc, in logical pixels, for the puck at the edge.
689const WHEEL_MARGIN: f32 = 7.0;
690
691thread_local! {
692    /// Discs already computed, by side in pixels and brightness. A panel with four wheels
693    /// shares one, and a drag repaints none. The few sizes in use stay few.
694    static WHEEL_DISCS: RefCell<HashMap<(u32, u32), ImageData>> = RefCell::new(HashMap::new());
695}
696
697/// The disc of colors, `side` pixels wide, with an antialiased edge. Costly to compute and
698/// cheap to show, since it is a texture from then on.
699fn wheel_disc(side: u32, value: f32) -> Option<ImageData> {
700    WHEEL_DISCS.with_borrow_mut(|discs| {
701        let key = (side, value.to_bits());
702        if let Some(disc) = discs.get(&key) {
703            return Some(disc.clone());
704        }
705        if discs.len() >= 8 {
706            discs.clear();
707        }
708        let middle = side as f32 / 2.0;
709        let disc = ImageData::from_fn(side, side, |x, y| {
710            let offset = Point::new(x as f32 + 0.5 - middle, y as f32 + 0.5 - middle);
711            let coverage = (middle - offset.x.hypot(offset.y)).clamp(0.0, 1.0);
712            HueSaturation::at(offset, middle).color(value).with_alpha(coverage)
713        })?;
714        discs.insert(key, disc.clone());
715        Some(disc)
716    })
717}
718
719/// Picks hue and saturation on a disc, by a drag or the arrow keys: left and right turn the
720/// hue, up and down change the saturation, with Shift for bigger steps. Holding Shift during
721/// a drag keeps the hue. A double click or Home takes the color out, keeping the hue.
722///
723/// ```
724/// use ravenrs_reactive::State;
725/// use ravenrs_widgets::{ColorWheel, HueSaturation};
726///
727/// let tint = State::new(HueSaturation::new(30.0, 0.4));
728/// let wheel = ColorWheel::new(tint.binding()).diameter(140.0).label("Shadows");
729/// ```
730#[derive(Debug)]
731#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
732pub struct ColorWheel {
733    value: Binding<HueSaturation>,
734    diameter: f32,
735    brightness: f32,
736    label: Option<String>,
737    disabled: bool,
738}
739
740#[cfg_attr(feature = "live", ravenrs_live::expose)]
741impl ColorWheel {
742    /// A wheel bound to `value`, 160 logical pixels wide.
743    pub fn new(value: Binding<HueSaturation>) -> Self {
744        Self { value, diameter: 160.0, brightness: 1.0, label: None, disabled: false }
745    }
746
747    /// Width and height in logical pixels.
748    pub fn diameter(mut self, diameter: f32) -> Self {
749        self.diameter = diameter;
750        self
751    }
752
753    /// How bright the disc is drawn, from 0 to 1 (1 by default). It changes only the look.
754    pub fn brightness(mut self, brightness: f32) -> Self {
755        // Few levels, since each one is a texture.
756        self.brightness = (brightness.clamp(0.0, 1.0) * 16.0).round() / 16.0;
757        self
758    }
759
760    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
761    pub fn label(mut self, label: impl Into<String>) -> Self {
762        self.label = Some(label.into());
763        self
764    }
765
766    /// Turns the control off: it fades, leaves the tab order and ignores input.
767    pub fn disabled(mut self, disabled: bool) -> Self {
768        self.disabled = disabled;
769        self
770    }
771}
772
773impl View for ColorWheel {
774    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
775        let theme = *cx.theme();
776        let labels = cx.labels();
777        let current = self.value.get();
778        let (edge, ink, surface) =
779            (theme.color(Semantic::Border), theme.color(Semantic::Ink), theme.color(Semantic::Surface));
780        let (value, opacity) = (self.brightness, if self.disabled { 0.5 } else { 1.0 });
781        let node = cx.container(
782            LayoutStyle {
783                width: Some(self.diameter),
784                height: Some(self.diameter),
785                align_self: Some(Align::Start),
786                ..LayoutStyle::leaf()
787            },
788            &[],
789            Paint::Rich(Rc::new(move |frame: Rect, paint: &mut PaintCx<'_>| {
790                let disc_frame = frame.inflate(-WHEEL_MARGIN);
791                if disc_frame.is_empty() {
792                    return;
793                }
794                let side = (disc_frame.width() * paint.scale).ceil() as u32;
795                let scene = &mut *paint.scene;
796                if let Some(disc) = wheel_disc(side, value) {
797                    scene.push_image(ImageQuad { opacity, ..ImageQuad::new(disc_frame, disc) });
798                }
799                let radius = disc_frame.width() / 2.0;
800                let ring = Quad::new(disc_frame.inflate(0.5), Color::TRANSPARENT).radius(radius + 0.5);
801                scene.push_quad(ring.border(1.0, edge));
802                let middle = frame.center();
803                let tick = |w: f32, h: f32| Rect::new(middle.x - w / 2.0, middle.y - h / 2.0, w, h);
804                // A darker gray of the disc itself, since the center is the same in any theme.
805                let cross = HueSaturation::default().color(value * 0.45).with_alpha(opacity);
806                scene.push_quad(Quad::new(tick(9.0, 1.0), cross));
807                scene.push_quad(Quad::new(tick(1.0, 9.0), cross));
808                // Two rings, one of each kind of contrast, so the puck shows over any color.
809                let offset = current.offset(radius);
810                let puck = Rect::new(middle.x + offset.x - 6.5, middle.y + offset.y - 6.5, 13.0, 13.0);
811                scene.push_quad(
812                    Quad::new(puck, current.color(value).with_alpha(opacity))
813                        .radius(6.5)
814                        .border(2.0, surface),
815                );
816                scene
817                    .push_quad(Quad::new(puck.inflate(1.0), Color::TRANSPARENT).radius(7.5).border(1.0, ink));
818            })),
819        );
820        let name = self.label.clone().unwrap_or_else(|| labels.color_wheel.clone());
821        let text = format!(
822            "{} {}°, {} {}%",
823            labels.color_hue,
824            current.hue.round(),
825            labels.color_saturation,
826            (current.saturation * 100.0).round()
827        );
828        cx.semantics(node, Semantics::new(Role::Slider).name(name).value(text).disabled(self.disabled));
829        if self.disabled {
830            return node;
831        }
832        cx.focusable(node, self.diameter / 2.0);
833        cx.cursor(node, CursorIcon::Pointer);
834        let (dragged, modifiers) = (self.value.clone(), cx.modifiers());
835        cx.on_drag(
836            node,
837            Rc::new(move |d| {
838                if d.phase == DragPhase::End {
839                    return;
840                }
841                let middle = d.frame.center();
842                let radius = (d.frame.width() / 2.0 - WHEEL_MARGIN).max(1.0);
843                let offset = Point::new(d.position.x - middle.x, d.position.y - middle.y);
844                let picked = HueSaturation::at(offset, radius);
845                let hue = if modifiers.get().shift { dragged.get().hue } else { picked.hue };
846                dragged.set(HueSaturation::new(hue, picked.saturation));
847            }),
848        );
849        let reset = self.value.clone();
850        cx.on_double_click(node, Rc::new(move || reset.update(|v| v.saturation = 0.0)));
851        let keyed = self.value.clone();
852        cx.on_key(
853            node,
854            Rc::new(move |key, modifiers| {
855                let times = if modifiers.shift { 10.0 } else { 1.0 };
856                let now = keyed.get();
857                keyed.set(match key {
858                    Key::ArrowLeft => HueSaturation::new(now.hue - times, now.saturation),
859                    Key::ArrowRight => HueSaturation::new(now.hue + times, now.saturation),
860                    Key::ArrowUp => HueSaturation::new(now.hue, now.saturation + times / 100.0),
861                    Key::ArrowDown => HueSaturation::new(now.hue, now.saturation - times / 100.0),
862                    Key::Home => HueSaturation::new(now.hue, 0.0),
863                    _ => return false,
864                });
865                true
866            }),
867        );
868        node
869    }
870}
871
872// ---------------------------------------------------------------------------------------------
873// CurveEditor
874// ---------------------------------------------------------------------------------------------
875
876/// Room around the plot of a [`CurveEditor`], in logical pixels, for the handles at its ends.
877const CURVE_MARGIN: f32 = 7.0;
878/// How close to a handle a press takes it, in logical pixels.
879const CURVE_REACH: f32 = 11.0;
880/// The least horizontal distance between two points, as a fraction of the plot.
881const CURVE_MIN_GAP: f32 = 0.01;
882
883/// The points in order, or a straight line when there are too few to make a curve.
884fn curve_points(points: &[Point]) -> Vec<Point> {
885    let mut sorted: Vec<Point> = points
886        .iter()
887        .filter(|p| p.x.is_finite() && p.y.is_finite())
888        .map(|p| Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)))
889        .collect();
890    sorted.sort_by(|a, b| a.x.total_cmp(&b.x));
891    sorted.dedup_by(|a, b| (a.x - b.x).abs() < f32::EPSILON);
892    if sorted.len() < 2 { vec![Point::ZERO, Point::new(1.0, 1.0)] } else { sorted }
893}
894
895/// The plot of a [`CurveEditor`] inside its frame.
896#[derive(Clone, Copy)]
897struct CurvePlot(Rect);
898
899#[cfg_attr(feature = "live", ravenrs_live::expose)]
900impl CurvePlot {
901    fn of(frame: Rect) -> Self {
902        Self(frame.inflate(-CURVE_MARGIN))
903    }
904
905    fn screen(self, p: Point) -> Point {
906        Point::new(self.0.x() + p.x * self.0.width(), self.0.max_y() - p.y * self.0.height())
907    }
908
909    fn value(self, at: Point) -> Point {
910        Point::new(
911            ((at.x - self.0.x()) / self.0.width().max(1.0)).clamp(0.0, 1.0),
912            ((self.0.max_y() - at.y) / self.0.height().max(1.0)).clamp(0.0, 1.0),
913        )
914    }
915
916    /// The point whose handle is within reach of `at`, the closest one.
917    fn handle_at(self, points: &[Point], at: Point) -> Option<usize> {
918        let distance = |p: &Point| {
919            let s = self.screen(*p);
920            (s.x - at.x).hypot(s.y - at.y)
921        };
922        points
923            .iter()
924            .enumerate()
925            .filter(|(_, p)| distance(p) <= CURVE_REACH)
926            .min_by(|a, b| distance(a.1).total_cmp(&distance(b.1)))
927            .map(|(i, _)| i)
928    }
929}
930
931/// Moves a point, staying between its neighbors so the curve remains a function.
932fn curve_move(points: &mut [Point], index: usize, to: Point) {
933    let least = if index == 0 { 0.0 } else { points[index - 1].x + CURVE_MIN_GAP };
934    let most = points.get(index + 1).map_or(1.0, |next| next.x - CURVE_MIN_GAP);
935    if let Some(point) = points.get_mut(index) {
936        *point = Point::new(to.x.clamp(least, most.max(least)), to.y.clamp(0.0, 1.0));
937    }
938}
939
940/// Adds a point in its place, or finds none if it would sit on top of another.
941fn curve_insert(points: &mut Vec<Point>, at: Point) -> Option<usize> {
942    if points.iter().any(|p| (p.x - at.x).abs() < CURVE_MIN_GAP) {
943        return None;
944    }
945    let index = points.iter().position(|p| p.x > at.x).unwrap_or(points.len());
946    points.insert(index, at);
947    Some(index)
948}
949
950/// Editable points over a smooth curve that never swings past them. A press on empty space
951/// adds a point and a drag moves it; a double click or Delete removes one, except the two
952/// ends. From the keyboard, the arrows move the chosen point (with Shift, further), Page Up
953/// and Page Down choose another, and Enter adds one on the curve halfway to the next.
954///
955/// Points go from 0 to 1 on both axes, with the input in `x`. They are kept in order of `x`.
956///
957/// ```
958/// use ravenrs_core::Point;
959/// use ravenrs_reactive::State;
960/// use ravenrs_widgets::CurveEditor;
961///
962/// let tones = State::new(vec![Point::ZERO, Point::new(0.4, 0.55), Point::new(1.0, 1.0)]);
963/// let editor = CurveEditor::new(tones.binding()).height(220.0).label("Tone curve");
964/// let middle = CurveEditor::value_at(&tones.get(), 0.5);
965/// assert!(middle > 0.55 && middle < 1.0);
966/// ```
967#[derive(Debug)]
968#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
969pub struct CurveEditor {
970    points: Binding<Vec<Point>>,
971    width: Option<f32>,
972    height: f32,
973    color: Option<Color>,
974    baseline: bool,
975    grid: u32,
976    backdrop: Vec<HistogramChannel>,
977    backdrop_scale: HistogramScale,
978    label: Option<String>,
979    disabled: bool,
980}
981
982#[cfg_attr(feature = "live", ravenrs_live::expose)]
983impl CurveEditor {
984    /// An editor of `points`, across its parent, 200 logical pixels tall.
985    pub fn new(points: Binding<Vec<Point>>) -> Self {
986        Self {
987            points,
988            width: None,
989            height: 200.0,
990            color: None,
991            baseline: true,
992            grid: 4,
993            backdrop: Vec::new(),
994            backdrop_scale: HistogramScale::Linear,
995            label: None,
996            disabled: false,
997        }
998    }
999
1000    /// What the curve through `points` gives for the input `x`, for applying it. It uses the
1001    /// same curve the editor draws.
1002    pub fn value_at(points: &[Point], x: f32) -> f32 {
1003        let points = curve_points(points);
1004        let slopes = monotone_slopes(&points);
1005        curve_value(&points, &slopes, x)
1006    }
1007
1008    /// Fixed size in logical pixels.
1009    pub fn size(mut self, width: f32, height: f32) -> Self {
1010        self.width = Some(width);
1011        self.height = height;
1012        self
1013    }
1014
1015    /// Height in logical pixels.
1016    pub fn height(mut self, height: f32) -> Self {
1017        self.height = height;
1018        self
1019    }
1020
1021    /// The color of the curve and its handles, as data, such as a channel's (ink by default).
1022    pub fn color(mut self, color: Color) -> Self {
1023        self.color = Some(color);
1024        self
1025    }
1026
1027    /// Shows the straight line of no change behind the curve (on by default).
1028    pub fn baseline(mut self, baseline: bool) -> Self {
1029        self.baseline = baseline;
1030        self
1031    }
1032
1033    /// Divides the plot in this many parts each way, with light lines (4 by default).
1034    pub fn grid(mut self, parts: u32) -> Self {
1035        self.grid = parts;
1036        self
1037    }
1038
1039    /// Shows these channels behind the curve, as a [`Histogram`] does, faintly.
1040    pub fn histogram(
1041        mut self,
1042        channels: impl IntoIterator<Item = HistogramChannel>,
1043        scale: HistogramScale,
1044    ) -> Self {
1045        self.backdrop = channels.into_iter().collect();
1046        self.backdrop_scale = scale;
1047        self
1048    }
1049
1050    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
1051    pub fn label(mut self, label: impl Into<String>) -> Self {
1052        self.label = Some(label.into());
1053        self
1054    }
1055
1056    /// Turns the control off: it fades, leaves the tab order and ignores input.
1057    pub fn disabled(mut self, disabled: bool) -> Self {
1058        self.disabled = disabled;
1059        self
1060    }
1061}
1062
1063/// The curve at `x`, as the cubic between the two points around it.
1064fn curve_value(points: &[Point], slopes: &[f32], x: f32) -> f32 {
1065    let x = x.clamp(points[0].x, points[points.len() - 1].x);
1066    let i = points.iter().rposition(|p| p.x <= x).unwrap_or(0).min(points.len() - 2);
1067    let (a, b) = (points[i], points[i + 1]);
1068    let run = (b.x - a.x).max(f32::EPSILON);
1069    let t = (x - a.x) / run;
1070    let (t2, t3) = (t * t, t * t * t);
1071    let value = (2.0 * t3 - 3.0 * t2 + 1.0) * a.y
1072        + (t3 - 2.0 * t2 + t) * run * slopes[i]
1073        + (-2.0 * t3 + 3.0 * t2) * b.y
1074        + (t3 - t2) * run * slopes[i + 1];
1075    value.clamp(0.0, 1.0)
1076}
1077
1078impl View for CurveEditor {
1079    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1080        let theme = *cx.theme();
1081        let labels = cx.labels();
1082        let points = curve_points(&self.points.get());
1083        let chosen = cx.use_state(|| None::<usize>);
1084        let chosen_now = chosen.get().filter(|i| *i < points.len());
1085        let (background, grid, muted) =
1086            (theme.color(Semantic::Canvas), theme.color(Semantic::Border), theme.color(Semantic::InkMuted));
1087        let (surface, focus) = (theme.color(Semantic::Surface), theme.color(Semantic::Accent));
1088        let opacity = if self.disabled { 0.5 } else { 1.0 };
1089        let curve_color = self.color.unwrap_or_else(|| theme.color(Semantic::Ink)).with_alpha(opacity);
1090        let backdrop = (!self.backdrop.is_empty()).then(|| HistogramPicture {
1091            channels: self.backdrop.iter().cloned().collect(),
1092            scale: self.backdrop_scale,
1093            dim: overlap_dim(background),
1094            opacity: 0.35,
1095        });
1096        let (parts, baseline, radius) = (self.grid, self.baseline, theme.radii.sm);
1097        let drawn = points.clone();
1098        let node = cx.container(
1099            LayoutStyle {
1100                width: self.width,
1101                height: Some(self.height),
1102                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
1103                ..LayoutStyle::leaf()
1104            },
1105            &[],
1106            Paint::Custom(Rc::new(move |frame: Rect, scene| {
1107                scene.push_quad(Quad::new(frame, background).radius(radius).border(1.0, grid));
1108                let plot = CurvePlot::of(frame);
1109                let area = plot.0;
1110                if area.is_empty() {
1111                    return;
1112                }
1113                if let Some(backdrop) = &backdrop {
1114                    backdrop.paint(scene, area);
1115                }
1116                for part in 0..=parts.max(1) {
1117                    let share = part as f32 / parts.max(1) as f32;
1118                    let line = grid.with_alpha(grid.a * 0.6);
1119                    let x = (area.x() + area.width() * share).round();
1120                    let y = (area.y() + area.height() * share).round();
1121                    scene.push_quad(Quad::new(Rect::new(x, area.y(), 1.0, area.height()), line));
1122                    scene.push_quad(Quad::new(Rect::new(area.x(), y, area.width(), 1.0), line));
1123                }
1124                if baseline {
1125                    let line = [plot.screen(Point::ZERO), plot.screen(Point::new(1.0, 1.0))];
1126                    scene.push_path(PathFill::solid(stroke_outline(&line, 1.0), muted.with_alpha(0.6)));
1127                }
1128                // One sample every two pixels looks smooth at these line widths.
1129                let slopes = monotone_slopes(&drawn);
1130                let samples = ((area.width() / 2.0).ceil() as usize).max(2);
1131                let (from, to) = (drawn[0].x, drawn[drawn.len() - 1].x);
1132                let mut line: Vec<Point> = Vec::with_capacity(samples + 3);
1133                if from > 0.0 {
1134                    line.push(plot.screen(Point::new(0.0, drawn[0].y)));
1135                }
1136                line.extend((0..=samples).map(|i| {
1137                    let x = from + (to - from) * i as f32 / samples as f32;
1138                    plot.screen(Point::new(x, curve_value(&drawn, &slopes, x)))
1139                }));
1140                if to < 1.0 {
1141                    line.push(plot.screen(Point::new(1.0, drawn[drawn.len() - 1].y)));
1142                }
1143                scene.push_path(PathFill::solid(stroke_outline(&line, 1.75), curve_color));
1144                for (i, p) in drawn.iter().enumerate() {
1145                    let at = plot.screen(*p);
1146                    // The chosen one is larger and filled, so it does not depend on color.
1147                    let (half, fill) = if chosen_now == Some(i) { (6.0, focus) } else { (4.5, surface) };
1148                    let handle = Rect::new(at.x - half, at.y - half, half * 2.0, half * 2.0);
1149                    scene.push_quad(
1150                        Quad::new(handle, fill.with_alpha(opacity)).radius(half).border(1.5, curve_color),
1151                    );
1152                }
1153            })),
1154        );
1155        let name = self.label.clone().unwrap_or_else(|| labels.curve_editor.clone());
1156        let percent = |v: f32| (v * 100.0).round();
1157        let value = match chosen_now {
1158            Some(i) => format!(
1159                "{} {}/{}: {} {}%, {} {}%",
1160                labels.curve_point,
1161                i + 1,
1162                points.len(),
1163                labels.curve_input,
1164                percent(points[i].x),
1165                labels.curve_output,
1166                percent(points[i].y),
1167            ),
1168            None => format!("{} {}", points.len(), labels.curve_points),
1169        };
1170        cx.semantics(node, Semantics::new(Role::Slider).name(name).value(value).disabled(self.disabled));
1171        if self.disabled {
1172            return node;
1173        }
1174        cx.focusable(node, theme.radii.sm);
1175        cx.cursor(node, CursorIcon::Pointer);
1176        let press = cx.use_state(|| Rc::new(Cell::new((Point::ZERO, Rect::ZERO)))).get();
1177
1178        let (binding, picked, pressed) = (self.points.clone(), chosen.clone(), press.clone());
1179        cx.on_drag(
1180            node,
1181            Rc::new(move |d| {
1182                let plot = CurvePlot::of(d.frame);
1183                let mut points = curve_points(&binding.get());
1184                match d.phase {
1185                    DragPhase::Start => {
1186                        pressed.set((d.position, d.frame));
1187                        let index = plot.handle_at(&points, d.position).or_else(|| {
1188                            let added = curve_insert(&mut points, plot.value(d.position));
1189                            if added.is_some() {
1190                                binding.set(points.clone());
1191                            }
1192                            added
1193                        });
1194                        picked.set(index);
1195                    }
1196                    DragPhase::Move => {
1197                        if let Some(index) = *picked.peek() {
1198                            curve_move(&mut points, index, plot.value(d.position));
1199                            binding.set(points);
1200                        }
1201                    }
1202                    DragPhase::End => {}
1203                }
1204            }),
1205        );
1206
1207        let (binding, picked) = (self.points.clone(), chosen.clone());
1208        cx.on_double_click(
1209            node,
1210            Rc::new(move || {
1211                let (position, frame) = press.get();
1212                let mut points = curve_points(&binding.get());
1213                let hit = CurvePlot::of(frame).handle_at(&points, position);
1214                if let Some(index) = hit.filter(|i| *i != 0 && *i + 1 != points.len()) {
1215                    points.remove(index);
1216                    binding.set(points);
1217                    picked.set(None);
1218                }
1219            }),
1220        );
1221
1222        let (binding, picked) = (self.points.clone(), chosen);
1223        cx.on_key(
1224            node,
1225            Rc::new(move |key, modifiers| {
1226                let mut points = curve_points(&binding.get());
1227                let current = picked.peek().filter(|i| *i < points.len());
1228                let last = points.len() - 1;
1229                let step = if modifiers.shift { 0.05 } else { 0.01 };
1230                let nudge = |x: f32, y: f32| {
1231                    let index = current.unwrap_or(0);
1232                    let was = points[index];
1233                    (index, Point::new(was.x + x * step, was.y + y * step))
1234                };
1235                let moved = match key {
1236                    Key::ArrowLeft => nudge(-1.0, 0.0),
1237                    Key::ArrowRight => nudge(1.0, 0.0),
1238                    Key::ArrowUp => nudge(0.0, 1.0),
1239                    Key::ArrowDown => nudge(0.0, -1.0),
1240                    Key::PageUp => {
1241                        picked.set(Some(current.map_or(last, |i| i.saturating_sub(1))));
1242                        return true;
1243                    }
1244                    Key::PageDown => {
1245                        picked.set(Some(current.map_or(0, |i| (i + 1).min(last))));
1246                        return true;
1247                    }
1248                    Key::Enter => {
1249                        let index = current.unwrap_or(0).min(last - 1);
1250                        let x = (points[index].x + points[index + 1].x) / 2.0;
1251                        let slopes = monotone_slopes(&points);
1252                        let at = Point::new(x, curve_value(&points, &slopes, x));
1253                        if let Some(added) = curve_insert(&mut points, at) {
1254                            binding.set(points);
1255                            picked.set(Some(added));
1256                        }
1257                        return true;
1258                    }
1259                    Key::Delete | Key::Backspace => {
1260                        let Some(index) = current.filter(|i| *i != 0 && *i != last) else {
1261                            return false;
1262                        };
1263                        points.remove(index);
1264                        binding.set(points);
1265                        picked.set(Some(index.min(last - 1)));
1266                        return true;
1267                    }
1268                    _ => return false,
1269                };
1270                curve_move(&mut points, moved.0, moved.1);
1271                binding.set(points);
1272                picked.set(Some(moved.0));
1273                true
1274            }),
1275        );
1276        node
1277    }
1278}
1279
1280// ---------------------------------------------------------------------------------------------
1281// Zoom: the view shared by ZoomControl, ZoomPanView and Minimap
1282// ---------------------------------------------------------------------------------------------
1283
1284/// Where zoom steps stop, as screen pixels per unit of content.
1285const ZOOM_STOPS: [f32; 17] =
1286    [0.05, 0.1, 0.125, 0.25, 0.33, 0.5, 0.67, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 16.0, 32.0, 64.0];
1287/// How far in a [`ZoomView`] goes by default, relative to the content's own size.
1288const ZOOM_DEFAULT_MAX: f32 = 16.0;
1289
1290/// The next stop from `scale`, in the direction of `up` or down.
1291pub(crate) fn zoom_stop(scale: f32, up: bool) -> f32 {
1292    let next = if up {
1293        ZOOM_STOPS.iter().copied().find(|s| *s > scale * 1.01)
1294    } else {
1295        ZOOM_STOPS.iter().rev().copied().find(|s| *s < scale * 0.99)
1296    };
1297    next.unwrap_or(scale)
1298}
1299
1300/// How a picture is being looked at: how much it is enlarged and which part is in the middle.
1301/// [`ZoomPanView`], [`ZoomControl`] and [`Minimap`] share one through a `Binding`, and each
1302/// changes what it is responsible for.
1303///
1304/// While `fit` is on, [`ZoomPanView`] keeps the whole content in view and writes the scale
1305/// this takes, so the value also reads correctly when the window is resized.
1306#[derive(Clone, Copy, Debug, PartialEq)]
1307#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1308pub struct ZoomView {
1309    /// Screen pixels per unit of content: 1 is 100%.
1310    pub scale: f32,
1311    /// The point of the content in the middle of the view, from 0 to 1 on each axis.
1312    pub center: Point,
1313    /// Follows the view's size to show the whole content.
1314    pub fit: bool,
1315    /// The size of the view on screen, written by [`ZoomPanView`] and read by [`Minimap`].
1316    pub viewport: Size,
1317}
1318
1319impl Default for ZoomView {
1320    fn default() -> Self {
1321        Self::fit()
1322    }
1323}
1324
1325#[cfg_attr(feature = "live", ravenrs_live::expose)]
1326impl ZoomView {
1327    /// Shows the whole content.
1328    pub const fn fit() -> Self {
1329        Self { scale: 1.0, center: Point::new(0.5, 0.5), fit: true, viewport: Size::ZERO }
1330    }
1331
1332    /// At this scale, centered.
1333    pub const fn at(scale: f32) -> Self {
1334        Self { scale, center: Point::new(0.5, 0.5), fit: false, viewport: Size::ZERO }
1335    }
1336
1337    /// The same view around this point of the content, from 0 to 1 on each axis.
1338    pub const fn centered_on(mut self, center: Point) -> Self {
1339        self.center = center;
1340        self
1341    }
1342
1343    /// Where the whole content lands, relative to the top left corner of the view.
1344    pub fn placed(&self, content: Size) -> Rect {
1345        let (w, h) = (content.width * self.scale, content.height * self.scale);
1346        Rect::new(
1347            self.viewport.width / 2.0 - self.center.x * w,
1348            self.viewport.height / 2.0 - self.center.y * h,
1349            w,
1350            h,
1351        )
1352    }
1353
1354    /// The part of the content in view, from 0 to 1 on each axis.
1355    pub fn visible(&self, content: Size) -> Rect {
1356        // No view has measured itself yet, and nothing can be said to be out of it.
1357        if self.viewport.width <= 0.0 || self.viewport.height <= 0.0 {
1358            return Rect::new(0.0, 0.0, 1.0, 1.0);
1359        }
1360        let share = |view: f32, whole: f32| (view / (whole * self.scale).max(f32::EPSILON)).min(1.0);
1361        let (w, h) = (share(self.viewport.width, content.width), share(self.viewport.height, content.height));
1362        Rect::new(self.center.x - w / 2.0, self.center.y - h / 2.0, w, h)
1363    }
1364
1365    /// The scale that shows the whole content in the view.
1366    fn fit_scale(&self, content: Size) -> f32 {
1367        let ratio = |view: f32, whole: f32| view / whole.max(f32::EPSILON);
1368        ratio(self.viewport.width, content.width).min(ratio(self.viewport.height, content.height))
1369    }
1370
1371    /// This view within its limits: the scale in range and no empty space on an axis where
1372    /// the content is larger than the view, so it cannot run away.
1373    fn settled(mut self, viewport: Size, content: Size, range: ZoomRange) -> Self {
1374        self.viewport = viewport;
1375        let fit = self.fit_scale(content).max(f32::EPSILON);
1376        let (least, most) = range.limits(fit);
1377        self.scale = if self.fit { fit } else { self.scale.clamp(least, most) };
1378        let axis = |center: f32, view: f32, whole: f32| {
1379            let half = view / (2.0 * whole * self.scale).max(f32::EPSILON);
1380            if half >= 0.5 { 0.5 } else { center.clamp(half, 1.0 - half) }
1381        };
1382        self.center = Point::new(
1383            axis(self.center.x, viewport.width, content.width),
1384            axis(self.center.y, viewport.height, content.height),
1385        );
1386        self
1387    }
1388
1389    /// At another scale, with the point of the content at `anchor` in the view staying there.
1390    fn zoomed(self, scale: f32, anchor: Point, content: Size) -> Self {
1391        let before = self.placed(content);
1392        let fraction = |at: f32, start: f32, size: f32| (at - start) / size.max(f32::EPSILON);
1393        let (fx, fy) =
1394            (fraction(anchor.x, before.x(), before.width()), fraction(anchor.y, before.y(), before.height()));
1395        let (w, h) = (content.width * scale, content.height * scale);
1396        let center = Point::new(
1397            fx + (self.viewport.width / 2.0 - anchor.x) / w.max(f32::EPSILON),
1398            fy + (self.viewport.height / 2.0 - anchor.y) / h.max(f32::EPSILON),
1399        );
1400        Self { scale, center, fit: false, ..self }
1401    }
1402}
1403
1404/// How far a view may zoom: `least` unset means down to showing the whole content.
1405#[derive(Clone, Copy, Debug)]
1406struct ZoomRange {
1407    least: Option<f32>,
1408    most: f32,
1409}
1410
1411#[cfg_attr(feature = "live", ravenrs_live::expose)]
1412impl ZoomRange {
1413    const DEFAULT: Self = Self { least: None, most: ZOOM_DEFAULT_MAX };
1414
1415    /// The limits for content that fits at scale `fit`, which is always reachable.
1416    fn limits(self, fit: f32) -> (f32, f32) {
1417        (self.least.unwrap_or(fit).min(fit), self.most.max(fit))
1418    }
1419}
1420
1421/// A scale as people read it: `50%`, and `12,5%` with the decimal only when it matters.
1422fn zoom_percent(scale: f32, decimal: char) -> String {
1423    let percent = scale * 100.0;
1424    if percent < 100.0 && (percent - percent.round()).abs() >= 0.05 {
1425        format!("{percent:.1}%").replace('.', &decimal.to_string())
1426    } else {
1427        format!("{}%", percent.round())
1428    }
1429}
1430
1431// ---------------------------------------------------------------------------------------------
1432// ZoomControl
1433// ---------------------------------------------------------------------------------------------
1434
1435/// Fit, 1:1, the current percentage and the buttons that zoom in and out, for a [`ZoomView`].
1436/// It works from a toolbar of its own or inside a
1437/// [`FloatingToolbar`](crate::FloatingToolbar).
1438///
1439/// ```
1440/// use ravenrs_reactive::State;
1441/// use ravenrs_widgets::{ZoomControl, ZoomView};
1442///
1443/// let view = State::new(ZoomView::fit());
1444/// let control = ZoomControl::new(view.binding()).range(0.1, 8.0);
1445/// ```
1446#[derive(Debug)]
1447#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1448pub struct ZoomControl {
1449    view: Binding<ZoomView>,
1450    least: f32,
1451    most: f32,
1452    size: ButtonSize,
1453    label: Option<String>,
1454}
1455
1456#[cfg_attr(feature = "live", ravenrs_live::expose)]
1457impl ZoomControl {
1458    /// A control bound to `view`, with small buttons.
1459    pub fn new(view: Binding<ZoomView>) -> Self {
1460        Self {
1461            view,
1462            least: ZOOM_STOPS[0],
1463            most: ZOOM_STOPS[ZOOM_STOPS.len() - 1],
1464            size: ButtonSize::Small,
1465            label: None,
1466        }
1467    }
1468
1469    /// The least and the most its buttons reach, as a scale where 1 is 100%. Match it to
1470    /// the range of the [`ZoomPanView`] that shares the view.
1471    pub fn range(mut self, least: f32, most: f32) -> Self {
1472        self.least = least;
1473        self.most = most.max(least);
1474        self
1475    }
1476
1477    /// The size of its buttons.
1478    pub fn size(mut self, size: ButtonSize) -> Self {
1479        self.size = size;
1480        self
1481    }
1482
1483    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
1484    pub fn label(mut self, label: impl Into<String>) -> Self {
1485        self.label = Some(label.into());
1486        self
1487    }
1488}
1489
1490impl View for ZoomControl {
1491    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1492        let theme = *cx.theme();
1493        let labels = cx.labels();
1494        let current = self.view.get();
1495        let (least, most) = (self.least, self.most);
1496        let change = |f: fn(&mut ZoomView, f32, f32)| {
1497            let view = self.view.clone();
1498            move || view.update(|v| f(v, least, most))
1499        };
1500        let fit = Button::new(labels.zoom_fit.clone())
1501            .style(ButtonStyle::Ghost)
1502            .size(self.size)
1503            .selected(current.fit)
1504            .on_click(change(|v, _, _| v.fit = true));
1505        let actual = Button::new("1:1")
1506            .accessible_name(labels.zoom_actual.clone())
1507            .style(ButtonStyle::Ghost)
1508            .size(self.size)
1509            .selected(!current.fit && (current.scale - 1.0).abs() < 0.001)
1510            .on_click(change(|v, _, _| *v = ZoomView { scale: 1.0, fit: false, ..*v }));
1511        let out = IconButton::new(IconName::Minus)
1512            .label(labels.zoom_out.clone())
1513            .size(self.size)
1514            .disabled(current.scale <= least * 1.001)
1515            .on_click(change(|v, least, _| {
1516                *v = ZoomView { scale: zoom_stop(v.scale, false).max(least), fit: false, ..*v };
1517            }));
1518        let more = IconButton::new(IconName::Plus)
1519            .label(labels.zoom_in.clone())
1520            .size(self.size)
1521            .disabled(current.scale >= most * 0.999)
1522            .on_click(change(|v, _, most| {
1523                *v = ZoomView { scale: zoom_stop(v.scale, true).min(most), fit: false, ..*v };
1524            }));
1525        let nodes = [cx.child(&fit), cx.child(&actual)];
1526        let line = cx.container(
1527            LayoutStyle { width: Some(1.0), height: Some(16.0), ..LayoutStyle::leaf() },
1528            &[],
1529            Paint::Quad(QuadPaint::new(theme.color(Semantic::Border))),
1530        );
1531        let out = cx.child(&out);
1532        let percent = zoom_percent(current.scale, labels.decimal_separator);
1533        let attrs = util::attrs(&theme, TextStyle::Callout, None);
1534        // As wide as the widest percentage, so the buttons do not move as it changes.
1535        let widest = cx.measure_text("1600%", &attrs).width.ceil();
1536        let text = util::label(cx, &percent, TextStyle::Callout, None, theme.color(Semantic::Ink));
1537        let number = cx.container(
1538            LayoutStyle {
1539                min_width: Some(widest),
1540                justify: Justify::Center,
1541                ..LayoutStyle::stack(Axis::Horizontal)
1542            },
1543            &[text],
1544            Paint::None,
1545        );
1546        let more = cx.child(&more);
1547        let node = cx.container(
1548            LayoutStyle {
1549                spacing: theme.spacing.s1,
1550                align: Align::Center,
1551                align_self: Some(Align::Fit),
1552                ..LayoutStyle::stack(Axis::Horizontal)
1553            },
1554            &[nodes[0], nodes[1], line, out, number, more],
1555            Paint::None,
1556        );
1557        let name = self.label.clone().unwrap_or_else(|| labels.zoom.clone());
1558        cx.semantics(node, Semantics::new(Role::Group).name(name));
1559        node
1560    }
1561}
1562
1563// ---------------------------------------------------------------------------------------------
1564// ZoomPanView
1565// ---------------------------------------------------------------------------------------------
1566
1567/// Where drawn content goes, for [`ZoomPanView::custom`] and [`Minimap::custom`].
1568#[derive(Clone, Copy, Debug, PartialEq)]
1569#[non_exhaustive]
1570#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1571pub struct ZoomPanPaint {
1572    /// The box of the view on screen. Drawing is clipped to it.
1573    pub frame: Rect,
1574    /// Where the whole content lands on screen. Much of it may be outside `frame`: draw only
1575    /// what touches `frame` when there is a lot to draw.
1576    pub placed: Rect,
1577    /// Screen pixels per unit of content.
1578    pub scale: f32,
1579}
1580
1581/// What zooms: the runtime has no transform for a laid out subtree, so content draws itself
1582/// from the view it is given. See [`ZoomPanView`].
1583#[derive(Clone)]
1584enum ZoomContent {
1585    Image(ImageData),
1586    Custom(ZoomDrawFn),
1587}
1588
1589/// Draws content from where a view puts it.
1590type ZoomDrawFn = Rc<dyn Fn(ZoomPanPaint, &mut Scene)>;
1591
1592#[cfg_attr(feature = "live", ravenrs_live::expose)]
1593impl ZoomContent {
1594    fn paint(&self, scene: &mut Scene, frame: Rect, placed: Rect, scale: f32, radius: f32) {
1595        match self {
1596            Self::Image(image) => {
1597                // Only the part in view, which keeps the rectangle within what the GPU's
1598                // coordinates hold well at a high zoom.
1599                let shown = placed.intersect(&frame);
1600                if shown.is_empty() || placed.is_empty() {
1601                    return;
1602                }
1603                let mut quad = ImageQuad::new(shown, image.clone()).radius(radius);
1604                quad.uv = [
1605                    (shown.x() - placed.x()) / placed.width(),
1606                    (shown.y() - placed.y()) / placed.height(),
1607                    (shown.max_x() - placed.x()) / placed.width(),
1608                    (shown.max_y() - placed.y()) / placed.height(),
1609                ];
1610                scene.push_image(quad);
1611            }
1612            Self::Custom(draw) => {
1613                scene.push_clip(frame);
1614                draw(ZoomPanPaint { frame, placed, scale }, scene);
1615                scene.pop_clip();
1616            }
1617        }
1618    }
1619}
1620
1621/// What a throw of the content does after the pointer lets go: the path of a critically
1622/// damped spring that ends where friction would, or at the limit if that comes first.
1623#[derive(Clone, Copy, Debug)]
1624struct Glide {
1625    started: f64,
1626    /// Displacement from the end when it started, in pixels of the scaled content.
1627    from: Point,
1628    /// Where it stops, in the same pixels.
1629    target: Point,
1630    velocity: Point,
1631    /// What it wrote last: anything else means someone else moved the view.
1632    written: ZoomView,
1633}
1634
1635/// The response of the spring behind a throw: the longer, the farther it coasts.
1636const GLIDE_RESPONSE: f32 = 1.4;
1637/// Below this speed at release, in pixels per second, the content just stops.
1638const GLIDE_MIN_SPEED: f32 = 90.0;
1639/// How near the end a throw counts as finished, in pixels and pixels per second.
1640const GLIDE_REST: (f32, f32) = (0.5, 8.0);
1641
1642/// What a [`ZoomPanView`] remembers between events, none of which should rebuild it.
1643#[derive(Debug, Default)]
1644struct ZoomPanMemory {
1645    frame: Cell<Rect>,
1646    pointer: Cell<Option<Point>>,
1647    /// The pointer and the center of the view when the drag began.
1648    grab: Cell<Option<(Point, Point)>>,
1649    clock: Cell<f64>,
1650    /// When and where the content was on the last frame of the drag.
1651    sample: Cell<Option<(f64, Point)>>,
1652    velocity: Cell<Point>,
1653    glide: Cell<Option<Glide>>,
1654    /// The scroll offset this view asked for last: anything else came from the wheel.
1655    scroll: Cell<Point>,
1656    /// It has already asked for the focus once.
1657    focused: Cell<bool>,
1658}
1659
1660/// How the parts of a [`ZoomPanView`] see the same picture.
1661#[derive(Clone)]
1662struct ZoomPanWiring {
1663    view: Binding<ZoomView>,
1664    content: Size,
1665    range: ZoomRange,
1666    memory: Rc<ZoomPanMemory>,
1667    live: State<bool>,
1668}
1669
1670#[cfg_attr(feature = "live", ravenrs_live::expose)]
1671impl ZoomPanWiring {
1672    fn current(&self, viewport: Size) -> ZoomView {
1673        self.view.get().settled(viewport, self.content, self.range)
1674    }
1675
1676    fn write(&self, next: ZoomView) {
1677        if self.view.get() != next {
1678            self.view.set(next);
1679        }
1680    }
1681
1682    /// Goes to this scale keeping the point at `anchor` in place. Reaching the scale that
1683    /// shows everything turns fit on, so the view goes on following the window.
1684    fn zoom_to(&self, scale: f32, anchor: Point) {
1685        self.memory.glide.set(None);
1686        let viewport = self.memory.frame.get().size;
1687        let current = self.current(viewport);
1688        let mut next =
1689            current.zoomed(scale, anchor, self.content).settled(viewport, self.content, self.range);
1690        let fit = next.fit_scale(self.content);
1691        next.fit = (next.scale - fit).abs() <= fit * 0.0005;
1692        self.write(next);
1693    }
1694
1695    fn pan_by(&self, dx: f32, dy: f32) {
1696        self.memory.glide.set(None);
1697        let viewport = self.memory.frame.get().size;
1698        let v = self.current(viewport);
1699        let (w, h) = self.scaled(v.scale);
1700        let center = Point::new(v.center.x + dx / w, v.center.y + dy / h);
1701        self.write(ZoomView { center, ..v }.settled(viewport, self.content, self.range));
1702    }
1703
1704    /// The content's size on screen at a scale.
1705    fn scaled(&self, scale: f32) -> (f32, f32) {
1706        ((self.content.width * scale).max(f32::EPSILON), (self.content.height * scale).max(f32::EPSILON))
1707    }
1708
1709    fn on_drag(&self, d: Drag) {
1710        let memory = &self.memory;
1711        memory.frame.set(d.frame);
1712        match d.phase {
1713            DragPhase::Start => {
1714                memory.glide.set(None);
1715                memory.grab.set(Some((d.position, self.current(d.frame.size).center)));
1716                memory.sample.set(None);
1717                memory.velocity.set(Point::ZERO);
1718                self.live.set_if_changed(true);
1719            }
1720            DragPhase::Move => {
1721                let Some((pressed, center)) = memory.grab.get() else { return };
1722                let v = self.current(d.frame.size);
1723                let (w, h) = self.scaled(v.scale);
1724                let center = Point::new(
1725                    center.x - (d.position.x - pressed.x) / w,
1726                    center.y - (d.position.y - pressed.y) / h,
1727                );
1728                self.write(ZoomView { center, ..v }.settled(d.frame.size, self.content, self.range));
1729            }
1730            DragPhase::End => {
1731                memory.grab.set(None);
1732                self.release(d.frame.size);
1733            }
1734        }
1735    }
1736
1737    /// Starts coasting at the speed of the last frames, or stops.
1738    fn release(&self, viewport: Size) {
1739        let memory = &self.memory;
1740        let velocity = memory.velocity.get();
1741        if velocity.x.hypot(velocity.y) < GLIDE_MIN_SPEED {
1742            self.live.set_if_changed(false);
1743            return;
1744        }
1745        let v = self.current(viewport);
1746        let (w, h) = self.scaled(v.scale);
1747        let at = Point::new(v.center.x * w, v.center.y * h);
1748        // Displaced by v/omega, a critically damped spring decays exactly as friction does.
1749        let spring = glide_spring();
1750        let reach = spring.stiffness.sqrt();
1751        let thrown = Point::new((at.x + velocity.x / reach) / w, (at.y + velocity.y / reach) / h);
1752        let end = ZoomView { center: thrown, ..v }.settled(viewport, self.content, self.range);
1753        let target = Point::new(end.center.x * w, end.center.y * h);
1754        memory.glide.set(Some(Glide {
1755            started: memory.clock.get(),
1756            from: Point::new(at.x - target.x, at.y - target.y),
1757            target,
1758            velocity,
1759            written: v,
1760        }));
1761    }
1762
1763    /// One frame of a gesture, from paint: it needs no build, and when nothing else reads the
1764    /// view, none happens until the gesture ends.
1765    fn on_frame(&self, viewport: Size, time: f64) {
1766        let memory = &self.memory;
1767        memory.clock.set(time);
1768        if memory.grab.get().is_some() {
1769            let v = self.current(viewport);
1770            let (w, h) = self.scaled(v.scale);
1771            let at = Point::new(v.center.x * w, v.center.y * h);
1772            if let Some((then, was)) = memory.sample.get() {
1773                let dt = (time - then) as f32;
1774                if dt > 0.0 {
1775                    let before = memory.velocity.get();
1776                    memory.velocity.set(Point::new(
1777                        before.x * 0.4 + (at.x - was.x) / dt * 0.6,
1778                        before.y * 0.4 + (at.y - was.y) / dt * 0.6,
1779                    ));
1780                    memory.sample.set(Some((time, at)));
1781                }
1782            } else {
1783                memory.sample.set(Some((time, at)));
1784            }
1785        } else if let Some(mut glide) = memory.glide.get() {
1786            let v = self.current(viewport);
1787            if v.center != glide.written.center || v.scale != glide.written.scale {
1788                memory.glide.set(None);
1789                self.live.set_if_changed(false);
1790                return;
1791            }
1792            let (spring, t) = (glide_spring(), (time - glide.started) as f32);
1793            let (x, vx) = spring.sample(glide.from.x, glide.velocity.x, t);
1794            let (y, vy) = spring.sample(glide.from.y, glide.velocity.y, t);
1795            let (w, h) = self.scaled(v.scale);
1796            let center = Point::new((glide.target.x + x) / w, (glide.target.y + y) / h);
1797            let next = ZoomView { center, ..v }.settled(viewport, self.content, self.range);
1798            glide.written = next;
1799            self.write(next);
1800            if x.hypot(y) < GLIDE_REST.0 && vx.hypot(vy) < GLIDE_REST.1 {
1801                memory.glide.set(None);
1802                self.live.set_if_changed(false);
1803            } else {
1804                memory.glide.set(Some(glide));
1805            }
1806        }
1807    }
1808
1809    fn on_key(&self, key: &Key, shift: bool) -> bool {
1810        let viewport = self.memory.frame.get().size;
1811        let middle = Point::new(viewport.width / 2.0, viewport.height / 2.0);
1812        let v = self.current(viewport);
1813        let step = if shift { 0.5 } else { 0.1 };
1814        match key {
1815            Key::Character(c) => match c.as_str() {
1816                "+" | "=" => self.zoom_to(zoom_stop(v.scale, true), middle),
1817                "-" | "_" => self.zoom_to(zoom_stop(v.scale, false), middle),
1818                "0" => {
1819                    self.memory.glide.set(None);
1820                    self.write(ZoomView { fit: true, ..v }.settled(viewport, self.content, self.range));
1821                }
1822                "1" => self.zoom_to(1.0, middle),
1823                _ => return false,
1824            },
1825            Key::ArrowLeft => self.pan_by(-viewport.width * step, 0.0),
1826            Key::ArrowRight => self.pan_by(viewport.width * step, 0.0),
1827            Key::ArrowUp => self.pan_by(0.0, -viewport.height * step),
1828            Key::ArrowDown => self.pan_by(0.0, viewport.height * step),
1829            _ => return false,
1830        }
1831        true
1832    }
1833
1834    fn on_double_click(&self) {
1835        let viewport = self.memory.frame.get().size;
1836        let v = self.current(viewport);
1837        if v.fit {
1838            // Up to 100%, or twice as large for content that already fits beyond it.
1839            let target = if v.scale < 0.99 { 1.0 } else { v.scale * 2.0 };
1840            let middle = Point::new(viewport.width / 2.0, viewport.height / 2.0);
1841            let at = self.memory.pointer.get().unwrap_or(middle);
1842            self.zoom_to(target, at);
1843        } else {
1844            self.memory.glide.set(None);
1845            self.write(ZoomView { fit: true, ..v }.settled(viewport, self.content, self.range));
1846        }
1847    }
1848}
1849
1850fn glide_spring() -> Spring {
1851    Spring::from_response(GLIDE_RESPONSE, 1.0)
1852}
1853
1854/// Zoom and pan over content: pinch or Cmd/Ctrl with the wheel to zoom, a drag or the wheel to
1855/// move, a double click between fit and 100%, and the keys `+`, `-`, `0`, `1` and the arrows
1856/// (with Shift, further). A thrown drag coasts to a stop, which asks for frames only meanwhile
1857/// and never happens with reduced motion. The content cannot leave the view.
1858///
1859/// Content is an image or a closure that draws from where the view puts it, not a laid out
1860/// view: the runtime has no transform for a subtree, and scaling one by
1861/// [`Motion::effect`](crate::Motion::effect) would neither move what can be clicked nor
1862/// paint children whose unscaled place is off screen. A closure can still reuse any drawing
1863/// through [`Scene::apply_effect`].
1864///
1865/// While a gesture runs it paints without a build. If nothing else in the UI reads the
1866/// [`ZoomView`], it costs no rebuilds at all, however big the screen around it.
1867///
1868/// ```no_run
1869/// use ravenrs_core::ImageData;
1870/// use ravenrs_reactive::State;
1871/// use ravenrs_widgets::{Minimap, ZoomControl, ZoomPanView, ZoomView};
1872///
1873/// # fn photo() -> ImageData { unimplemented!() }
1874/// let view = State::new(ZoomView::fit());
1875/// let stage = ZoomPanView::image(view.binding(), photo());
1876/// let controls = ZoomControl::new(view.binding());
1877/// let map = Minimap::image(view.binding(), photo());
1878/// ```
1879#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1880pub struct ZoomPanView {
1881    view: Binding<ZoomView>,
1882    content: Size,
1883    picture: ZoomContent,
1884    width: Option<f32>,
1885    height: Option<f32>,
1886    grow: f32,
1887    range: ZoomRange,
1888    inertia: bool,
1889    label: Option<String>,
1890    /// Leaves the arrows to whoever else listens while there is nowhere to pan.
1891    defer_arrows: bool,
1892    /// Takes the focus when it first appears.
1893    autofocus: bool,
1894}
1895
1896impl fmt::Debug for ZoomPanView {
1897    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1898        f.debug_struct("ZoomPanView").field("content", &self.content).finish_non_exhaustive()
1899    }
1900}
1901
1902#[cfg_attr(feature = "live", ravenrs_live::expose)]
1903impl ZoomPanView {
1904    /// Shows an image, with one unit of content per pixel of it.
1905    pub fn image(view: Binding<ZoomView>, image: ImageData) -> Self {
1906        let content = Size::new(image.width() as f32, image.height() as f32);
1907        Self::with(view, content, ZoomContent::Image(image))
1908    }
1909
1910    /// Shows what `draw` draws, for content of this size in units of the caller.
1911    pub fn custom(
1912        view: Binding<ZoomView>,
1913        content: Size,
1914        draw: impl Fn(ZoomPanPaint, &mut Scene) + 'static,
1915    ) -> Self {
1916        Self::with(view, content, ZoomContent::Custom(Rc::new(draw)))
1917    }
1918
1919    fn with(view: Binding<ZoomView>, content: Size, picture: ZoomContent) -> Self {
1920        Self {
1921            view,
1922            content,
1923            picture,
1924            width: None,
1925            height: None,
1926            grow: 1.0,
1927            range: ZoomRange::DEFAULT,
1928            inertia: true,
1929            label: None,
1930            defer_arrows: false,
1931            autofocus: false,
1932        }
1933    }
1934
1935    /// Fixed size in logical pixels, instead of taking the free space.
1936    pub fn size(mut self, width: f32, height: f32) -> Self {
1937        self.width = Some(width);
1938        self.height = Some(height);
1939        self.grow = 0.0;
1940        self
1941    }
1942
1943    /// How much of the free space along the parent stack it takes (1 by default).
1944    pub fn grow(mut self, grow: f32) -> Self {
1945        self.grow = grow;
1946        self
1947    }
1948
1949    /// The least and the most scale, where 1 is 100%. By default, it goes down to what shows
1950    /// the whole content and up to 1600%. What shows the whole content is always in range.
1951    pub fn range(mut self, least: f32, most: f32) -> Self {
1952        self.range = ZoomRange { least: Some(least), most };
1953        self
1954    }
1955
1956    /// Lets a thrown drag coast (on by default).
1957    pub fn inertia(mut self, inertia: bool) -> Self {
1958        self.inertia = inertia;
1959        self
1960    }
1961
1962    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
1963    pub fn label(mut self, label: impl Into<String>) -> Self {
1964        self.label = Some(label.into());
1965        self
1966    }
1967}
1968
1969impl View for ZoomPanView {
1970    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1971        let labels = cx.labels();
1972        let radius = cx.theme().radii.sm;
1973        let live = cx.use_state(|| false);
1974        let is_live = live.get();
1975        // During a gesture this view paints from the binding on its own: reading it here too
1976        // would make every step of the gesture a rebuild of the whole screen.
1977        let stored = if is_live { Observer::untracked(|| self.view.get()) } else { self.view.get() };
1978        let memory = cx.use_state(|| Rc::new(ZoomPanMemory::default())).get();
1979        let (content, range) = (self.content, self.range);
1980        let view = match cx.scroll_viewport() {
1981            Some(viewport) => {
1982                let mut view = stored.settled(viewport, content, range);
1983                // The wheel moves the scroll offset, which stands for the pan.
1984                let offset = cx.scroll_offset();
1985                let asked = memory.scroll.get();
1986                if (offset.x - asked.x).abs() > 0.01 || (offset.y - asked.y).abs() > 0.01 {
1987                    let (w, h) = (content.width * view.scale, content.height * view.scale);
1988                    view.center = Point::new(
1989                        (offset.x + viewport.width / 2.0) / w.max(f32::EPSILON),
1990                        (offset.y + viewport.height / 2.0) / h.max(f32::EPSILON),
1991                    );
1992                    view = view.settled(viewport, content, range);
1993                }
1994                let placed = view.placed(content);
1995                let wanted = Point::new((-placed.x()).max(0.0), (-placed.y()).max(0.0));
1996                if (wanted.x - offset.x).abs() > 0.01 || (wanted.y - offset.y).abs() > 0.01 {
1997                    cx.set_scroll_offset(wanted);
1998                    memory.scroll.set(wanted);
1999                } else {
2000                    memory.scroll.set(offset);
2001                }
2002                if view != stored {
2003                    self.view.set(view);
2004                }
2005                view
2006            }
2007            None => stored,
2008        };
2009        let wiring = ZoomPanWiring {
2010            view: self.view.clone(),
2011            content,
2012            range,
2013            memory: memory.clone(),
2014            live: live.clone(),
2015        };
2016        // Only what the wheel can reach counts as content of the scroll.
2017        let room = cx.container(
2018            LayoutStyle {
2019                width: Some(content.width * view.scale),
2020                height: Some(content.height * view.scale),
2021                ..LayoutStyle::leaf()
2022            },
2023            &[],
2024            Paint::None,
2025        );
2026        let picture = self.picture.clone();
2027        let draw = {
2028            let wiring = wiring.clone();
2029            move |frame: Rect, scene: &mut Scene| {
2030                wiring.memory.frame.set(frame);
2031                let v = wiring.current(frame.size);
2032                let placed = v.placed(content);
2033                let on_screen = Rect::new(
2034                    frame.x() + placed.x(),
2035                    frame.y() + placed.y(),
2036                    placed.width(),
2037                    placed.height(),
2038                );
2039                picture.paint(scene, frame, on_screen, v.scale, 0.0);
2040            }
2041        };
2042        let paint = if is_live {
2043            let wiring = wiring.clone();
2044            Paint::Live(Rc::new(move |frame: Rect, time, scene: &mut Scene| {
2045                wiring.on_frame(frame.size, time);
2046                draw(frame, scene);
2047            }))
2048        } else {
2049            Paint::Custom(Rc::new(draw))
2050        };
2051        let node = cx.container(
2052            LayoutStyle {
2053                width: self.width,
2054                height: self.height,
2055                grow: self.grow,
2056                align: Align::Start,
2057                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
2058                overflow_hidden: true,
2059                ..LayoutStyle::stack(Axis::Vertical)
2060            },
2061            &[room],
2062            paint,
2063        );
2064        cx.scrollable_virtual(node, true);
2065        let name = self.label.clone().unwrap_or_else(|| labels.zoom_view.clone());
2066        cx.semantics(
2067            node,
2068            Semantics::new(Role::Image).name(name).value(zoom_percent(view.scale, labels.decimal_separator)),
2069        );
2070        cx.focusable(node, radius);
2071        let pannable = {
2072            let placed = view.placed(content);
2073            placed.width() > view.viewport.width + 0.5 || placed.height() > view.viewport.height + 0.5
2074        };
2075        if pannable {
2076            cx.cursor(node, CursorIcon::Pointer);
2077        }
2078        let inertia = self.inertia && !cx.reduced_motion();
2079        let dragged = wiring.clone();
2080        cx.on_drag(
2081            node,
2082            Rc::new(move |d| {
2083                if !inertia {
2084                    dragged.memory.velocity.set(Point::ZERO);
2085                }
2086                dragged.on_drag(d);
2087            }),
2088        );
2089        let moved = wiring.memory.clone();
2090        cx.on_pointer_move(
2091            node,
2092            Rc::new(move |at, frame| {
2093                moved.pointer.set(Some(Point::new(at.x - frame.x(), at.y - frame.y())));
2094            }),
2095        );
2096        let pinched = wiring.clone();
2097        cx.on_pinch(
2098            node,
2099            Rc::new(move |delta| {
2100                let viewport = pinched.memory.frame.get().size;
2101                let middle = Point::new(viewport.width / 2.0, viewport.height / 2.0);
2102                let at = pinched.memory.pointer.get().unwrap_or(middle);
2103                let scale = pinched.current(viewport).scale * (1.0 + delta).max(0.05);
2104                pinched.zoom_to(scale, at);
2105            }),
2106        );
2107        let doubled = wiring.clone();
2108        cx.on_double_click(node, Rc::new(move || doubled.on_double_click()));
2109        let defer_arrows = self.defer_arrows;
2110        cx.on_key(
2111            node,
2112            Rc::new(move |key, modifiers| {
2113                let arrow = matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown);
2114                if defer_arrows && arrow && wiring.current(wiring.memory.frame.get().size).fit {
2115                    return false;
2116                }
2117                wiring.on_key(key, modifiers.shift)
2118            }),
2119        );
2120        if self.autofocus && !memory.focused.replace(true) {
2121            cx.request_focus();
2122        }
2123        node
2124    }
2125}
2126
2127// ---------------------------------------------------------------------------------------------
2128// Minimap
2129// ---------------------------------------------------------------------------------------------
2130
2131/// A small copy of the content with a rectangle around what is in view, linked to a
2132/// [`ZoomPanView`] by the same [`ZoomView`]. A press or a drag moves the view there, and so do
2133/// the arrow keys (with Shift, further).
2134///
2135/// ```
2136/// use ravenrs_core::{Color, ImageData, Size};
2137/// use ravenrs_reactive::State;
2138/// use ravenrs_widgets::{Minimap, ZoomView};
2139///
2140/// let view = State::new(ZoomView::at(2.0));
2141/// if let Some(thumbnail) = ImageData::from_fn(64, 48, |_, _| Color::rgb(0.3, 0.4, 0.5)) {
2142///     let map = Minimap::image(view.binding(), thumbnail).content_size(Size::new(6400.0, 4800.0));
2143/// }
2144/// ```
2145#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
2146pub struct Minimap {
2147    view: Binding<ZoomView>,
2148    content: Size,
2149    picture: ZoomContent,
2150    width: f32,
2151    max_height: f32,
2152    press_zoom: Option<f32>,
2153    label: Option<String>,
2154}
2155
2156impl fmt::Debug for Minimap {
2157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2158        f.debug_struct("Minimap").field("content", &self.content).finish_non_exhaustive()
2159    }
2160}
2161
2162#[cfg_attr(feature = "live", ravenrs_live::expose)]
2163impl Minimap {
2164    /// A map showing `image`, usually a small version of the content.
2165    pub fn image(view: Binding<ZoomView>, image: ImageData) -> Self {
2166        let content = Size::new(image.width() as f32, image.height() as f32);
2167        Self::with(view, content, ZoomContent::Image(image))
2168    }
2169
2170    /// A map showing what `draw` draws, for content of this size in units of the caller.
2171    pub fn custom(
2172        view: Binding<ZoomView>,
2173        content: Size,
2174        draw: impl Fn(ZoomPanPaint, &mut Scene) + 'static,
2175    ) -> Self {
2176        Self::with(view, content, ZoomContent::Custom(Rc::new(draw)))
2177    }
2178
2179    fn with(view: Binding<ZoomView>, content: Size, picture: ZoomContent) -> Self {
2180        Self { view, content, picture, width: 160.0, max_height: 160.0, press_zoom: None, label: None }
2181    }
2182
2183    /// The size of the content the view shows, when `image` is a smaller version of it.
2184    pub fn content_size(mut self, content: Size) -> Self {
2185        self.content = content;
2186        self
2187    }
2188
2189    /// The most width and height, in logical pixels (160 each by default). The map takes the
2190    /// proportions of the content inside that.
2191    pub fn max_size(mut self, width: f32, height: f32) -> Self {
2192        self.width = width;
2193        self.max_height = height;
2194        self
2195    }
2196
2197    /// A press while everything is in view zooms to this scale, there. Without it, such a
2198    /// press does nothing, since there is nowhere to go.
2199    pub fn press_zoom(mut self, scale: f32) -> Self {
2200        self.press_zoom = Some(scale);
2201        self
2202    }
2203
2204    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
2205    pub fn label(mut self, label: impl Into<String>) -> Self {
2206        self.label = Some(label.into());
2207        self
2208    }
2209}
2210
2211/// Moves a view's center, stopping where the rectangle of what is in view reaches an edge.
2212fn minimap_center(view: ZoomView, content: Size, center: Point) -> ZoomView {
2213    let visible = view.visible(content);
2214    let axis = |value: f32, size: f32| {
2215        let half = size / 2.0;
2216        if half >= 0.5 { 0.5 } else { value.clamp(half, 1.0 - half) }
2217    };
2218    let center = Point::new(axis(center.x, visible.width()), axis(center.y, visible.height()));
2219    ZoomView { center, ..view }
2220}
2221
2222impl View for Minimap {
2223    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
2224        let theme = *cx.theme();
2225        let labels = cx.labels();
2226        let current = self.view.get();
2227        let content = self.content;
2228        let aspect = content.width / content.height.max(f32::EPSILON);
2229        let (width, height) = if self.width / aspect <= self.max_height {
2230            (self.width, self.width / aspect)
2231        } else {
2232            (self.max_height * aspect, self.max_height)
2233        };
2234        let (background, border, scrim, accent) = (
2235            theme.color(Semantic::Canvas),
2236            theme.color(Semantic::Border),
2237            theme.color(Semantic::Scrim),
2238            theme.color(Semantic::Accent),
2239        );
2240        let radius = theme.radii.sm;
2241        let picture = self.picture.clone();
2242        let visible = current.visible(content);
2243        let node = cx.container(
2244            LayoutStyle {
2245                width: Some(width),
2246                height: Some(height),
2247                align_self: Some(Align::Start),
2248                ..LayoutStyle::leaf()
2249            },
2250            &[],
2251            Paint::Custom(Rc::new(move |frame: Rect, scene| {
2252                scene.push_quad(Quad::new(frame, background).radius(radius));
2253                picture.paint(scene, frame, frame, frame.width() / content.width.max(f32::EPSILON), radius);
2254                scene.push_quad(Quad::new(frame, Color::TRANSPARENT).radius(radius).border(1.0, border));
2255                if visible.width() >= 0.999 && visible.height() >= 0.999 {
2256                    return;
2257                }
2258                let shown = Rect::new(
2259                    frame.x() + visible.x() * frame.width(),
2260                    frame.y() + visible.y() * frame.height(),
2261                    visible.width() * frame.width(),
2262                    visible.height() * frame.height(),
2263                );
2264                // What is out of view goes dark, so the rectangle reads over any picture.
2265                let (above, below) = (shown.y() - frame.y(), frame.max_y() - shown.max_y());
2266                let (left, right) = (shown.x() - frame.x(), frame.max_x() - shown.max_x());
2267                for part in [
2268                    Rect::new(frame.x(), frame.y(), frame.width(), above),
2269                    Rect::new(frame.x(), shown.max_y(), frame.width(), below),
2270                    Rect::new(frame.x(), shown.y(), left, shown.height()),
2271                    Rect::new(shown.max_x(), shown.y(), right, shown.height()),
2272                ] {
2273                    if !part.is_empty() {
2274                        scene.push_quad(Quad::new(part, scrim));
2275                    }
2276                }
2277                scene.push_quad(Quad::new(shown, Color::TRANSPARENT).border(1.5, accent));
2278            })),
2279        );
2280        let name = self.label.clone().unwrap_or_else(|| labels.minimap.clone());
2281        let percent = |v: f32| (v * 100.0).round();
2282        let text = format!(
2283            "{} · {}%, {}%",
2284            zoom_percent(current.scale, labels.decimal_separator),
2285            percent(current.center.x),
2286            percent(current.center.y)
2287        );
2288        cx.semantics(node, Semantics::new(Role::Slider).name(name).value(text));
2289        cx.focusable(node, theme.radii.sm);
2290        cx.cursor(node, CursorIcon::Pointer);
2291        let (dragged, press_zoom) = (self.view.clone(), self.press_zoom);
2292        cx.on_drag(
2293            node,
2294            Rc::new(move |d| {
2295                if d.phase == DragPhase::End {
2296                    return;
2297                }
2298                let center = Point::new(
2299                    ((d.position.x - d.frame.x()) / d.frame.width().max(1.0)).clamp(0.0, 1.0),
2300                    ((d.position.y - d.frame.y()) / d.frame.height().max(1.0)).clamp(0.0, 1.0),
2301                );
2302                let mut view = dragged.get();
2303                let everything = view.visible(content);
2304                if everything.width() >= 0.999 && everything.height() >= 0.999 {
2305                    let Some(scale) = press_zoom else { return };
2306                    view = ZoomView { scale, fit: false, ..view };
2307                }
2308                let next = minimap_center(view, content, center);
2309                if next != dragged.get() {
2310                    dragged.set(next);
2311                }
2312            }),
2313        );
2314        let keyed = self.view.clone();
2315        cx.on_key(
2316            node,
2317            Rc::new(move |key, modifiers| {
2318                let step = if modifiers.shift { 0.25 } else { 0.05 };
2319                let (dx, dy) = match key {
2320                    Key::ArrowLeft => (-step, 0.0),
2321                    Key::ArrowRight => (step, 0.0),
2322                    Key::ArrowUp => (0.0, -step),
2323                    Key::ArrowDown => (0.0, step),
2324                    _ => return false,
2325                };
2326                let view = keyed.get();
2327                let center = Point::new(view.center.x + dx, view.center.y + dy);
2328                keyed.set(minimap_center(view, content, center));
2329                true
2330            }),
2331        );
2332        node
2333    }
2334}