Skip to main content

siplot/widget/
high_level.rs

1//! High-level silx-style plot widgets.
2//!
3//! These types own backend state and expose the user-facing plotting API:
4//! callers add data items, tune axes/labels/colors, then call [`PlotWidget::show`]
5//! from their egui app. The low-level stateless renderer remains
6//! [`crate::PlotView`].
7
8use std::fmt;
9use std::mem;
10use std::ops::{Deref, DerefMut};
11use std::path::{Path, PathBuf};
12
13use egui::Color32;
14use egui_wgpu::RenderState;
15
16use crate::core::backend::{
17    Backend, CurveColor, CurveSpec, ImagePixelsSpec, ImageSpec, ItemHandle, MarkerSpec, PickResult,
18    ShapeSpec, TriangleSpec,
19};
20use crate::core::calibration::Calibration;
21use crate::core::colormap::{AutoscaleMode, Colormap, Normalization};
22use crate::core::items::{Baseline, LineStyle, ScalarMask, Symbol};
23use crate::core::marker::{Marker, MarkerConstraint, MarkerKind};
24use crate::core::plot::{DataMargins, DataRange, GraphGrid, Plot, PlotId};
25use crate::core::roi::{ManagedRoi, Roi, RoiInteractionMode, RoiLineStyle};
26use crate::core::scatter_viz::{GridImage, ScatterLineProfile};
27use crate::core::shape::{Shape, ShapeKind};
28use crate::core::sift_align::{
29    AffineTransformation, MatchedKeypoint, SiftAlignment, sift_auto_align,
30};
31use crate::core::transform::{AxisSide, Margins, Scale, YAxis};
32use crate::core::triangles::Triangles;
33use crate::render::backend_wgpu::WgpuBackend;
34use crate::render::gpu_curve::CurveData;
35use crate::render::gpu_image::{AggregationMode, ImageData, ImagePixels, InterpolationMode};
36use crate::render::save::{SaveError, SaveFormat};
37use crate::widget::interaction::{DrawEvent, DrawMode, DrawParams, MouseButton, RoiDrawKind};
38use crate::widget::plot_widget::{PlotInteractionMode, PlotResponse, PlotView};
39use crate::widget::position_info::{
40    SNAP_THRESHOLD_DIST, Snap, SnapItem, SnapItemKind, SnappingMode, snap_to_nearest,
41    snapping_candidates,
42};
43
44/// Live profile extraction mode (silx profile toolbar).
45///
46/// Used with [`PlotWidget::show_profile_toolbar`] and
47/// [`ImageView::profile_values`].
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum ProfileMode {
50    /// Profile disabled.
51    #[default]
52    None,
53    /// Extract the row at the cursor Y position (horizontal slice).
54    Horizontal,
55    /// Extract the column at the cursor X position (vertical slice).
56    Vertical,
57    /// Extract profile along a drawn line segment.
58    Line,
59    /// Extract profile averaged/summed over a drawn rectangle.
60    Rectangle,
61}
62
63/// Popup controls for the median-filter action (silx `MedianFilterDialog`).
64///
65/// Held by the caller (e.g. in egui temp-memory) and passed to
66/// [`Plot2D::show_median_filter`], which mutates it from the kernel-width and
67/// conditional widgets. `kernel_width` is the square-kernel width for the 2D
68/// action (silx `MedianFilter2DAction`), kept odd by the widget.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub struct MedianFilterParams {
71    /// Odd kernel width (silx `MedianFilterDialog` spinbox; min 1, step 2).
72    pub kernel_width: usize,
73    /// Conditional median filtering (silx `MedianFilterDialog` checkbox): only
74    /// replace a center pixel that is the window min or max.
75    pub conditional: bool,
76}
77
78impl Default for MedianFilterParams {
79    fn default() -> Self {
80        // silx MedianFilterDialog defaults to a 3x3 kernel, conditional off.
81        Self {
82            kernel_width: 3,
83            conditional: false,
84        }
85    }
86}
87
88/// Data validation failures returned by helper APIs that build derived items.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub enum PlotDataError {
91    /// A row-major image-like buffer did not have `width * height` values.
92    ImageDataLength { expected: usize, actual: usize },
93    /// Histogram counts require exactly one more edge than bin count.
94    HistogramLength { bins: usize, edges: usize },
95    /// Requested profile row is outside the image height.
96    ProfileRow { row: u32, height: u32 },
97    /// Requested profile column is outside the image width.
98    ProfileColumn { column: u32, width: u32 },
99}
100
101impl fmt::Display for PlotDataError {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::ImageDataLength { expected, actual } => {
105                write!(
106                    f,
107                    "image data length {actual} does not match expected {expected}"
108                )
109            }
110            Self::HistogramLength { bins, edges } => {
111                write!(
112                    f,
113                    "histogram with {bins} bins requires {bins_plus_one} edges",
114                    bins_plus_one = bins + 1
115                )?;
116                write!(f, ", got {edges}")
117            }
118            Self::ProfileRow { row, height } => {
119                write!(f, "profile row {row} is outside image height {height}")
120            }
121            Self::ProfileColumn { column, width } => {
122                write!(f, "profile column {column} is outside image width {width}")
123            }
124        }
125    }
126}
127
128impl std::error::Error for PlotDataError {}
129
130/// Summary statistics over finite values.
131#[derive(Clone, Copy, Debug, Default, PartialEq)]
132pub struct ValueStats {
133    /// Number of input values.
134    pub count: usize,
135    /// Number of finite values used for min/max/mean.
136    pub finite_count: usize,
137    /// Minimum finite value.
138    pub min: Option<f64>,
139    /// Maximum finite value.
140    pub max: Option<f64>,
141    /// Mean of finite values.
142    pub mean: Option<f64>,
143}
144
145impl ValueStats {
146    /// Compute statistics from `f64` values, ignoring non-finite values for
147    /// min/max/mean while still counting them in [`Self::count`].
148    ///
149    /// Delegates to [`crate::core::stats::Stats`] (the single source of truth
150    /// for the silx statistic set) by treating the flat value array as a
151    /// `width = len`, `height = 1` scalar image with unit geometry, then keeps
152    /// only the count/finite-count/min/max/mean fields of the public
153    /// `ValueStats` shape.
154    pub fn from_f64(values: &[f64]) -> Self {
155        Self::from_stats(crate::core::stats::Stats::for_image(
156            values,
157            values.len(),
158            1,
159            (0.0, 0.0),
160            (1.0, 1.0),
161            crate::core::stats::StatScope::All,
162        ))
163    }
164
165    /// Compute statistics from `f32` values, ignoring non-finite values for
166    /// min/max/mean while still counting them in [`Self::count`].
167    ///
168    /// Widens to `f64` and delegates to [`crate::core::stats::Stats`], the same
169    /// single-source-of-truth path as [`Self::from_f64`].
170    pub fn from_f32(values: &[f32]) -> Self {
171        let widened: Vec<f64> = values.iter().map(|&v| v as f64).collect();
172        Self::from_f64(&widened)
173    }
174
175    /// Project a [`crate::core::stats::Stats`] result onto the `ValueStats`
176    /// fields. `Stats` carries the full silx statistic set (delta/sum/COM/
177    /// argmin/argmax); `ValueStats` keeps only count/finite-count/min/max/mean.
178    fn from_stats(stats: crate::core::stats::Stats) -> Self {
179        Self {
180            count: stats.count,
181            finite_count: stats.finite_count,
182            min: stats.min,
183            max: stats.max,
184            mean: stats.mean,
185        }
186    }
187}
188
189/// Statistics for curve-like items.
190#[derive(Clone, Copy, Debug, PartialEq)]
191pub struct CurveStats {
192    /// Statistics over the X values.
193    pub x: ValueStats,
194    /// Statistics over the Y values.
195    pub y: ValueStats,
196    /// Which Y axis this curve is bound to.
197    pub y_axis: YAxis,
198}
199
200/// Statistics for image-like items.
201#[derive(Clone, Copy, Debug, PartialEq)]
202pub struct ImageStats {
203    /// Image width in pixels.
204    pub width: u32,
205    /// Image height in pixels.
206    pub height: u32,
207    /// Total pixel count (`width * height`).
208    pub pixel_count: usize,
209    /// Scalar pixel statistics. `None` for direct RGBA images and masks.
210    pub scalar: Option<ValueStats>,
211}
212
213/// Geometry shared by image-like items.
214#[derive(Clone, Copy, Debug, PartialEq)]
215pub struct ImageGeometry {
216    /// Data-space position of the image's top-left corner `(x, y)`.
217    pub origin: (f64, f64),
218    /// Data-space size of one pixel `(dx, dy)`.
219    pub scale: (f64, f64),
220    /// Overall opacity in `[0.0, 1.0]`.
221    pub alpha: f32,
222}
223
224impl Default for ImageGeometry {
225    fn default() -> Self {
226        Self {
227            origin: (0.0, 0.0),
228            scale: (1.0, 1.0),
229            alpha: 1.0,
230        }
231    }
232}
233
234/// Per-item statistics retained by [`PlotWidget`].
235#[derive(Clone, Copy, Debug, PartialEq)]
236pub enum ItemStats {
237    Curve(CurveStats),
238    Image(ImageStats),
239}
240
241/// High-level item family tracked by [`PlotWidget`].
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243pub enum PlotItemKind {
244    Curve,
245    Histogram,
246    Scatter,
247    Image,
248    Mask,
249    Triangles,
250    Shape,
251    Marker,
252}
253
254impl PlotItemKind {
255    /// Short lowercase string name for the item family.
256    pub fn as_str(self) -> &'static str {
257        match self {
258            Self::Curve => "curve",
259            Self::Histogram => "histogram",
260            Self::Scatter => "scatter",
261            Self::Image => "image",
262            Self::Mask => "mask",
263            Self::Triangles => "triangles",
264            Self::Shape => "shape",
265            Self::Marker => "marker",
266        }
267    }
268
269    /// `true` for item families that live on the curve layer (Curve, Histogram, Scatter).
270    pub fn is_curve_like(self) -> bool {
271        matches!(self, Self::Curve | Self::Histogram | Self::Scatter)
272    }
273
274    /// `true` for item families that live on the image layer (Image, Mask).
275    pub fn is_image_like(self) -> bool {
276        matches!(self, Self::Image | Self::Mask)
277    }
278}
279
280/// Map a [`PlotItemKind`] to the [`SnapItemKind`] used by the `PositionInfo`
281/// snapping classifier (silx snaps only curves, histograms, and scatters).
282fn snap_item_kind(kind: PlotItemKind) -> SnapItemKind {
283    match kind {
284        PlotItemKind::Curve => SnapItemKind::Curve,
285        PlotItemKind::Histogram => SnapItemKind::Histogram,
286        PlotItemKind::Scatter => SnapItemKind::Scatter,
287        _ => SnapItemKind::Other,
288    }
289}
290
291/// High-level events queued by [`PlotWidget`] for application code to drain.
292#[derive(Clone, Debug, PartialEq)]
293pub enum PlotEvent {
294    /// An item was added to the plot.
295    ItemAdded {
296        handle: ItemHandle,
297        kind: PlotItemKind,
298    },
299    /// An item's data was updated in place.
300    ItemUpdated {
301        handle: ItemHandle,
302        kind: PlotItemKind,
303    },
304    /// An item was removed from the plot.
305    ItemRemoved {
306        handle: ItemHandle,
307        kind: PlotItemKind,
308    },
309    /// The selected item changed (via legend click or [`PlotWidget::set_active_item`]).
310    ActiveItemChanged {
311        previous: Option<ItemHandle>,
312        current: Option<ItemHandle>,
313    },
314    /// The display limits changed (pan, zoom, or programmatic update), carrying
315    /// the new ranges (silx `limitsChanged`, `PlotEvents.py:176-184`): `x` and
316    /// `y` are the left axes' `(min, max)`, `y2` the right axis' `(min, max)` or
317    /// `None` when no right axis is in use.
318    LimitsChanged {
319        x: (f64, f64),
320        y: (f64, f64),
321        y2: Option<(f64, f64)>,
322    },
323    /// A ROI was added to the collection at `index` (silx `sigRoiAdded`),
324    /// whether programmatically ([`PlotWidget::add_roi`] /
325    /// [`PlotWidget::add_managed_roi`]) or by an on-plot interactive draw. For an
326    /// interactive draw a [`Self::RoiCreated`] is emitted on the same frame,
327    /// after this (silx emits `sigRoiAdded` then `sigInteractiveRoiFinalized`).
328    /// Distinct from [`Self::RoiChanged`], which signals only a geometry change
329    /// to an existing ROI.
330    RoiAdded { index: usize },
331    /// An ROI edge drag or whole-ROI body drag moved the ROI at `index`
332    /// (silx `sigRoiChanged`). A pure geometry change — adds emit
333    /// [`Self::RoiAdded`] instead.
334    RoiChanged { index: usize },
335    /// A new ROI was created at `index` by an on-plot draw in
336    /// [`PlotInteractionMode::RoiCreate`] (silx `sigInteractiveRoiFinalized`:
337    /// the interactive draw gesture finished). Read its geometry with
338    /// `plot().rois[index].roi`. siplot builds the ROI only on draw-finish (no
339    /// mid-draw ROI object), so silx's separate `sigInteractiveRoiCreated`
340    /// (mid-gesture) collapses into this finish event.
341    RoiCreated { index: usize },
342    /// The in-progress draw preview advanced this frame in
343    /// [`PlotInteractionMode::RoiCreate`] (silx `drawingProgress`): `points` are
344    /// the current rubber-band's data-space vertices for the given `mode`.
345    DrawingProgress {
346        mode: DrawMode,
347        points: Vec<(f64, f64)>,
348    },
349    /// An on-plot draw completed this frame (silx `drawingFinished`), carrying the
350    /// resolved [`DrawParams`]. In [`PlotInteractionMode::RoiCreate`] a
351    /// [`Self::RoiCreated`] is emitted on the same frame (the ROI is built on top
352    /// of the finished draw, mirroring silx's `RegionOfInterestManager`).
353    DrawingFinished { mode: DrawMode, params: DrawParams },
354    /// A single ROI at `index` is about to be removed, emitted *before* the
355    /// removal so a listener can still read the ROI being dropped (silx
356    /// `RegionOfInterestManager.sigRoiAboutToBeRemoved`). After this the ROI is
357    /// gone and indices past it shift down by one.
358    RoiAboutToBeRemoved { index: usize },
359    /// All ROIs were cleared in one operation via [`PlotWidget::clear_rois`]
360    /// (re-read `rois()`). A single-ROI removal emits [`Self::RoiAboutToBeRemoved`]
361    /// instead — `RoisCleared` means the whole collection was emptied.
362    RoisCleared,
363    /// The current/highlighted ROI changed, by a manager selection or
364    /// [`PlotWidget::set_current_roi`] (silx `sigCurrentRoiChanged`). Carries the
365    /// previously- and newly-current ROI indices (either may be `None`).
366    CurrentRoiChanged {
367        previous: Option<usize>,
368        current: Option<usize>,
369    },
370    /// A ROI's handle-editing interaction mode changed, via the right-click
371    /// interaction-mode submenu or [`PlotWidget::set_roi_interaction_mode`] (silx
372    /// `InteractionModeMixIn.sigInteractionModeChanged`). Carries the ROI index
373    /// and its new [`RoiInteractionMode`].
374    RoiInteractionModeChanged {
375        index: usize,
376        mode: RoiInteractionMode,
377    },
378    /// A draggable marker was moved, either by an on-screen drag or by
379    /// [`PlotWidget::set_marker_position`] (silx `markerMoving` /
380    /// `markerMoved`). `handle` identifies the moved marker; read its new
381    /// position with [`PlotWidget::marker_position`].
382    MarkerMoved { handle: ItemHandle },
383    /// A draggable marker's drag began this frame (silx `beginDrag`). The drag
384    /// lifecycle is `MarkerDragStarted` → `MarkerMoved`×N → `MarkerDragFinished`;
385    /// the first [`Self::MarkerMoved`] arrives on the same frame. Read the
386    /// position with [`PlotWidget::marker_position`].
387    MarkerDragStarted { handle: ItemHandle },
388    /// A draggable marker's drag ended this frame, i.e. the button was released
389    /// (silx `endDrag` `markerMoved`). The marker's final position is already
390    /// persisted; read it with [`PlotWidget::marker_position`].
391    MarkerDragFinished { handle: ItemHandle },
392    /// A curve was clicked (silx `curveClicked`). `handle` identifies the
393    /// curve; `index`/`x`/`y` locate the nearest picked vertex; `button` is the
394    /// mouse button used.
395    CurveClicked {
396        handle: ItemHandle,
397        index: usize,
398        x: f64,
399        y: f64,
400        button: MouseButton,
401    },
402    /// An image was clicked (silx `imageClicked`). `col`/`row` are the picked
403    /// pixel column and row.
404    ImageClicked {
405        handle: ItemHandle,
406        col: u32,
407        row: u32,
408        button: MouseButton,
409    },
410    /// A non-indexed overlay item (marker, scatter, or shape) was clicked (silx
411    /// `markerClicked` and the generic item-pick path). Identify its kind with
412    /// [`PlotWidget::item_kind`].
413    ItemClicked {
414        handle: ItemHandle,
415        button: MouseButton,
416    },
417    /// The cursor hovered over an item with no button held (silx `hover` signal,
418    /// `prepareHoverSignal`). Mirrors silx's payload: `kind` is the item type,
419    /// `label` its name, `x`/`y` the data-space cursor position, `xpixel`/`ypixel`
420    /// the pixel cursor position, and `draggable` whether the item can be dragged
421    /// (true only for a draggable marker). silx's `selectable` flag is omitted: in
422    /// siplot every pickable item is set active on click, so the flag would be
423    /// a constant `true` carrying no information.
424    ItemHovered {
425        handle: ItemHandle,
426        kind: PlotItemKind,
427        label: Option<String>,
428        x: f64,
429        y: f64,
430        xpixel: f32,
431        ypixel: f32,
432        draggable: bool,
433    },
434}
435
436/// A legend right-click context-menu action (silx `LegendListContextMenu`).
437/// The action set mirrors silx: `SetActive` (`setActiveCurve`), `MapToLeft` /
438/// `MapToRight` (move the curve's Y axis), checkable `TogglePoints` /
439/// `ToggleLines` (symbol / line visibility), `Remove`, and `Rename`.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum LegendAction {
442    /// Make the item the active item (silx `setActiveCurve`).
443    SetActive,
444    /// Move a curve to the left Y axis (silx `Map to left`).
445    MapToLeft,
446    /// Move a curve to the right Y axis (silx `Map to right`).
447    MapToRight,
448    /// Toggle a curve's point markers (silx checkable `Points`).
449    TogglePoints,
450    /// Toggle a curve's connecting line (silx checkable `Lines`).
451    ToggleLines,
452    /// Remove the item from the plot (silx `Remove curve`).
453    Remove,
454    /// Open the rename popup for the item (silx `Rename curve`).
455    Rename,
456}
457
458/// Return value of [`PlotWidget::show_legend`].
459#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
460pub struct LegendResponse {
461    /// Item whose row was clicked (single-click select).
462    pub selected: Option<ItemHandle>,
463    /// Item whose row was double-clicked (activation).
464    pub activated: Option<ItemHandle>,
465    /// Handle whose visibility was toggled this frame (eye icon click).
466    pub visibility_changed: Option<ItemHandle>,
467    /// Context-menu action fired this frame, with the item it targeted. The
468    /// action is already self-applied by `show_legend`; this is reported for
469    /// callers that want to observe or react to it.
470    pub context_action: Option<(ItemHandle, LegendAction)>,
471}
472
473/// Return value of [`PlotWidget::show_toolbar`].
474#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
475pub struct ToolbarResponse {
476    /// Home button was clicked this frame.
477    pub reset_zoom: bool,
478    /// Interaction mode button (select/pan/zoom) was clicked.
479    pub interaction_mode_changed: bool,
480    /// Crosshair cursor toggle was clicked.
481    pub cursor_changed: bool,
482    /// Major grid toggle was clicked.
483    pub grid_changed: bool,
484    /// Minor grid toggle was clicked.
485    pub minor_grid_changed: bool,
486    /// Keep-aspect-ratio toggle was clicked.
487    pub aspect_changed: bool,
488    /// X log toggle was clicked.
489    pub x_log_changed: bool,
490    /// Y log toggle was clicked.
491    pub y_log_changed: bool,
492    /// X autoscale toggle was clicked (silx `XAxisAutoScaleAction`).
493    pub autoscale_x_changed: bool,
494    /// Y autoscale toggle was clicked (silx `YAxisAutoScaleAction`).
495    pub autoscale_y_changed: bool,
496    /// X invert toggle was clicked.
497    pub x_inverted_changed: bool,
498    /// Y invert toggle was clicked.
499    pub y_inverted_changed: bool,
500    /// Show-axis toggle was clicked (silx `ShowAxisAction`).
501    pub show_axis_changed: bool,
502    /// Curve-style cycle button was clicked (silx `CurveStyleAction`).
503    pub curve_style_changed: bool,
504    /// Zoom-in button was clicked (silx `ZoomInAction`).
505    pub zoom_in: bool,
506    /// Zoom-out button was clicked (silx `ZoomOutAction`).
507    pub zoom_out: bool,
508    /// Zoom-back button was clicked (silx `ZoomBackAction`).
509    pub zoom_back: bool,
510    /// A zoom-enabled-axes menu item was toggled (silx `ZoomEnabledAxesMenu`).
511    pub zoom_axes_changed: bool,
512    /// Save button was clicked (silx `SaveAction`).
513    pub save: bool,
514    /// Copy-to-clipboard button was clicked (silx `CopyAction`).
515    pub copy: bool,
516    /// Print button was clicked (silx `PrintAction`).
517    pub print: bool,
518}
519
520/// Return value of [`PlotWidget::show_with_toolbar`].
521pub struct PlotWithToolbarResponse {
522    /// What the toolbar registered this frame.
523    pub toolbar: ToolbarResponse,
524    /// What the plot view registered this frame.
525    pub plot: PlotResponse,
526}
527
528/// Silx-style name for a standalone high-level plot surface.
529///
530/// In egui the native application owns the actual OS window, so `PlotWindow`
531/// is an API alias for [`PlotWidget`] with the same retained item and toolbar
532/// behavior.
533pub type PlotWindow = PlotWidget;
534
535/// Default figure resolution used by the toolbar Save button when no explicit
536/// size is supplied (silx saves at the widget's pixel size; the toolbar has no
537/// data-area handle here, so it uses a fixed default).
538const DEFAULT_SAVE_SIZE: (u32, u32) = (1024, 768);
539
540/// Default figure resolution (dots per inch) recorded in formats that carry it
541/// (TIFF resolution tags). Mirrors silx's matplotlib-backend default of 90 dpi;
542/// 96 is the common screen value used here as a sensible default for the raster
543/// snapshot. PNG/PPM/SVG ignore it (px-sized containers).
544const DEFAULT_SAVE_DPI: u32 = 96;
545
546/// Order two axis bounds so the result is `(min, max)`, mirroring silx
547/// `LimitsToolBar._xFloatEditChanged`'s swap when the user types `max < min`.
548/// Pure so the [`PlotWidget::show_limits_toolbar`] swap is unit-testable without
549/// a GPU backend.
550fn ordered_limits(a: f64, b: f64) -> (f64, f64) {
551    if a <= b { (a, b) } else { (b, a) }
552}
553
554#[derive(Clone, Copy, Debug, PartialEq, Eq)]
555enum ToolbarIcon {
556    Home,
557    Select,
558    Pan,
559    Zoom,
560    Cursor,
561    Grid,
562    MinorGrid,
563    Aspect,
564    LogX,
565    LogY,
566    InvertX,
567    InvertY,
568    ShowAxis,
569    CurveStyle,
570    ZoomIn,
571    ZoomOut,
572    ZoomBack,
573    Save,
574    Copy,
575    Print,
576    AutoscaleX,
577    AutoscaleY,
578    MedianFilter,
579    PixelHistogram,
580}
581
582impl ToolbarIcon {
583    fn size(self) -> egui::Vec2 {
584        match self {
585            Self::LogX | Self::LogY => egui::vec2(34.0, 24.0),
586            _ => egui::vec2(28.0, 24.0),
587        }
588    }
589}
590
591fn expected_image_len(width: u32, height: u32) -> usize {
592    (width as usize).saturating_mul(height as usize)
593}
594
595/// Round a kernel dimension up to the nearest odd value (>= 1), matching the
596/// silx odd-kernel requirement (`MedianFilterDialog` spinbox min 1, step 2).
597fn force_odd(n: usize) -> usize {
598    if n <= 1 {
599        1
600    } else if n % 2 == 1 {
601        n
602    } else {
603        n + 1
604    }
605}
606
607/// Build the temp PNG path the print shim rasterizes into before handing it to
608/// the printer. Joins `dir` with a process-unique file name so concurrent plots
609/// (or a copy in flight) do not collide. Pure (no filesystem touch), so the
610/// naming is unit-testable; the actual write + printer submit are the shims.
611fn print_temp_png_path(dir: &Path, pid: u32) -> PathBuf {
612    dir.join(format!("siplot-print-{pid}.png"))
613}
614
615fn validate_image_len(width: u32, height: u32, actual: usize) -> Result<usize, PlotDataError> {
616    let expected = expected_image_len(width, height);
617    if actual == expected {
618        Ok(expected)
619    } else {
620        Err(PlotDataError::ImageDataLength { expected, actual })
621    }
622}
623
624fn toolbar_icon_button(
625    ui: &mut egui::Ui,
626    icon: ToolbarIcon,
627    selected: bool,
628    tooltip: &str,
629) -> egui::Response {
630    let (rect, response) = ui.allocate_exact_size(icon.size(), egui::Sense::click());
631    let response = response.on_hover_text(tooltip);
632    if ui.is_rect_visible(rect) {
633        draw_toolbar_button(ui, rect, &response, selected, icon);
634    }
635    response
636}
637
638fn draw_toolbar_button(
639    ui: &egui::Ui,
640    rect: egui::Rect,
641    response: &egui::Response,
642    selected: bool,
643    icon: ToolbarIcon,
644) {
645    let visuals = ui.style().interact_selectable(response, selected);
646    let painter = ui.painter();
647    let button_rect = rect.shrink(1.0);
648    if selected || response.hovered() || response.has_focus() {
649        painter.rect_filled(button_rect, 2.0, visuals.weak_bg_fill);
650        painter.rect_stroke(
651            button_rect,
652            2.0,
653            visuals.bg_stroke,
654            egui::StrokeKind::Inside,
655        );
656    }
657
658    let color = if !ui.is_enabled() {
659        ui.visuals().weak_text_color()
660    } else if selected {
661        ui.visuals().selection.stroke.color
662    } else {
663        visuals.fg_stroke.color
664    };
665    draw_toolbar_icon(painter, rect.shrink(5.0), icon, color);
666}
667
668fn draw_toolbar_icon(painter: &egui::Painter, rect: egui::Rect, icon: ToolbarIcon, color: Color32) {
669    let stroke = egui::Stroke::new(1.6, color);
670    match icon {
671        ToolbarIcon::Home => draw_home_icon(painter, rect, stroke),
672        ToolbarIcon::Select => draw_select_icon(painter, rect, stroke),
673        ToolbarIcon::Pan => draw_pan_icon(painter, rect, stroke),
674        ToolbarIcon::Zoom => draw_zoom_icon(painter, rect, stroke),
675        ToolbarIcon::Cursor => draw_cursor_icon(painter, rect, stroke),
676        ToolbarIcon::Grid => draw_grid_icon(painter, rect, stroke, 3),
677        ToolbarIcon::MinorGrid => draw_grid_icon(painter, rect, stroke, 4),
678        ToolbarIcon::Aspect => draw_center_text(painter, rect, "1:1", 11.0, color),
679        ToolbarIcon::LogX => draw_log_icon(painter, rect, false, stroke),
680        ToolbarIcon::LogY => draw_log_icon(painter, rect, true, stroke),
681        ToolbarIcon::InvertX => draw_axis_icon(painter, rect, "X", false, stroke),
682        ToolbarIcon::InvertY => draw_axis_icon(painter, rect, "Y", true, stroke),
683        ToolbarIcon::ShowAxis => draw_show_axis_icon(painter, rect, stroke),
684        ToolbarIcon::CurveStyle => draw_curve_style_icon(painter, rect, stroke),
685        ToolbarIcon::ZoomIn => draw_zoom_step_icon(painter, rect, stroke, true),
686        ToolbarIcon::ZoomOut => draw_zoom_step_icon(painter, rect, stroke, false),
687        ToolbarIcon::ZoomBack => draw_zoom_back_icon(painter, rect, stroke),
688        ToolbarIcon::Save => draw_save_icon(painter, rect, stroke),
689        ToolbarIcon::Copy => draw_copy_icon(painter, rect, stroke),
690        ToolbarIcon::Print => draw_print_icon(painter, rect, stroke),
691        ToolbarIcon::AutoscaleX => draw_autoscale_icon(painter, rect, "X", false, stroke),
692        ToolbarIcon::AutoscaleY => draw_autoscale_icon(painter, rect, "Y", true, stroke),
693        ToolbarIcon::MedianFilter => draw_median_filter_icon(painter, rect, stroke),
694        ToolbarIcon::PixelHistogram => draw_pixel_histogram_icon(painter, rect, stroke),
695    }
696}
697
698/// Draw three rising histogram bars for the [`ToolbarIcon::PixelHistogram`]
699/// button (silx `pixel-intensities` icon).
700fn draw_pixel_histogram_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
701    let base = rect.bottom();
702    let bar_w = rect.width() / 4.0;
703    let heights = [0.4_f32, 0.75, 1.0];
704    for (i, h) in heights.iter().enumerate() {
705        let x = rect.left() + bar_w * (i as f32 * 1.2 + 0.2);
706        let top = base - rect.height() * h;
707        let bar = egui::Rect::from_min_max(egui::pos2(x, top), egui::pos2(x + bar_w * 0.8, base));
708        painter.rect_stroke(bar, 0.0, stroke, egui::StrokeKind::Inside);
709    }
710}
711
712/// Draw a small 3x3 grid with a highlighted center cell for the
713/// [`ToolbarIcon::MedianFilter`] toggle (silx `median-filter` icon): a kernel
714/// sweeping a center pixel.
715fn draw_median_filter_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
716    let grid = rect.shrink(1.0);
717    let third_x = grid.width() / 3.0;
718    let third_y = grid.height() / 3.0;
719    // 3x3 cell grid outline.
720    painter.rect_stroke(grid, 0.0, stroke, egui::StrokeKind::Inside);
721    for i in 1..3 {
722        let x = grid.left() + third_x * i as f32;
723        painter.line_segment(
724            [egui::pos2(x, grid.top()), egui::pos2(x, grid.bottom())],
725            stroke,
726        );
727        let y = grid.top() + third_y * i as f32;
728        painter.line_segment(
729            [egui::pos2(grid.left(), y), egui::pos2(grid.right(), y)],
730            stroke,
731        );
732    }
733    // Highlight the center cell (the pixel being replaced by its window median).
734    let center = egui::Rect::from_min_size(
735        egui::pos2(grid.left() + third_x, grid.top() + third_y),
736        egui::vec2(third_x, third_y),
737    );
738    painter.rect_filled(center.shrink(1.0), 0.0, stroke.color);
739}
740
741/// Draw a labeled axis with a double-headed fit-arrow for the
742/// Shared glyph for the per-axis autoscale (silx `plot-xauto` / `plot-yauto`),
743/// log-scale (silx `plot-xlog` / `plot-ylog`) and invert toggles. A `label`
744/// (the axis letter, or "log") occupies the bulk of the icon while a
745/// double-headed arrow — whose orientation names the axis — is tucked against
746/// one edge: the X arrow along the bottom, the Y arrow down the left. The
747/// label and arrow sit in separate bands so they never overlap. (The previous
748/// autoscale/invert glyphs ran the arrow through the centre and painted the
749/// label on top of the shaft; the log glyph stacked two text lines in the
750/// 14px height — both crowded.) `inward` points the arrowheads toward each
751/// other (invert / flip) instead of outward (autoscale / log), keeping the
752/// actions visually distinct.
753fn draw_axis_arrow_icon(
754    painter: &egui::Painter,
755    rect: egui::Rect,
756    label: &str,
757    vertical: bool,
758    inward: bool,
759    font_size: f32,
760    stroke: egui::Stroke,
761) {
762    // Arrowhead barb length. Each head is a compact "V" spanning `a` along the
763    // shaft; `inward` flips which side the vertex sits on so the head points
764    // toward (invert) or away from (autoscale / log) the centre.
765    let a = 2.0;
766    let font = egui::FontId::proportional(font_size);
767    if vertical {
768        // Vertical double-arrow tucked against the left edge.
769        let x = rect.left() + 2.5;
770        let (ytop, ybot) = (rect.top() + 1.5, rect.bottom() - 1.5);
771        painter.line_segment([egui::pos2(x, ytop), egui::pos2(x, ybot)], stroke);
772        if inward {
773            // Heads point toward the centre (vertex inset, barbs at the ends).
774            painter.line_segment([egui::pos2(x, ytop + a), egui::pos2(x - a, ytop)], stroke);
775            painter.line_segment([egui::pos2(x, ytop + a), egui::pos2(x + a, ytop)], stroke);
776            painter.line_segment([egui::pos2(x, ybot - a), egui::pos2(x - a, ybot)], stroke);
777            painter.line_segment([egui::pos2(x, ybot - a), egui::pos2(x + a, ybot)], stroke);
778        } else {
779            // Heads point away from the centre (vertex at the ends).
780            painter.line_segment([egui::pos2(x, ytop), egui::pos2(x - a, ytop + a)], stroke);
781            painter.line_segment([egui::pos2(x, ytop), egui::pos2(x + a, ytop + a)], stroke);
782            painter.line_segment([egui::pos2(x, ybot), egui::pos2(x - a, ybot - a)], stroke);
783            painter.line_segment([egui::pos2(x, ybot), egui::pos2(x + a, ybot - a)], stroke);
784        }
785        // Label centred in the area to the right of the arrow.
786        let lx = (x + a + rect.right()) * 0.5;
787        painter.text(
788            egui::pos2(lx, rect.center().y),
789            egui::Align2::CENTER_CENTER,
790            label,
791            font,
792            stroke.color,
793        );
794    } else {
795        // Horizontal double-arrow tucked against the bottom edge.
796        let y = rect.bottom() - 2.0;
797        let (xl, xr) = (rect.left() + 1.5, rect.right() - 1.5);
798        painter.line_segment([egui::pos2(xl, y), egui::pos2(xr, y)], stroke);
799        if inward {
800            painter.line_segment([egui::pos2(xl + a, y), egui::pos2(xl, y - a)], stroke);
801            painter.line_segment([egui::pos2(xl + a, y), egui::pos2(xl, y + a)], stroke);
802            painter.line_segment([egui::pos2(xr - a, y), egui::pos2(xr, y - a)], stroke);
803            painter.line_segment([egui::pos2(xr - a, y), egui::pos2(xr, y + a)], stroke);
804        } else {
805            painter.line_segment([egui::pos2(xl, y), egui::pos2(xl + a, y - a)], stroke);
806            painter.line_segment([egui::pos2(xl, y), egui::pos2(xl + a, y + a)], stroke);
807            painter.line_segment([egui::pos2(xr, y), egui::pos2(xr - a, y - a)], stroke);
808            painter.line_segment([egui::pos2(xr, y), egui::pos2(xr - a, y + a)], stroke);
809        }
810        // Label centred in the area above the arrow.
811        let ly = (rect.top() + (y - a)) * 0.5;
812        painter.text(
813            egui::pos2(rect.center().x, ly),
814            egui::Align2::CENTER_CENTER,
815            label,
816            font,
817            stroke.color,
818        );
819    }
820}
821
822/// [`ToolbarIcon::AutoscaleX`] / [`ToolbarIcon::AutoscaleY`] toggles (silx
823/// `plot-xauto` / `plot-yauto`): a double arrow pointing outward reads as "fit
824/// this axis to the data extent". `vertical` selects the Y orientation.
825fn draw_autoscale_icon(
826    painter: &egui::Painter,
827    rect: egui::Rect,
828    axis: &str,
829    vertical: bool,
830    stroke: egui::Stroke,
831) {
832    draw_axis_arrow_icon(painter, rect, axis, vertical, false, 11.0, stroke);
833}
834
835/// Draw two overlapping document outlines for the [`ToolbarIcon::Copy`] button.
836fn draw_copy_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
837    let back = egui::Rect::from_min_max(
838        egui::pos2(rect.left() + 2.0, rect.top() + 2.0),
839        egui::pos2(rect.right() - 5.0, rect.bottom() - 5.0),
840    );
841    let front = egui::Rect::from_min_max(
842        egui::pos2(rect.left() + 5.0, rect.top() + 5.0),
843        egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
844    );
845    painter.rect_stroke(back, 1.0, stroke, egui::StrokeKind::Inside);
846    painter.rect_stroke(front, 1.0, stroke, egui::StrokeKind::Inside);
847}
848
849/// Draw a floppy-disk save glyph for the [`ToolbarIcon::Save`] button.
850fn draw_save_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
851    let body = egui::Rect::from_min_max(
852        egui::pos2(rect.left() + 2.0, rect.top() + 2.0),
853        egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
854    );
855    painter.rect_stroke(body, 1.0, stroke, egui::StrokeKind::Inside);
856    // Label area (top strip).
857    let label = egui::Rect::from_min_max(
858        egui::pos2(body.left() + 3.0, body.top()),
859        egui::pos2(body.right() - 3.0, body.top() + body.height() * 0.35),
860    );
861    painter.rect_stroke(label, 0.0, stroke, egui::StrokeKind::Inside);
862    // Shutter notch.
863    let notch = egui::Rect::from_min_max(
864        egui::pos2(body.right() - 6.0, body.top() + 1.0),
865        egui::pos2(body.right() - 3.0, body.top() + body.height() * 0.3),
866    );
867    painter.rect_filled(notch, 0.0, stroke.color);
868}
869
870/// Draw a printer glyph (paper feed, body, output tray) for the
871/// [`ToolbarIcon::Print`] button.
872fn draw_print_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
873    let cx_left = rect.left() + 5.0;
874    let cx_right = rect.right() - 5.0;
875    // Paper feeding in from the top.
876    let feed = egui::Rect::from_min_max(
877        egui::pos2(cx_left + 2.0, rect.top() + 2.0),
878        egui::pos2(cx_right - 2.0, rect.top() + rect.height() * 0.32),
879    );
880    painter.rect_stroke(feed, 0.0, stroke, egui::StrokeKind::Inside);
881    // Printer body.
882    let body = egui::Rect::from_min_max(
883        egui::pos2(cx_left, rect.top() + rect.height() * 0.34),
884        egui::pos2(cx_right, rect.bottom() - rect.height() * 0.18),
885    );
886    painter.rect_stroke(body, 1.0, stroke, egui::StrokeKind::Inside);
887    // Output tray (printed sheet emerging at the bottom).
888    let tray = egui::Rect::from_min_max(
889        egui::pos2(cx_left + 2.0, rect.bottom() - rect.height() * 0.34),
890        egui::pos2(cx_right - 2.0, rect.bottom() - 2.0),
891    );
892    painter.rect_stroke(tray, 0.0, stroke, egui::StrokeKind::Inside);
893}
894
895/// Draw a leftward back-arrow for the [`ToolbarIcon::ZoomBack`] button.
896fn draw_zoom_back_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
897    let c = rect.center();
898    let left = egui::pos2(rect.left() + 2.0, c.y);
899    let right = egui::pos2(rect.right() - 2.0, c.y);
900    painter.line_segment([left, right], stroke);
901    let arrow = 4.0;
902    painter.line_segment([left, left + egui::vec2(arrow, -arrow)], stroke);
903    painter.line_segment([left, left + egui::vec2(arrow, arrow)], stroke);
904}
905
906/// Draw a magnifier with a `+` ([`ToolbarIcon::ZoomIn`]) or `-`
907/// ([`ToolbarIcon::ZoomOut`]) inside the lens.
908fn draw_zoom_step_icon(
909    painter: &egui::Painter,
910    rect: egui::Rect,
911    stroke: egui::Stroke,
912    plus: bool,
913) {
914    let radius = rect.width().min(rect.height()) * 0.28;
915    let center = egui::pos2(rect.left() + radius + 2.0, rect.top() + radius + 2.0);
916    painter.circle_stroke(center, radius, stroke);
917    painter.line_segment(
918        [
919            center + egui::vec2(radius * 0.7, radius * 0.7),
920            egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
921        ],
922        stroke,
923    );
924    let bar = radius * 0.55;
925    // Horizontal bar of the +/-.
926    painter.line_segment(
927        [center - egui::vec2(bar, 0.0), center + egui::vec2(bar, 0.0)],
928        stroke,
929    );
930    if plus {
931        // Vertical bar makes the +.
932        painter.line_segment(
933            [center - egui::vec2(0.0, bar), center + egui::vec2(0.0, bar)],
934            stroke,
935        );
936    }
937}
938
939/// Draw a short dashed line over a dotted line for the [`ToolbarIcon::CurveStyle`]
940/// cycle button.
941fn draw_curve_style_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
942    let y_top = rect.top() + rect.height() * 0.35;
943    let y_bot = rect.top() + rect.height() * 0.65;
944    let left = rect.left() + 1.0;
945    let right = rect.right() - 1.0;
946    // Dashed segment on top.
947    let mut x = left;
948    while x < right {
949        let seg_end = (x + 3.0).min(right);
950        painter.line_segment([egui::pos2(x, y_top), egui::pos2(seg_end, y_top)], stroke);
951        x += 5.0;
952    }
953    // Dotted segment below.
954    let mut x = left;
955    while x < right {
956        painter.line_segment([egui::pos2(x, y_bot), egui::pos2(x + 1.0, y_bot)], stroke);
957        x += 3.0;
958    }
959}
960
961/// Draw an L-shaped axes glyph (left Y axis + bottom X axis with arrow tips)
962/// for the [`ToolbarIcon::ShowAxis`] toggle.
963fn draw_show_axis_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
964    let origin = egui::pos2(rect.left() + 3.0, rect.bottom() - 3.0);
965    let x_end = egui::pos2(rect.right() - 1.0, rect.bottom() - 3.0);
966    let y_end = egui::pos2(rect.left() + 3.0, rect.top() + 1.0);
967    painter.line_segment([origin, x_end], stroke);
968    painter.line_segment([origin, y_end], stroke);
969    let arrow = 3.0;
970    // X-axis arrow head.
971    painter.line_segment([x_end, x_end + egui::vec2(-arrow, -arrow)], stroke);
972    painter.line_segment([x_end, x_end + egui::vec2(-arrow, arrow)], stroke);
973    // Y-axis arrow head.
974    painter.line_segment([y_end, y_end + egui::vec2(-arrow, arrow)], stroke);
975    painter.line_segment([y_end, y_end + egui::vec2(arrow, arrow)], stroke);
976}
977
978fn draw_home_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
979    let top = egui::pos2(rect.center().x, rect.top());
980    let left_roof = egui::pos2(rect.left(), rect.center().y - 1.0);
981    let right_roof = egui::pos2(rect.right(), rect.center().y - 1.0);
982    painter.line_segment([left_roof, top], stroke);
983    painter.line_segment([top, right_roof], stroke);
984    let house = egui::Rect::from_min_max(
985        egui::pos2(rect.left() + 3.0, rect.center().y - 1.0),
986        egui::pos2(rect.right() - 3.0, rect.bottom()),
987    );
988    painter.rect_stroke(house, 1.0, stroke, egui::StrokeKind::Inside);
989}
990
991fn draw_select_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
992    let points = vec![
993        egui::pos2(rect.left() + 2.0, rect.top() + 1.0),
994        egui::pos2(rect.left() + 2.0, rect.bottom() - 2.0),
995        egui::pos2(rect.left() + 7.0, rect.bottom() - 6.0),
996        egui::pos2(rect.left() + 10.0, rect.bottom() - 1.0),
997        egui::pos2(rect.left() + 13.0, rect.bottom() - 2.5),
998        egui::pos2(rect.left() + 10.0, rect.bottom() - 7.0),
999        egui::pos2(rect.right() - 2.0, rect.bottom() - 7.0),
1000    ];
1001    painter.add(egui::Shape::convex_polygon(
1002        points,
1003        Color32::TRANSPARENT,
1004        stroke,
1005    ));
1006}
1007
1008fn draw_pan_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
1009    let c = rect.center();
1010    let arrow = 3.0;
1011    painter.line_segment(
1012        [egui::pos2(rect.left(), c.y), egui::pos2(rect.right(), c.y)],
1013        stroke,
1014    );
1015    painter.line_segment(
1016        [egui::pos2(c.x, rect.top()), egui::pos2(c.x, rect.bottom())],
1017        stroke,
1018    );
1019    for (tip, a, b) in [
1020        (
1021            egui::pos2(rect.left(), c.y),
1022            egui::pos2(rect.left() + arrow, c.y - arrow),
1023            egui::pos2(rect.left() + arrow, c.y + arrow),
1024        ),
1025        (
1026            egui::pos2(rect.right(), c.y),
1027            egui::pos2(rect.right() - arrow, c.y - arrow),
1028            egui::pos2(rect.right() - arrow, c.y + arrow),
1029        ),
1030        (
1031            egui::pos2(c.x, rect.top()),
1032            egui::pos2(c.x - arrow, rect.top() + arrow),
1033            egui::pos2(c.x + arrow, rect.top() + arrow),
1034        ),
1035        (
1036            egui::pos2(c.x, rect.bottom()),
1037            egui::pos2(c.x - arrow, rect.bottom() - arrow),
1038            egui::pos2(c.x + arrow, rect.bottom() - arrow),
1039        ),
1040    ] {
1041        painter.line_segment([tip, a], stroke);
1042        painter.line_segment([tip, b], stroke);
1043    }
1044}
1045
1046fn draw_zoom_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
1047    let radius = rect.width().min(rect.height()) * 0.28;
1048    let center = egui::pos2(rect.left() + radius + 2.0, rect.top() + radius + 2.0);
1049    painter.circle_stroke(center, radius, stroke);
1050    painter.line_segment(
1051        [
1052            center + egui::vec2(radius * 0.7, radius * 0.7),
1053            egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
1054        ],
1055        stroke,
1056    );
1057}
1058
1059fn draw_cursor_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
1060    let center = rect.center();
1061    painter.line_segment(
1062        [
1063            egui::pos2(rect.left(), center.y),
1064            egui::pos2(rect.right(), center.y),
1065        ],
1066        stroke,
1067    );
1068    painter.line_segment(
1069        [
1070            egui::pos2(center.x, rect.top()),
1071            egui::pos2(center.x, rect.bottom()),
1072        ],
1073        stroke,
1074    );
1075    painter.circle_stroke(center, rect.width().min(rect.height()) * 0.28, stroke);
1076}
1077
1078fn draw_grid_icon(
1079    painter: &egui::Painter,
1080    rect: egui::Rect,
1081    stroke: egui::Stroke,
1082    divisions: usize,
1083) {
1084    painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Inside);
1085    for index in 1..divisions {
1086        let t = index as f32 / divisions as f32;
1087        let x = egui::lerp(rect.left()..=rect.right(), t);
1088        let y = egui::lerp(rect.top()..=rect.bottom(), t);
1089        painter.line_segment(
1090            [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
1091            stroke,
1092        );
1093        painter.line_segment(
1094            [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)],
1095            stroke,
1096        );
1097    }
1098}
1099
1100/// [`ToolbarIcon::LogX`] / [`ToolbarIcon::LogY`] toggles (silx `plot-xlog` /
1101/// `plot-ylog`): the word "log" with a double-arrow tucked against the axis
1102/// edge (X along the bottom, Y down the left) naming the axis the log scale
1103/// applies to. Mirrors the autoscale glyph but labelled "log" instead of a
1104/// single letter; `vertical` selects the Y orientation.
1105fn draw_log_icon(painter: &egui::Painter, rect: egui::Rect, vertical: bool, stroke: egui::Stroke) {
1106    draw_axis_arrow_icon(painter, rect, "log", vertical, false, 9.0, stroke);
1107}
1108
1109/// [`ToolbarIcon::InvertX`] / [`ToolbarIcon::InvertY`] toggles: the same axis
1110/// letter + double-arrow glyph as autoscale, but the arrowheads point inward
1111/// (toward each other) to read as "flip / reverse this axis" rather than "fit
1112/// to extent", so the two toolbar buttons stay distinguishable.
1113fn draw_axis_icon(
1114    painter: &egui::Painter,
1115    rect: egui::Rect,
1116    axis: &str,
1117    vertical: bool,
1118    stroke: egui::Stroke,
1119) {
1120    draw_axis_arrow_icon(painter, rect, axis, vertical, true, 11.0, stroke);
1121}
1122
1123fn draw_center_text(
1124    painter: &egui::Painter,
1125    rect: egui::Rect,
1126    text: &str,
1127    size: f32,
1128    color: Color32,
1129) {
1130    painter.text(
1131        rect.center(),
1132        egui::Align2::CENTER_CENTER,
1133        text,
1134        egui::FontId::proportional(size),
1135        color,
1136    );
1137}
1138
1139fn mask_rgba_pixels(mask: &[bool], color: Color32) -> Vec<[u8; 4]> {
1140    let rgba = color.to_srgba_unmultiplied();
1141    mask.iter()
1142        .map(|masked| if *masked { rgba } else { [0, 0, 0, 0] })
1143        .collect()
1144}
1145
1146/// Build a step-line outline for histogram `counts` and bin `edges`.
1147pub fn histogram_step_values(
1148    edges: &[f64],
1149    counts: &[f64],
1150) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
1151    if edges.len() != counts.len() + 1 {
1152        return Err(PlotDataError::HistogramLength {
1153            bins: counts.len(),
1154            edges: edges.len(),
1155        });
1156    }
1157    if counts.is_empty() {
1158        return Ok((Vec::new(), Vec::new()));
1159    }
1160
1161    let mut x = Vec::with_capacity(counts.len() * 2 + 2);
1162    let mut y = Vec::with_capacity(counts.len() * 2 + 2);
1163    x.push(edges[0]);
1164    y.push(0.0);
1165    for (index, count) in counts.iter().copied().enumerate() {
1166        x.push(edges[index]);
1167        y.push(count);
1168        x.push(edges[index + 1]);
1169        y.push(count);
1170    }
1171    x.push(edges[counts.len()]);
1172    y.push(0.0);
1173    Ok((x, y))
1174}
1175
1176/// How bin positions relate to the bins they describe when deriving the `N + 1`
1177/// bin edges from `N` positions, mirroring the silx histogram `align` parameter
1178/// (`Histogram.setData(align=)`, values `"left"`/`"center"`/`"right"`).
1179#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1180pub enum HistogramAlign {
1181    /// Each position is its bin's **left** edge; the trailing edge extends by the
1182    /// last spacing (silx `_computeEdges(x, "right")` — silx names it from where
1183    /// the extra edge is appended, on the right).
1184    Left,
1185    /// Each position is its bin's **center** (silx default; `_computeEdges(x,
1186    /// "center")`).
1187    #[default]
1188    Center,
1189    /// Each position is its bin's **right** edge; the leading edge extends back by
1190    /// the first spacing (silx `_computeEdges(x, "left")`).
1191    Right,
1192}
1193
1194/// Derive the `N + 1` histogram bin edges from `N` bin positions and an
1195/// alignment, mirroring silx `items.histogram._computeEdges` (used by
1196/// `Histogram.setData(align=)`).
1197///
1198/// silx assumes uniform-ish spacing and extends the open end by one neighbour
1199/// gap: for `Left` (positions are left edges) the trailing edge is
1200/// `x[-1] + (x[-1] − x[-2])`; for `Right` (positions are right edges) the leading
1201/// edge is `x[0] − (x[1] − x[0])`; for `Center` it right-aligns first, then
1202/// shifts every edge left by half of its following gap (the last half-gap reused
1203/// for the final edge), placing each position at its bin centre. A lone position
1204/// uses a unit gap (silx `width = 1`). An empty input yields no edges.
1205///
1206/// Note the silx naming inversion this enum corrects: silx's `"right"` rule
1207/// appends the extra edge on the right and so treats positions as **left** edges
1208/// (and vice-versa); [`HistogramAlign`] names the variant after where the
1209/// position sits in its bin.
1210pub fn histogram_edges(positions: &[f64], align: HistogramAlign) -> Vec<f64> {
1211    if positions.is_empty() {
1212        return Vec::new();
1213    }
1214    let n = positions.len();
1215    match align {
1216        HistogramAlign::Left => {
1217            // silx `_computeEdges(x, "right")`: positions are left edges; append
1218            // x[-1] + last_gap.
1219            let width = if n > 1 {
1220                positions[n - 1] - positions[n - 2]
1221            } else {
1222                1.0
1223            };
1224            let mut edges = positions.to_vec();
1225            edges.push(positions[n - 1] + width);
1226            edges
1227        }
1228        HistogramAlign::Right => {
1229            // silx `_computeEdges(x, "left")`: positions are right edges; prepend
1230            // x[0] - first_gap.
1231            let width = if n > 1 {
1232                positions[1] - positions[0]
1233            } else {
1234                1.0
1235            };
1236            let mut edges = Vec::with_capacity(n + 1);
1237            edges.push(positions[0] - width);
1238            edges.extend_from_slice(positions);
1239            edges
1240        }
1241        HistogramAlign::Center => {
1242            // silx: right-align (positions as left edges), then shift each edge
1243            // left by half its following gap so the positions land at bin centres.
1244            let right = histogram_edges(positions, HistogramAlign::Left);
1245            let mut widths: Vec<f64> = right.windows(2).map(|w| (w[1] - w[0]) / 2.0).collect();
1246            if let Some(&last) = widths.last() {
1247                widths.push(last);
1248            }
1249            right
1250                .iter()
1251                .zip(widths.iter())
1252                .map(|(&edge, &width)| edge - width)
1253                .collect()
1254        }
1255    }
1256}
1257
1258/// Pick the filled-histogram bin under data coordinates `(x_data, y_data)`,
1259/// mirroring silx `Histogram.__pickFilledHistogram` (items/histogram.py:244-279).
1260///
1261/// `edges` are the `N + 1` ascending bin edges and `values` the `N` per-bin
1262/// heights; `baseline` is the level the bars rise from (silx default `0`). A bar
1263/// occupies `[edges[i], edges[i + 1]) × [baseline, value]` (or `[value, baseline]`
1264/// when the bar points down). Returns the index of the bar containing the point,
1265/// or `None` when the point is outside the histogram's bounding box or not inside
1266/// any bar.
1267///
1268/// The bounding-box test is strict (silx `xmin < x < xmax`, `ymin < y < ymax`,
1269/// with the y-bounds including `0` so the fill region between bars and baseline is
1270/// covered); the per-bar test is inclusive. The bin is located with silx's
1271/// `searchsorted(edges, x, side="left") - 1`, clamped to `[0, N - 1]`.
1272pub fn pick_histogram(
1273    edges: &[f64],
1274    values: &[f64],
1275    baseline: f64,
1276    x_data: f64,
1277    y_data: f64,
1278) -> Option<usize> {
1279    if values.is_empty() || edges.len() != values.len() + 1 {
1280        return None;
1281    }
1282
1283    // Bounding box (silx `Histogram._getBounds`, linear-axis branch): x spans the
1284    // edges; y includes 0 so the area between the bars and the baseline counts.
1285    let xmin = edges[0];
1286    let xmax = edges[edges.len() - 1];
1287    let mut vmin = f64::INFINITY;
1288    let mut vmax = f64::NEG_INFINITY;
1289    for &v in values {
1290        if v.is_finite() {
1291            vmin = vmin.min(v);
1292            vmax = vmax.max(v);
1293        }
1294    }
1295    if !vmin.is_finite() {
1296        // All values NaN: silx `_getBounds` returns None → nothing to pick.
1297        return None;
1298    }
1299    let ymin = vmin.min(0.0);
1300    let ymax = vmax.max(0.0);
1301    if x_data <= xmin || x_data >= xmax || y_data <= ymin || y_data >= ymax {
1302        return None;
1303    }
1304
1305    // Bin index: silx `searchsorted(edges, x, side="left") - 1`, clamped to a
1306    // valid bin. `partition_point` counts edges strictly below x = side="left".
1307    let index = edges
1308        .partition_point(|&e| e < x_data)
1309        .saturating_sub(1)
1310        .min(values.len() - 1);
1311
1312    let value = values[index];
1313    let hit = (baseline <= value && baseline <= y_data && y_data <= value)
1314        || (value < baseline && value <= y_data && y_data <= baseline);
1315    if hit { Some(index) } else { None }
1316}
1317
1318/// Extract one image row as a 1D profile.
1319pub fn horizontal_profile_values(
1320    width: u32,
1321    height: u32,
1322    data: &[f32],
1323    row: u32,
1324) -> Result<Vec<f64>, PlotDataError> {
1325    validate_image_len(width, height, data.len())?;
1326    if row >= height {
1327        return Err(PlotDataError::ProfileRow { row, height });
1328    }
1329    let width = width as usize;
1330    let start = row as usize * width;
1331    Ok(data[start..start + width]
1332        .iter()
1333        .map(|value| *value as f64)
1334        .collect())
1335}
1336
1337/// Extract one image column as a 1D profile.
1338pub fn vertical_profile_values(
1339    width: u32,
1340    height: u32,
1341    data: &[f32],
1342    column: u32,
1343) -> Result<Vec<f64>, PlotDataError> {
1344    validate_image_len(width, height, data.len())?;
1345    if column >= width {
1346        return Err(PlotDataError::ProfileColumn { column, width });
1347    }
1348    let width = width as usize;
1349    let column = column as usize;
1350    Ok((0..height as usize)
1351        .map(|row| data[row * width + column] as f64)
1352        .collect())
1353}
1354
1355/// How a profile reduces the pixels across its integration band, mirroring the
1356/// silx profile `method` parameter (`tools/profile/core.py`, values `"mean"` /
1357/// `"sum"`).
1358#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1359pub enum ProfileMethod {
1360    /// Average the band pixels (silx `numpy.mean`), the silx default.
1361    #[default]
1362    Mean,
1363    /// Sum the band pixels (silx `numpy.sum`).
1364    Sum,
1365}
1366
1367/// Extract an axis-aligned profile that integrates a band of `roi_width` pixels
1368/// centered on `position`, reducing each profile sample with `method`, faithful
1369/// to silx `_alignedFullProfile` (`tools/profile/core.py:204-270`).
1370///
1371/// With `horizontal == true` the profile runs along X (one sample per column) and
1372/// `position` is the Y (row) of the line; the band spans `roi_width` rows. With
1373/// `horizontal == false` the profile runs along Y (one sample per row) and
1374/// `position` is the X (column); the band spans `roi_width` columns. siplot's
1375/// ImageView uses identity geometry (origin `(0, 0)`, scale `(1, 1)`), so
1376/// `position` is already in image pixels.
1377///
1378/// The band is placed exactly as silx does: clip `roi_width` to the image,
1379/// `start = ⌊⌊position⌋ + 0.5 − roi_width/2⌋` clamped to `[0, dim − roi_width]`,
1380/// `end = start + roi_width`. `roi_width` is treated as at least 1.
1381/// [`ProfileMethod::Mean`] divides each sample by the band size;
1382/// [`ProfileMethod::Sum`] does not. This generalizes
1383/// [`horizontal_profile_values`] / [`vertical_profile_values`] (the
1384/// `roi_width == 1`, [`ProfileMethod::Mean`] case).
1385pub fn aligned_profile_values(
1386    width: u32,
1387    height: u32,
1388    data: &[f32],
1389    position: f64,
1390    roi_width: u32,
1391    horizontal: bool,
1392    method: ProfileMethod,
1393) -> Result<Vec<f64>, PlotDataError> {
1394    validate_image_len(width, height, data.len())?;
1395    let w = width as usize;
1396    let h = height as usize;
1397    if w == 0 || h == 0 {
1398        return Ok(Vec::new());
1399    }
1400
1401    // The dimension the band integrates over, and the profile's own length.
1402    let (band_dim, profile_len) = if horizontal { (h, w) } else { (w, h) };
1403
1404    // silx `roiWidth = min(dim, roiWidth)`, treated as at least one pixel.
1405    let band = (roi_width.max(1) as usize).min(band_dim);
1406
1407    // silx `start = int(int(position) + 0.5 - roiWidth/2.0)`, then clamp to
1408    // `[0, dim - roiWidth]`. Both `int()`s truncate toward zero.
1409    let start_f = position.trunc() + 0.5 - band as f64 / 2.0;
1410    let start = (start_f.trunc() as i64).clamp(0, (band_dim - band) as i64) as usize;
1411    let end = start + band;
1412    let denom = band as f64;
1413
1414    let profile = (0..profile_len)
1415        .map(|p| {
1416            let acc: f64 = (start..end)
1417                .map(|b| {
1418                    // Horizontal: p = column, b = row; Vertical: p = row, b = column.
1419                    let idx = if horizontal { b * w + p } else { p * w + b };
1420                    data[idx] as f64
1421                })
1422                .sum();
1423            match method {
1424                ProfileMethod::Mean => acc / denom,
1425                ProfileMethod::Sum => acc,
1426            }
1427        })
1428        .collect();
1429    Ok(profile)
1430}
1431
1432/// Extract a 1D profile along a line segment using nearest neighbor sampling.
1433/// `start` and `end` are in (column, row) coordinates.
1434pub fn line_profile_values(
1435    width: u32,
1436    height: u32,
1437    data: &[f32],
1438    start: (f64, f64),
1439    end: (f64, f64),
1440) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
1441    validate_image_len(width, height, data.len())?;
1442    let (x0, y0) = start;
1443    let (x1, y1) = end;
1444    let dx = x1 - x0;
1445    let dy = y1 - y0;
1446    let dist = (dx * dx + dy * dy).sqrt();
1447    let n_points = dist.ceil() as usize + 1;
1448
1449    let mut x_vals = Vec::with_capacity(n_points);
1450    let mut y_vals = Vec::with_capacity(n_points);
1451
1452    let w = width as i64;
1453    let h = height as i64;
1454
1455    for i in 0..n_points {
1456        let t = if n_points > 1 {
1457            i as f64 / (n_points - 1) as f64
1458        } else {
1459            0.0
1460        };
1461        let x = x0 + t * dx;
1462        let y = y0 + t * dy;
1463        let col = x.round() as i64;
1464        let row = y.round() as i64;
1465
1466        let val = if col >= 0 && col < w && row >= 0 && row < h {
1467            data[(row as usize) * (width as usize) + (col as usize)] as f64
1468        } else {
1469            f64::NAN
1470        };
1471        x_vals.push(t * dist);
1472        y_vals.push(val);
1473    }
1474
1475    Ok((x_vals, y_vals))
1476}
1477
1478/// Bilinear interpolation of `data` (row-major `width × height`) at fractional
1479/// column `col` and row `row`, porting silx `BilinearImage.c_funct`
1480/// (image/bilinear.pyx:121-215, the no-mask path). Coordinates outside the image
1481/// clamp to the nearest edge (silx "nearest for outside"). Indexed
1482/// `data[row * width + col]`.
1483fn bilinear_sample(width: usize, height: usize, data: &[f32], col: f64, row: f64) -> f64 {
1484    // silx clamps the row coord (`d0`) and column coord (`d1`) into the image.
1485    let d0 = row.clamp(0.0, height as f64 - 1.0);
1486    let d1 = col.clamp(0.0, width as f64 - 1.0);
1487    let r0 = d0.floor();
1488    let r1 = d0.ceil();
1489    let c0 = d1.floor();
1490    let c1 = d1.ceil();
1491    let (i0, i1) = (r0 as usize, r1 as usize); // row indices
1492    let (j0, j1) = (c0 as usize, c1 as usize); // column indices
1493    let at = |i: usize, j: usize| data[i * width + j] as f64;
1494    if i0 == i1 && j0 == j1 {
1495        at(i0, j0)
1496    } else if i0 == i1 {
1497        // Same row: interpolate across columns.
1498        at(i0, j0) * (c1 - d1) + at(i0, j1) * (d1 - c0)
1499    } else if j0 == j1 {
1500        // Same column: interpolate across rows.
1501        at(i0, j0) * (r1 - d0) + at(i1, j0) * (d0 - r0)
1502    } else {
1503        // Full bilinear: row weights (r1-d0)/(d0-r0), col weights (c1-d1)/(d1-c0).
1504        at(i0, j0) * (r1 - d0) * (c1 - d1)
1505            + at(i1, j0) * (d0 - r0) * (c1 - d1)
1506            + at(i0, j1) * (r1 - d0) * (d1 - c0)
1507            + at(i1, j1) * (d0 - r0) * (d1 - c0)
1508    }
1509}
1510
1511/// Extract a free-line image profile with a perpendicular band of `linewidth`
1512/// pixels, porting silx `BilinearImage.profile_line` (image/bilinear.pyx:391-466).
1513///
1514/// `start`/`end` are `(column, row)` pixel-centre coordinates (matching
1515/// [`line_profile_values`]; integer coordinates are pixel centres, so silx's
1516/// `-0.5` plot-corner shift is *not* applied here). The profile has
1517/// `ceil(length + 1)` samples; each sample bilinearly interpolates
1518/// (`bilinear_sample`) `linewidth` points spaced one pixel apart along the
1519/// perpendicular to the line and centred on it, then reduces them by `method`
1520/// ([`ProfileMethod::Mean`] = mean of the in-bounds finite band points, silx
1521/// default; [`ProfileMethod::Sum`] = their sum). Band points outside the image
1522/// are dropped (silx strict bounds test); a sample with no in-bounds finite
1523/// point is `NaN` under `Mean` and `0` under `Sum`, matching silx. Unlike the
1524/// nearest-neighbour [`line_profile_values`], sampling is bilinear and supports a
1525/// band width. Returns `(distance_along_line, value)` pairs.
1526pub fn line_profile_band(
1527    width: u32,
1528    height: u32,
1529    data: &[f32],
1530    start: (f64, f64),
1531    end: (f64, f64),
1532    linewidth: u32,
1533    method: ProfileMethod,
1534) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
1535    validate_image_len(width, height, data.len())?;
1536    let w = width as usize;
1537    let h = height as usize;
1538    let (src_col0, src_row0) = start;
1539    let (dst_col, dst_row) = end;
1540    // Degenerate line: silx returns a single interpolated sample.
1541    if src_row0 == dst_row && src_col0 == dst_col {
1542        return Ok((
1543            vec![0.0],
1544            vec![bilinear_sample(w, h, data, src_col0, src_row0)],
1545        ));
1546    }
1547    let lw = linewidth.max(1) as usize;
1548    let d_row = dst_row - src_row0;
1549    let d_col = dst_col - src_col0;
1550    let length = (d_row * d_row + d_col * d_col).sqrt();
1551    // Perpendicular unit vector (silx row_width / col_width) for the band offset.
1552    let row_width = d_col / length;
1553    let col_width = -d_row / length;
1554    let count = (length + 1.0).ceil() as usize; // silx `lengt`
1555    let denom = (count - 1) as f64; // count >= 2 since start != end
1556    let step_row = d_row / denom;
1557    let step_col = d_col / denom;
1558    // Shift the start onto the band's first perpendicular offset, centred on the
1559    // line (silx `src -= width * (linewidth - 1) / 2`).
1560    let src_row = src_row0 - row_width * (lw as f64 - 1.0) / 2.0;
1561    let src_col = src_col0 - col_width * (lw as f64 - 1.0) / 2.0;
1562
1563    let mut x_vals = Vec::with_capacity(count);
1564    let mut y_vals = Vec::with_capacity(count);
1565    for i in 0..count {
1566        let row = src_row + i as f64 * step_row;
1567        let col = src_col + i as f64 * step_col;
1568        let mut sum = 0.0;
1569        let mut cnt = 0usize;
1570        for j in 0..lw {
1571            let nr = row + j as f64 * row_width;
1572            let nc = col + j as f64 * col_width;
1573            // silx strict bounds test (band points outside the image are dropped,
1574            // unlike c_funct's internal edge clamp).
1575            if nc >= 0.0 && nc < width as f64 && nr >= 0.0 && nr < height as f64 {
1576                let val = bilinear_sample(w, h, data, nc, nr);
1577                if val.is_finite() {
1578                    cnt += 1;
1579                    sum += val;
1580                }
1581            }
1582        }
1583        let value = match (cnt > 0, method) {
1584            (true, ProfileMethod::Mean) => sum / cnt as f64,
1585            (true, ProfileMethod::Sum) => sum,
1586            (false, ProfileMethod::Mean) => f64::NAN,
1587            (false, ProfileMethod::Sum) => 0.0,
1588        };
1589        x_vals.push(i as f64 / denom * length);
1590        y_vals.push(value);
1591    }
1592    Ok((x_vals, y_vals))
1593}
1594
1595/// Extract a 1D profile within a rectangle by reducing along an axis.
1596///
1597/// `rect` is (x_min, x_max, y_min, y_max) in (column, row) coordinates.
1598/// `method` selects the band reduction (silx profile `method`):
1599/// [`ProfileMethod::Mean`] averages the band, [`ProfileMethod::Sum`] integrates
1600/// it (silx `numpy.mean` / `numpy.sum` over the rectangle's short axis).
1601pub fn rect_profile_values(
1602    width: u32,
1603    height: u32,
1604    data: &[f32],
1605    rect: (f64, f64, f64, f64),
1606    horizontal: bool,
1607    method: ProfileMethod,
1608) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
1609    validate_image_len(width, height, data.len())?;
1610    let (x_min, x_max, y_min, y_max) = rect;
1611
1612    let col_min = x_min.round().max(0.0) as usize;
1613    let col_max = x_max.round().min(width as f64 - 1.0) as usize;
1614    let row_min = y_min.round().max(0.0) as usize;
1615    let row_max = y_max.round().min(height as f64 - 1.0) as usize;
1616
1617    if col_min > col_max
1618        || row_min > row_max
1619        || col_max >= width as usize
1620        || row_max >= height as usize
1621    {
1622        return Ok((vec![], vec![]));
1623    }
1624
1625    let reduce = |sum: f64, count: f64| match method {
1626        ProfileMethod::Mean => sum / count,
1627        ProfileMethod::Sum => sum,
1628    };
1629
1630    if horizontal {
1631        let num_rows = (row_max - row_min + 1) as f64;
1632        let mut x_vals = Vec::with_capacity(col_max - col_min + 1);
1633        let mut y_vals = Vec::with_capacity(col_max - col_min + 1);
1634
1635        for col in col_min..=col_max {
1636            let mut sum = 0.0;
1637            for row in row_min..=row_max {
1638                sum += data[row * width as usize + col] as f64;
1639            }
1640            x_vals.push(col as f64);
1641            y_vals.push(reduce(sum, num_rows));
1642        }
1643        Ok((x_vals, y_vals))
1644    } else {
1645        let num_cols = (col_max - col_min + 1) as f64;
1646        let mut x_vals = Vec::with_capacity(row_max - row_min + 1);
1647        let mut y_vals = Vec::with_capacity(row_max - row_min + 1);
1648
1649        for row in row_min..=row_max {
1650            let mut sum = 0.0;
1651            for col in col_min..=col_max {
1652                sum += data[row * width as usize + col] as f64;
1653            }
1654            x_vals.push(row as f64);
1655            y_vals.push(reduce(sum, num_cols));
1656        }
1657        Ok((x_vals, y_vals))
1658    }
1659}
1660
1661fn fmt_stat(value: Option<f64>) -> String {
1662    value.map_or_else(|| "n/a".to_owned(), |value| format!("{value:.6}"))
1663}
1664
1665fn show_value_stats(ui: &mut egui::Ui, label: &str, stats: ValueStats) {
1666    ui.label(format!(
1667        "{label}: n={} finite={} min={} max={} mean={}",
1668        stats.count,
1669        stats.finite_count,
1670        fmt_stat(stats.min),
1671        fmt_stat(stats.max),
1672        fmt_stat(stats.mean)
1673    ));
1674}
1675
1676#[derive(Clone, Copy, Debug, Default)]
1677struct Bounds1D {
1678    min: f64,
1679    max: f64,
1680}
1681
1682impl Bounds1D {
1683    fn new(min: f64, max: f64) -> Option<Self> {
1684        (min.is_finite() && max.is_finite()).then(|| Self {
1685            min: min.min(max),
1686            max: min.max(max),
1687        })
1688    }
1689
1690    fn include(&mut self, other: Self) {
1691        self.min = self.min.min(other.min);
1692        self.max = self.max.max(other.max);
1693    }
1694
1695    fn as_non_degenerate(self) -> (f64, f64) {
1696        if self.max > self.min {
1697            (self.min, self.max)
1698        } else {
1699            let pad = (self.min.abs() * 0.05).max(0.5);
1700            (self.min - pad, self.max + pad)
1701        }
1702    }
1703}
1704
1705// Holds a per-extra-axis `Vec`, so it is `Clone` but not `Copy`; callers that
1706// merge bounds pass it by reference.
1707#[derive(Clone, Debug, Default)]
1708struct DataBounds {
1709    x: Option<Bounds1D>,
1710    y_left: Option<Bounds1D>,
1711    y_right: Option<Bounds1D>,
1712    /// Per-extra-axis bounds, indexed by `YAxis::Extra(n)` (parallel to
1713    /// `Plot::extra`). Grows on demand as curves bound to higher indices arrive.
1714    extra: Vec<Option<Bounds1D>>,
1715}
1716
1717impl DataBounds {
1718    fn include(&mut self, x: Bounds1D, y: Bounds1D, axis: YAxis) {
1719        include_axis(&mut self.x, x);
1720        match axis {
1721            YAxis::Left => include_axis(&mut self.y_left, y),
1722            YAxis::Right => include_axis(&mut self.y_right, y),
1723            YAxis::Extra(n) => {
1724                if self.extra.len() <= n {
1725                    self.extra.resize(n + 1, None);
1726                }
1727                include_axis(&mut self.extra[n], y);
1728            }
1729        }
1730    }
1731
1732    fn include_bounds(&mut self, other: &Self) {
1733        if let Some(x) = other.x {
1734            include_axis(&mut self.x, x);
1735        }
1736        if let Some(y) = other.y_left {
1737            include_axis(&mut self.y_left, y);
1738        }
1739        if let Some(y) = other.y_right {
1740            include_axis(&mut self.y_right, y);
1741        }
1742        for (n, slot) in other.extra.iter().enumerate() {
1743            if let Some(y) = slot {
1744                if self.extra.len() <= n {
1745                    self.extra.resize(n + 1, None);
1746                }
1747                include_axis(&mut self.extra[n], *y);
1748            }
1749        }
1750    }
1751}
1752
1753fn include_axis(slot: &mut Option<Bounds1D>, bounds: Bounds1D) {
1754    match slot {
1755        Some(existing) => existing.include(bounds),
1756        None => *slot = Some(bounds),
1757    }
1758}
1759
1760/// Map accumulated widget [`DataBounds`] to the model [`DataRange`] consumed by
1761/// [`Plot::reset_zoom_to_data_range`], padding degenerate (single-point) bounds
1762/// via [`Bounds1D::as_non_degenerate`] so each refit axis gets a non-degenerate
1763/// span. An axis with no data maps to `None`, leaving it pinned by the model's
1764/// per-axis autoscale logic (silx `PlotWidget.resetZoom` restores axes without
1765/// data). Pure (no `RenderState`/GPU) so the reset path is unit-testable.
1766fn data_range_from_bounds(bounds: &DataBounds) -> DataRange {
1767    DataRange {
1768        x: bounds.x.map(Bounds1D::as_non_degenerate),
1769        y: bounds.y_left.map(Bounds1D::as_non_degenerate),
1770        y2: bounds.y_right.map(Bounds1D::as_non_degenerate),
1771    }
1772}
1773
1774/// Per-extra-axis data bounds (non-degenerate-padded) for
1775/// [`Plot::reset_extra_axes_to`], parallel to `Plot::extra`. The model side
1776/// holds extra-axis ranges in their own `Vec`, so they ride alongside the
1777/// left/right [`DataRange`] rather than inside it.
1778fn extra_data_ranges(bounds: &DataBounds) -> Vec<Option<(f64, f64)>> {
1779    bounds
1780        .extra
1781        .iter()
1782        .map(|b| b.map(Bounds1D::as_non_degenerate))
1783        .collect()
1784}
1785
1786/// Map accumulated widget [`DataBounds`] to the model [`DataRange`] *cache*
1787/// (silx `_updateDataRange`, returned by `getDataRange`): the raw per-axis
1788/// min/max with no degenerate-span padding — a single data point reads as
1789/// `(v, v)`, matching silx (the non-degenerate span + data margins are a
1790/// refit-time concern applied by [`data_range_from_bounds`], not stored in the
1791/// cache). An axis with no data maps to `None`. Pure (no GPU) so it is
1792/// unit-testable.
1793fn raw_data_range_from_bounds(bounds: &DataBounds) -> DataRange {
1794    DataRange {
1795        x: bounds.x.map(|b| (b.min, b.max)),
1796        y: bounds.y_left.map(|b| (b.min, b.max)),
1797        y2: bounds.y_right.map(|b| (b.min, b.max)),
1798    }
1799}
1800
1801fn finite_bounds(values: &[f64]) -> Option<Bounds1D> {
1802    values
1803        .iter()
1804        .copied()
1805        .filter(|v| v.is_finite())
1806        .fold(None, |bounds, value| match bounds {
1807            Some(mut bounds) => {
1808                bounds.include(Bounds1D::new(value, value).expect("finite value"));
1809                Some(bounds)
1810            }
1811            None => Bounds1D::new(value, value),
1812        })
1813}
1814
1815fn image_bounds(image: &ImageSpec<'_>) -> Option<(Bounds1D, Bounds1D)> {
1816    let (width, height) = match image {
1817        ImageSpec {
1818            pixels: ImagePixelsSpec::Scalar { width, height, .. },
1819            ..
1820        }
1821        | ImageSpec {
1822            pixels: ImagePixelsSpec::Rgba { width, height, .. },
1823            ..
1824        } => (*width, *height),
1825    };
1826    let x0 = image.origin.0;
1827    let y0 = image.origin.1;
1828    let x1 = x0 + image.scale.0 * width as f64;
1829    let y1 = y0 + image.scale.1 * height as f64;
1830    Some((Bounds1D::new(x0, x1)?, Bounds1D::new(y0, y1)?))
1831}
1832
1833fn curve_spec_bounds(spec: &CurveSpec<'_>) -> DataBounds {
1834    let mut bounds = DataBounds::default();
1835    if let (Some(x), Some(y)) = (finite_bounds(spec.x), finite_bounds(spec.y)) {
1836        bounds.include(x, y, spec.y_axis);
1837    }
1838    bounds
1839}
1840
1841fn curve_spec_stats(spec: &CurveSpec<'_>) -> ItemStats {
1842    ItemStats::Curve(CurveStats {
1843        x: ValueStats::from_f64(spec.x),
1844        y: ValueStats::from_f64(spec.y),
1845        y_axis: spec.y_axis,
1846    })
1847}
1848
1849/// Capture a curve spec's raw data for live stats/fit consumers.
1850fn curve_spec_retained_data(spec: &CurveSpec<'_>) -> RetainedItemData {
1851    RetainedItemData::Curve {
1852        x: spec.x.to_vec(),
1853        y: spec.y.to_vec(),
1854    }
1855}
1856
1857/// Scale `color`'s alpha channel by `alpha` (clamped to `[0, 1]`), mirroring the
1858/// backend's `apply_alpha`. Delegates to
1859/// [`scale_alpha`](crate::core::color::scale_alpha), which scales the *straight*
1860/// alpha and keeps the straight RGB — reading the premultiplied `Color32`
1861/// accessors and re-wrapping would double-premultiply the RGB for translucent
1862/// curve colors.
1863fn apply_curve_alpha(color: Color32, alpha: f32) -> Color32 {
1864    crate::core::color::scale_alpha(color, alpha)
1865}
1866
1867/// Multiply per-point alpha into each color's alpha channel in place, mirroring
1868/// silx `Scatter.__applyColormapToData` (`rgbacolors[:, -1] *= __alpha`,
1869/// scatter.py:534-535): the colormap RGBA alpha is scaled by the per-point
1870/// alpha *before* the global item alpha multiplies on top in-shader (the
1871/// three-stage `colormap.alpha * per_point.alpha * global.alpha`).
1872///
1873/// Each `alpha` entry is clamped to `[0, 1]` (silx `setData` clamps the alpha
1874/// array, scatter.py:1051-1060). Composition runs over `min(colors, alpha)`
1875/// entries: a shorter `alpha` leaves the trailing colors unchanged, and extra
1876/// `alpha` entries past the colors are ignored — neither panics. `alpha`
1877/// matching the point count (the silx contract) scales every color.
1878///
1879/// silx operates on *straight* (un-premultiplied) RGBA, so this scales the
1880/// straight alpha (`Color32::to_srgba_unmultiplied`) and rebuilds via
1881/// `from_rgba_unmultiplied`, leaving the straight RGB unchanged. (Reading the
1882/// premultiplied `Color32::a/r/g/b` accessors and re-wrapping would
1883/// double-premultiply the RGB.)
1884fn compose_per_point_alpha(colors: &mut [Color32], alpha: &[f64]) {
1885    for (color, &a) in colors.iter_mut().zip(alpha) {
1886        let [r, g, b, sa] = color.to_srgba_unmultiplied();
1887        let sa = ((a.clamp(0.0, 1.0) as f32) * (sa as f32)).round() as u8;
1888        *color = Color32::from_rgba_unmultiplied(r, g, b, sa);
1889    }
1890}
1891
1892/// Map each per-point `value` through `colormap` to its RGBA color, optionally
1893/// scaling each color's alpha by the matching `alpha` entry, mirroring silx
1894/// `Scatter.__applyColormapToData` (scatter.py:526-535).
1895///
1896/// silx shares `__applyColormapToData` between the `POINTS` and `SOLID`
1897/// visualizations, so both [`ScatterView`] render arms call this single helper:
1898/// the value is normalized through the colormap into its 256-entry LUT, then —
1899/// when a per-point `alpha` array is present — the per-point alpha multiplies
1900/// the colormap RGBA alpha (stage 2 of the three-stage `colormap.alpha *
1901/// per_point.alpha * global.alpha`; see [`compose_per_point_alpha`]). Factored
1902/// out so the two arms cannot drift and the mapping is unit-testable without a
1903/// GPU backend.
1904fn point_colors(values: &[f64], colormap: &Colormap, alpha: Option<&[f64]>) -> Vec<Color32> {
1905    let mut colors: Vec<Color32> = values
1906        .iter()
1907        .map(|&v| {
1908            let t = colormap.normalize(v);
1909            let idx = (t * 255.0).clamp(0.0, 255.0) as usize;
1910            let [r, g, b, a] = colormap.lut[idx];
1911            Color32::from_rgba_unmultiplied(r, g, b, a)
1912        })
1913        .collect();
1914    if let Some(alpha) = alpha {
1915        compose_per_point_alpha(&mut colors, alpha);
1916    }
1917    colors
1918}
1919
1920/// Clamp each per-point alpha entry to `[0, 1]`, mirroring silx
1921/// `Scatter.setData` (`numpy.clip(alpha, 0.0, 1.0)`, scatter.py:1058-1059).
1922/// Stored at the setter so the retained array is already in range; the
1923/// composition in [`compose_per_point_alpha`] clamps again defensively.
1924fn clamp_alpha(mut alpha: Vec<f64>) -> Vec<f64> {
1925    for a in &mut alpha {
1926        *a = a.clamp(0.0, 1.0);
1927    }
1928    alpha
1929}
1930
1931/// Build a [`CurveData`] from a [`CurveSpec`], mirroring the backend's
1932/// `curve_data_from_spec`. Retained in the [`ItemRecord`] so the curve-style
1933/// cycle action can clone, edit the line style, and re-apply the full curve
1934/// (preserving color, symbol, width, error bars, fill). Owning the conversion
1935/// here keeps the retained copy faithful to what the backend renders.
1936fn curve_data_from_spec_hl(spec: &CurveSpec<'_>) -> CurveData {
1937    let color = match &spec.color {
1938        CurveColor::Uniform(color) => apply_curve_alpha(*color, spec.alpha),
1939        CurveColor::PerVertex(colors) => colors
1940            .first()
1941            .copied()
1942            .map(|color| apply_curve_alpha(color, spec.alpha))
1943            .unwrap_or(Color32::WHITE),
1944    };
1945    let mut curve = CurveData::new(spec.x.to_vec(), spec.y.to_vec(), color)
1946        .with_width(spec.line_width)
1947        .with_line_style(spec.line_style.clone())
1948        .with_marker_size(spec.symbol_size)
1949        .with_y_axis(spec.y_axis);
1950    if let CurveColor::PerVertex(colors) = &spec.color {
1951        curve = curve.with_colors(
1952            colors
1953                .iter()
1954                .copied()
1955                .map(|color| apply_curve_alpha(color, spec.alpha))
1956                .collect(),
1957        );
1958    }
1959    if let Some(gap_color) = spec.gap_color {
1960        curve = curve.with_gap_color(apply_curve_alpha(gap_color, spec.alpha));
1961    }
1962    if let Some(symbol) = spec.symbol {
1963        curve = curve.with_symbol(symbol);
1964    }
1965    if let Some(error) = &spec.x_error {
1966        curve = curve.with_x_error(error.clone());
1967    }
1968    if let Some(error) = &spec.y_error {
1969        curve = curve.with_y_error(error.clone());
1970    }
1971    if spec.fill {
1972        curve = curve.with_fill(spec.baseline.clone());
1973    }
1974    curve
1975}
1976
1977/// Borrow a [`RetainedItemData`] as a [`StatsInput`] for a live
1978/// [`StatsWidget`] / fit feed (silx `StatsWidget` per-item data). Split out so
1979/// the data→input bridge is unit-testable without a GPU backend.
1980///
1981/// [`StatsInput`]: crate::widget::stats_widget::StatsInput
1982fn retained_data_to_stats_input(
1983    data: &RetainedItemData,
1984) -> crate::widget::stats_widget::StatsInput<'_> {
1985    use crate::widget::stats_widget::StatsInput;
1986    match data {
1987        RetainedItemData::Curve { x, y } => StatsInput::Curve { xs: x, ys: y },
1988        RetainedItemData::Image {
1989            data,
1990            width,
1991            height,
1992            origin,
1993            scale,
1994            ..
1995        } => StatsInput::Image {
1996            data,
1997            width: *width,
1998            height: *height,
1999            origin: *origin,
2000            scale: *scale,
2001        },
2002    }
2003}
2004
2005/// Clone `base` with its value limits replaced by the [`AutoscaleMode`] range
2006/// over `pixels` (NaN-ignoring), for a raw-pixel autoscale (silx
2007/// `ColormapDialog` Stddev3 / Percentile autoscale, ColormapDialog.py:450-480).
2008///
2009/// The percentile pair comes from the base colormap's
2010/// [`autoscale_percentiles`](Colormap::autoscale_percentiles) (silx
2011/// `Colormap._percentiles`); [`AutoscaleMode::Stddev3`] ignores it. The LUT,
2012/// normalization, gamma, and NaN color are preserved — only `vmin`/`vmax`
2013/// change. Split out so the autoscale computation is unit-testable without a GPU
2014/// backend.
2015fn autoscaled_colormap(base: &Colormap, mode: AutoscaleMode, pixels: &[f64]) -> Colormap {
2016    let (vmin, vmax) = mode.range(pixels, base.autoscale_percentiles);
2017    let mut cm = base.clone();
2018    cm.vmin = vmin;
2019    cm.vmax = vmax;
2020    cm
2021}
2022
2023/// Borrow a [`RetainedItemData`]'s curve `(x, y)` arrays for a live
2024/// [`FitWidget`] target (silx `FitWidget.setData`), or `None` when the item is
2025/// not a curve. Split out so the data→fit feed is unit-testable without a GPU
2026/// backend.
2027///
2028/// [`FitWidget`]: crate::widget::fit_widget::FitWidget
2029fn retained_curve_xy(data: &RetainedItemData) -> Option<(&[f64], &[f64])> {
2030    match data {
2031        RetainedItemData::Curve { x, y } => Some((x, y)),
2032        RetainedItemData::Image { .. } => None,
2033    }
2034}
2035
2036/// Build one [`RoiStatsRow`] per ROI by reducing the active item's retained
2037/// `data` inside each ROI, for a [`RoiStatsWidget`] (silx `ROIStatsWidget`
2038/// table). An image is reduced with the proper per-pixel `roi.contains` test +
2039/// integral ([`image_roi_stats`]); a curve over its `x`-span
2040/// ([`curve_roi_stats`]). The row label is the ROI's name, or `ROI {index}`
2041/// when the name is empty. Pure (no GPU), so the row building is unit-testable.
2042///
2043/// [`RoiStatsRow`]: crate::widget::roi_stats_widget::RoiStatsRow
2044/// [`RoiStatsWidget`]: crate::widget::roi_stats_widget::RoiStatsWidget
2045/// [`image_roi_stats`]: crate::widget::roi_stats::image_roi_stats
2046/// [`curve_roi_stats`]: crate::widget::roi_stats::curve_roi_stats
2047fn roi_stats_rows(
2048    rois: &[ManagedRoi],
2049    data: &RetainedItemData,
2050) -> Vec<crate::widget::roi_stats_widget::RoiStatsRow> {
2051    use crate::widget::roi_stats::{curve_roi_stats, image_roi_stats};
2052    use crate::widget::roi_stats_widget::RoiStatsRow;
2053
2054    rois.iter()
2055        .enumerate()
2056        .map(|(index, managed)| {
2057            let stats = match data {
2058                RetainedItemData::Image {
2059                    data,
2060                    width,
2061                    height,
2062                    origin,
2063                    scale,
2064                    ..
2065                } => {
2066                    // image_roi_stats reduces f32 pixels; the retained pixels are
2067                    // f64 narrowed from the originally-f32 image, so the cast back
2068                    // to f32 is lossless.
2069                    let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
2070                    image_roi_stats(
2071                        &managed.roi,
2072                        &pixels,
2073                        *width,
2074                        *height,
2075                        [origin.0, origin.1],
2076                        [scale.0, scale.1],
2077                    )
2078                }
2079                RetainedItemData::Curve { x, y } => curve_roi_stats(&managed.roi, x, y),
2080            };
2081            let label = if managed.name.is_empty() {
2082                format!("ROI {index}")
2083            } else {
2084                managed.name.clone()
2085            };
2086            RoiStatsRow { label, stats }
2087        })
2088        .collect()
2089}
2090
2091/// One row per curve ROI (those with an `x`-span) over the active curve's
2092/// `(x, y)`, reduced via [`curve_roi_counts`] (silx `CurvesROIWidget`). ROIs with
2093/// no `x`-span (e.g. `HRange`) are not curve ROIs and are skipped; the surviving
2094/// rows keep each ROI's original index in its `ROI {index}` fallback label so it
2095/// stays traceable to [`PlotWidget::rois`].
2096///
2097/// [`curve_roi_counts`]: crate::widget::roi_stats::curve_roi_counts
2098fn curve_roi_rows(
2099    rois: &[ManagedRoi],
2100    x: &[f64],
2101    y: &[f64],
2102) -> Vec<crate::widget::curves_roi_widget::CurveRoiRow> {
2103    use crate::widget::curves_roi_widget::CurveRoiRow;
2104    use crate::widget::roi_stats::{curve_roi_counts, roi_x_span};
2105
2106    rois.iter()
2107        .enumerate()
2108        .filter_map(|(index, managed)| {
2109            let (from, to) = roi_x_span(&managed.roi)?;
2110            let counts = curve_roi_counts(&managed.roi, x, y)?;
2111            let label = if managed.name.is_empty() {
2112                format!("ROI {index}")
2113            } else {
2114                managed.name.clone()
2115            };
2116            Some(CurveRoiRow {
2117                label,
2118                from,
2119                to,
2120                counts,
2121            })
2122        })
2123        .collect()
2124}
2125
2126/// Capture a scalar image spec's raw pixels and geometry for live consumers, or
2127/// `None` for an RGBA image (no scalar field to retain).
2128fn image_spec_retained_data(spec: &ImageSpec<'_>) -> Option<RetainedItemData> {
2129    match &spec.pixels {
2130        ImagePixelsSpec::Scalar {
2131            width,
2132            height,
2133            data,
2134            colormap,
2135        } => Some(RetainedItemData::Image {
2136            data: data.iter().map(|&v| v as f64).collect(),
2137            width: *width as usize,
2138            height: *height as usize,
2139            origin: spec.origin,
2140            scale: spec.scale,
2141            colormap: colormap.clone(),
2142            alpha: spec.alpha,
2143            interpolation: spec.interpolation,
2144            aggregation: spec.aggregation,
2145            aggregation_block: spec.aggregation_block,
2146            alpha_map: spec.alpha_map.map(|a| a.to_vec()),
2147        }),
2148        ImagePixelsSpec::Rgba { .. } => None,
2149    }
2150}
2151
2152fn image_spec_bounds(spec: &ImageSpec<'_>) -> DataBounds {
2153    let mut bounds = DataBounds::default();
2154    if let Some((x, y)) = image_bounds(spec) {
2155        bounds.include(x, y, YAxis::Left);
2156    }
2157    bounds
2158}
2159
2160fn image_spec_stats(spec: &ImageSpec<'_>) -> ItemStats {
2161    let (width, height, scalar) = match &spec.pixels {
2162        ImagePixelsSpec::Scalar {
2163            width,
2164            height,
2165            data,
2166            ..
2167        } => (*width, *height, Some(ValueStats::from_f32(data))),
2168        ImagePixelsSpec::Rgba { width, height, .. } => (*width, *height, None),
2169    };
2170    ItemStats::Image(ImageStats {
2171        width,
2172        height,
2173        pixel_count: expected_image_len(width, height),
2174        scalar,
2175    })
2176}
2177
2178fn rgba_to_color32(rgba: [u8; 4]) -> Color32 {
2179    Color32::from_rgba_unmultiplied(rgba[0], rgba[1], rgba[2], rgba[3])
2180}
2181
2182fn fallback_legend_color(kind: PlotItemKind) -> Color32 {
2183    match kind {
2184        PlotItemKind::Curve => Color32::LIGHT_BLUE,
2185        PlotItemKind::Histogram => Color32::LIGHT_GREEN,
2186        PlotItemKind::Scatter => Color32::LIGHT_BLUE,
2187        PlotItemKind::Image => Color32::GRAY,
2188        PlotItemKind::Mask => Color32::from_rgba_unmultiplied(255, 80, 80, 160),
2189        PlotItemKind::Triangles => Color32::LIGHT_BLUE,
2190        PlotItemKind::Shape => Color32::WHITE,
2191        PlotItemKind::Marker => Color32::YELLOW,
2192    }
2193}
2194
2195fn curve_spec_legend_visual(spec: &CurveSpec<'_>, kind: PlotItemKind) -> LegendVisual {
2196    let color = match spec.color {
2197        CurveColor::Uniform(color) => color,
2198        CurveColor::PerVertex(colors) => colors
2199            .first()
2200            .copied()
2201            .unwrap_or_else(|| fallback_legend_color(kind)),
2202    };
2203    LegendVisual::curve(color, spec.line_style.clone(), spec.symbol)
2204}
2205
2206fn image_spec_legend_visual(spec: &ImageSpec<'_>, kind: PlotItemKind) -> LegendVisual {
2207    match &spec.pixels {
2208        ImagePixelsSpec::Scalar { colormap, .. } => LegendVisual::with_secondary(
2209            rgba_to_color32(colormap.lut[48]),
2210            rgba_to_color32(colormap.lut[208]),
2211        ),
2212        ImagePixelsSpec::Rgba { data, .. } => {
2213            let color = data
2214                .iter()
2215                .copied()
2216                .find(|rgba| rgba[3] != 0)
2217                .map(rgba_to_color32)
2218                .unwrap_or_else(|| fallback_legend_color(kind));
2219            LegendVisual::new(color)
2220        }
2221    }
2222}
2223
2224fn triangle_spec_legend_visual(spec: &TriangleSpec<'_>) -> LegendVisual {
2225    LegendVisual::new(
2226        spec.colors
2227            .first()
2228            .copied()
2229            .unwrap_or_else(|| fallback_legend_color(PlotItemKind::Triangles)),
2230    )
2231}
2232
2233fn shape_spec_legend_visual(spec: &ShapeSpec<'_>) -> LegendVisual {
2234    LegendVisual::new(spec.color)
2235}
2236
2237fn marker_spec_legend_visual(spec: &MarkerSpec<'_>) -> LegendVisual {
2238    LegendVisual::new(spec.color)
2239}
2240
2241fn xy_bounds(x: &[f64], y: &[f64], axis: YAxis) -> DataBounds {
2242    let mut bounds = DataBounds::default();
2243    if let (Some(x), Some(y)) = (finite_bounds(x), finite_bounds(y)) {
2244        bounds.include(x, y, axis);
2245    }
2246    bounds
2247}
2248
2249/// Route an active curve's per-axis labels onto the plot's active-label
2250/// overrides (silx `_setActiveItem` → `Axis._setCurrentLabel`): the X label
2251/// always drives the X axis; the Y label drives the left Y axis or the right
2252/// (y2) axis by the curve's `y_axis`. Returns
2253/// `(active_x, active_y_left, active_y2)` for [`Plot::active_x_label`] /
2254/// `active_y_label` / `active_y2_label`. Pure and headless-testable.
2255fn active_axis_label_overrides(
2256    x_label: Option<&str>,
2257    y_label: Option<&str>,
2258    y_axis: YAxis,
2259) -> (Option<String>, Option<String>, Option<String>) {
2260    let x = x_label.map(ToOwned::to_owned);
2261    let y = y_label.map(ToOwned::to_owned);
2262    match y_axis {
2263        YAxis::Left => (x, y, None),
2264        YAxis::Right => (x, None, y),
2265        // Extra axes have no active-label override slot on `Plot`; their label is
2266        // set explicitly via `set_graph_extra_y_label`, so an extra-bound active
2267        // curve contributes no left/right override.
2268        YAxis::Extra(_) => (x, None, None),
2269    }
2270}
2271
2272fn curve_spec_from_data(curve: &CurveData) -> CurveSpec<'_> {
2273    CurveSpec {
2274        x: &curve.x,
2275        y: &curve.y,
2276        color: curve
2277            .colors
2278            .as_deref()
2279            .map_or(CurveColor::Uniform(curve.color), CurveColor::PerVertex),
2280        gap_color: curve.gap_color,
2281        symbol: curve.symbol,
2282        line_width: curve.width,
2283        line_style: curve.line_style.clone(),
2284        y_axis: curve.y_axis,
2285        x_error: curve.x_error.clone(),
2286        y_error: curve.y_error.clone(),
2287        fill: curve.fill,
2288        alpha: 1.0,
2289        symbol_size: curve.marker_size,
2290        baseline: curve.baseline.clone(),
2291        // Per-curve axis labels live in the high-level `ItemRecord` (alongside
2292        // `legend`), not in `CurveData`, so a data round-trip carries none; the
2293        // record's labels are preserved across this re-application path.
2294        x_label: None,
2295        y_label: None,
2296    }
2297}
2298
2299fn image_spec_from_data(image: &ImageData) -> ImageSpec<'_> {
2300    match &image.pixels {
2301        ImagePixels::Scalar { data, colormap } => ImageSpec {
2302            pixels: ImagePixelsSpec::Scalar {
2303                width: image.width,
2304                height: image.height,
2305                data,
2306                colormap: colormap.clone(),
2307            },
2308            origin: image.origin,
2309            scale: image.scale,
2310            alpha: image.alpha,
2311            interpolation: image.interpolation,
2312            // `image` already holds the (possibly aggregated) data, so the
2313            // round-tripped spec must not re-aggregate.
2314            aggregation: AggregationMode::None,
2315            aggregation_block: (1, 1),
2316            // The alpha map (if any) is already at the displayed resolution on
2317            // the `ImageData`, so it carries through 1:1 with the data.
2318            alpha_map: image.alpha_map.as_deref(),
2319        },
2320        ImagePixels::Rgba { data } => ImageSpec {
2321            pixels: ImagePixelsSpec::Rgba {
2322                width: image.width,
2323                height: image.height,
2324                data,
2325            },
2326            origin: image.origin,
2327            scale: image.scale,
2328            alpha: image.alpha,
2329            interpolation: image.interpolation,
2330            aggregation: AggregationMode::None,
2331            aggregation_block: (1, 1),
2332            // Per-pixel alpha is scalar-only (silx `ImageData.setAlphaData`).
2333            alpha_map: None,
2334        },
2335    }
2336}
2337
2338/// Apply an optional pixel mask to a scalar field before upload, returning the
2339/// masked row-major data (silx `items/image.py` `getValueData`: masked pixels →
2340/// `NaN`).
2341///
2342/// Validates that `data.len() == width * height` and that `mask` describes the
2343/// same `width × height` shape, returning [`PlotDataError::ImageDataLength`] on
2344/// a mismatch; on success every pixel flagged by `mask` is `f32::NAN` and the
2345/// rest pass through unchanged. Split out from
2346/// [`Plot2D::try_add_masked_image`] so the pre-upload transform is unit-testable
2347/// without a GPU backend.
2348fn apply_image_mask(
2349    width: u32,
2350    height: u32,
2351    data: &[f32],
2352    mask: &ScalarMask,
2353) -> Result<Vec<f32>, PlotDataError> {
2354    let expected = (width as usize).saturating_mul(height as usize);
2355    if data.len() != expected {
2356        return Err(PlotDataError::ImageDataLength {
2357            expected,
2358            actual: data.len(),
2359        });
2360    }
2361    if mask.width() != width as usize || mask.height() != height as usize {
2362        return Err(PlotDataError::ImageDataLength {
2363            expected,
2364            actual: mask.width().saturating_mul(mask.height()),
2365        });
2366    }
2367    Ok(mask.apply(data))
2368}
2369
2370fn triangle_spec_from_data(triangles: &Triangles) -> TriangleSpec<'_> {
2371    TriangleSpec {
2372        x: &triangles.x,
2373        y: &triangles.y,
2374        triangles: &triangles.indices,
2375        colors: &triangles.colors,
2376        alpha: triangles.alpha,
2377    }
2378}
2379
2380fn shape_spec_from_data(shape: &Shape) -> ShapeSpec<'_> {
2381    ShapeSpec {
2382        x: &shape.x,
2383        y: &shape.y,
2384        kind: shape.kind,
2385        color: shape.color,
2386        fill: shape.fill,
2387        overlay: false,
2388        line_style: shape.line_style.clone(),
2389        line_width: shape.line_width,
2390        gap_color: shape.gap_color,
2391    }
2392}
2393
2394fn marker_spec_from_data(marker: &Marker) -> MarkerSpec<'_> {
2395    let (x, y, symbol, symbol_size) = match marker.kind {
2396        MarkerKind::Point { x, y, symbol, size } => (Some(x), Some(y), Some(symbol), size),
2397        MarkerKind::VLine { x } => (Some(x), None, None, 0.0),
2398        MarkerKind::HLine { y } => (None, Some(y), None, 0.0),
2399    };
2400    MarkerSpec {
2401        x,
2402        y,
2403        text: marker.text.as_deref(),
2404        color: marker.color,
2405        symbol,
2406        symbol_size,
2407        line_style: marker.line_style.clone(),
2408        line_width: marker.line_width,
2409        y_axis: marker.y_axis,
2410        bg_color: marker.bgcolor,
2411        is_draggable: marker.is_draggable,
2412        constraint: marker.constraint,
2413    }
2414}
2415
2416/// Raw item data retained alongside an [`ItemRecord`] so live consumers (a
2417/// [`StatsWidget`], a [`FitWidget`], a raw-pixel autoscale) can read the active
2418/// item's data without the caller re-supplying it. Only scalar curves and
2419/// scalar images are retained; RGBA images, triangles, shapes, and markers have
2420/// no retained data.
2421#[derive(Clone, Debug)]
2422enum RetainedItemData {
2423    /// A curve's `(x, y)` arrays.
2424    Curve { x: Vec<f64>, y: Vec<f64> },
2425    /// A scalar image's row-major pixels (as `f64`), its geometry, its
2426    /// colormap, and its global opacity (retained so a raw-pixel autoscale can
2427    /// re-upload the image with new value limits without depending on transient
2428    /// render state). The colormap is boxed: its 256-entry LUT would otherwise
2429    /// dominate the enum's size and bloat every `Curve` variant too.
2430    Image {
2431        data: Vec<f64>,
2432        width: usize,
2433        height: usize,
2434        origin: (f64, f64),
2435        scale: (f64, f64),
2436        colormap: Box<Colormap>,
2437        /// Global image opacity in `[0, 1]` (silx image `alpha`, `AlphaMixIn`).
2438        /// Retained so every re-upload path (autoscale, level edit, median
2439        /// filter, the [`ActiveImageAlphaSlider`]/[`NamedItemAlphaSlider`]
2440        /// bindings) preserves the current alpha instead of resetting it to the
2441        /// `ImageSpec::scalar` default of `1.0`.
2442        ///
2443        /// [`ActiveImageAlphaSlider`]: crate::widget::alpha_slider::ActiveImageAlphaSlider
2444        /// [`NamedItemAlphaSlider`]: crate::widget::alpha_slider::NamedItemAlphaSlider
2445        alpha: f32,
2446        /// Data-to-screen interpolation (silx image `interpolation`). Retained
2447        /// for the same reason as `alpha`: a re-upload rebuilds the spec via
2448        /// [`ImageSpec::scalar`], which would otherwise reset this to the
2449        /// [`Nearest`](InterpolationMode::Nearest) default.
2450        interpolation: InterpolationMode,
2451        /// Block aggregation mode (silx `ImageDataAggregated`), retained across
2452        /// re-uploads alongside `aggregation_block`.
2453        aggregation: AggregationMode,
2454        /// Per-axis aggregation block factors `(block_x, block_y)`, retained so a
2455        /// re-upload preserves the level-of-detail reduction.
2456        aggregation_block: (u32, u32),
2457        /// Per-pixel alpha map (silx `ImageData.getAlphaData`), row-major at the
2458        /// displayed resolution, retained for the same reason as `alpha`: a
2459        /// re-upload rebuilds the spec via [`ImageSpec::scalar`], whose builder
2460        /// defaults it to `None`, so without retaining it a level edit / autoscale
2461        /// / median filter would silently drop the alpha overlay. `None` when the
2462        /// image has no per-pixel alpha.
2463        alpha_map: Option<Vec<f32>>,
2464    },
2465}
2466
2467/// The display attributes of a retained scalar image that a re-upload must
2468/// carry over — geometry, opacity, interpolation, aggregation, and the
2469/// per-pixel alpha map. Bundled so that [`apply`](Self::apply) is the *single
2470/// owner* of restoring them onto a freshly built [`ImageSpec`]: every re-upload
2471/// path (level edit, autoscale, median filter, the alpha-slider bindings)
2472/// rebuilds the spec via [`ImageSpec::scalar`] — whose builder resets these to
2473/// defaults — and then calls `apply`, so a new attribute is preserved
2474/// everywhere by adding it here once, and a new re-upload path cannot silently
2475/// drop them.
2476///
2477/// Owns the alpha map by value (so it is not `Copy`): the rebuilt
2478/// [`ImageSpec`] borrows it, so the attrs must outlive the spec — which they do,
2479/// being bound before it on every re-upload path.
2480#[derive(Clone, Debug)]
2481struct ImageDisplayAttrs {
2482    origin: (f64, f64),
2483    scale: (f64, f64),
2484    alpha: f32,
2485    interpolation: InterpolationMode,
2486    aggregation: AggregationMode,
2487    aggregation_block: (u32, u32),
2488    alpha_map: Option<Vec<f32>>,
2489}
2490
2491impl ImageDisplayAttrs {
2492    /// Restore every carried-over display attribute onto a rebuilt scalar
2493    /// [`ImageSpec`] (the single owner of the retained→spec restore). Borrows
2494    /// `self` for the spec's data lifetime so the spec can reference the owned
2495    /// alpha map.
2496    fn apply<'a>(&'a self, spec: &mut ImageSpec<'a>) {
2497        spec.origin = self.origin;
2498        spec.scale = self.scale;
2499        spec.alpha = self.alpha;
2500        spec.interpolation = self.interpolation;
2501        spec.aggregation = self.aggregation;
2502        spec.aggregation_block = self.aggregation_block;
2503        spec.alpha_map = self.alpha_map.as_deref();
2504    }
2505}
2506
2507impl RetainedItemData {
2508    /// The display attributes of a retained scalar image, or `None` for a curve
2509    /// (the only other retained variant). Captured by every re-upload path and
2510    /// restored via [`ImageDisplayAttrs::apply`].
2511    fn image_display_attrs(&self) -> Option<ImageDisplayAttrs> {
2512        match self {
2513            RetainedItemData::Image {
2514                origin,
2515                scale,
2516                alpha,
2517                interpolation,
2518                aggregation,
2519                aggregation_block,
2520                alpha_map,
2521                ..
2522            } => Some(ImageDisplayAttrs {
2523                origin: *origin,
2524                scale: *scale,
2525                alpha: *alpha,
2526                interpolation: *interpolation,
2527                aggregation: *aggregation,
2528                aggregation_block: *aggregation_block,
2529                alpha_map: alpha_map.clone(),
2530            }),
2531            RetainedItemData::Curve { .. } => None,
2532        }
2533    }
2534}
2535
2536#[derive(Clone, Debug)]
2537struct ItemRecord {
2538    handle: ItemHandle,
2539    kind: PlotItemKind,
2540    bounds: DataBounds,
2541    legend: Option<String>,
2542    /// Per-curve X-axis label shown while this item is the active curve (silx
2543    /// `Curve.getXLabel`). Stored here (not in `CurveData`) so it is preserved
2544    /// across style/highlight re-applications, exactly like `legend`. `None`
2545    /// keeps the graph's default X label active.
2546    x_label: Option<String>,
2547    /// Per-curve Y-axis label shown while this item is the active curve (silx
2548    /// `Curve.getYLabel`), routed to the left or right axis by the curve's
2549    /// `y_axis`. See [`Self::x_label`].
2550    y_label: Option<String>,
2551    stats: Option<ItemStats>,
2552    visual: LegendVisual,
2553    /// Retained raw data for live stats/fit consumers; `None` for items whose
2554    /// data is not retained (RGBA images, triangles, shapes, markers).
2555    data: Option<RetainedItemData>,
2556    /// Full retained [`CurveData`] (data + style) of a curve-like item, so the
2557    /// curve-style cycle action (silx `CurveStyleAction`) can clone it, change
2558    /// the line style, and re-apply without losing color/symbol/error bars.
2559    /// `None` for non-curve items.
2560    curve_data: Option<CurveData>,
2561    /// UI-only restore cache for the legend "Points" toggle (silx checkable
2562    /// `Points` action). When the symbol is hidden, the previously visible
2563    /// [`Symbol`] is stashed here so toggling back on restores the *same*
2564    /// variant losslessly instead of a default. NOT a source of truth: no
2565    /// render/bounds/legend-visual path may read it — what is drawn is decided
2566    /// solely by [`CurveData::symbol`]. `None` means "nothing stashed".
2567    hidden_symbol: Option<Symbol>,
2568    /// UI-only restore cache for the legend "Lines" toggle (silx checkable
2569    /// `Lines` action). When the line is hidden, the previously visible
2570    /// [`LineStyle`] is stashed here so toggling back on restores the *same*
2571    /// style (e.g. `Dashed`, not just `Solid`). NOT a source of truth: no
2572    /// render/bounds/legend-visual path may read it — what is drawn is decided
2573    /// solely by [`CurveData::line_style`]. `None` means "nothing stashed".
2574    hidden_line_style: Option<LineStyle>,
2575}
2576
2577#[derive(Clone, Debug)]
2578struct LegendVisual {
2579    color: Color32,
2580    secondary: Option<Color32>,
2581    /// Curve line style, so the legend icon shows dashed / dotted / solid /
2582    /// none exactly as the curve draws (silx `LegendIcon.setLineStyle`).
2583    /// Non-curve kinds leave this at [`LineStyle::Solid`]; their swatch branches
2584    /// ignore it.
2585    line_style: LineStyle,
2586    /// Curve marker symbol drawn at the icon center (silx
2587    /// `LegendIcon.setSymbol`). `None` means no marker, matching a curve created
2588    /// with `symbol: None`.
2589    symbol: Option<Symbol>,
2590}
2591
2592const LEGEND_ROW_HEIGHT: f32 = 24.0;
2593const LEGEND_SWATCH_WIDTH: f32 = 54.0;
2594const LEGEND_CHECK_WIDTH: f32 = 22.0;
2595const LEGEND_ROW_MIN_WIDTH: f32 = 1.0;
2596
2597impl LegendVisual {
2598    fn new(color: Color32) -> Self {
2599        Self {
2600            color,
2601            secondary: None,
2602            line_style: LineStyle::Solid,
2603            symbol: None,
2604        }
2605    }
2606
2607    fn with_secondary(color: Color32, secondary: Color32) -> Self {
2608        Self {
2609            color,
2610            secondary: Some(secondary),
2611            line_style: LineStyle::Solid,
2612            symbol: None,
2613        }
2614    }
2615
2616    /// Curve icon carrying the curve's line style and marker symbol (silx
2617    /// `LegendIcon` built from the curve's `CurveStyle`).
2618    fn curve(color: Color32, line_style: LineStyle, symbol: Option<Symbol>) -> Self {
2619        Self {
2620            color,
2621            secondary: None,
2622            line_style,
2623            symbol,
2624        }
2625    }
2626}
2627
2628fn legend_row_width(available_width: f32) -> f32 {
2629    available_width.max(LEGEND_ROW_MIN_WIDTH)
2630}
2631
2632/// Default symbol restored when "Points" is toggled on for a curve that never
2633/// had a visible symbol cached (e.g. it was created with `symbol: None`). silx
2634/// has no recorded prior symbol either; a small filled point is the neutral
2635/// choice. Used only as the empty-cache fallback in [`set_symbol_visibility`].
2636const DEFAULT_RESTORE_SYMBOL: Symbol = Symbol::Point;
2637
2638/// Default line style restored when "Lines" is toggled on for a curve that
2639/// never had a visible line cached (e.g. it was created with
2640/// `line_style: LineStyle::None`). A solid line is the neutral default; used
2641/// only as the empty-cache fallback in [`set_line_visibility`].
2642const DEFAULT_RESTORE_LINE_STYLE: LineStyle = LineStyle::Solid;
2643
2644/// Pure transform for the legend "Points" checkable toggle (silx
2645/// `togglePointsAction`). Returns the new [`CurveData::symbol`] value and
2646/// maintains a lossless restore `cache` so a hide→show round-trip restores the
2647/// exact prior [`Symbol`] variant, not a default.
2648///
2649/// - hide (`visible == false`) when currently visible: stash `current` into
2650///   `cache`, return `None`.
2651/// - show (`visible == true`) when currently hidden: take from `cache`
2652///   (falling back to [`DEFAULT_RESTORE_SYMBOL`] only if the cache is empty),
2653///   return `Some(symbol)`.
2654/// - no-op cases (show-when-visible, hide-when-hidden) return `current`
2655///   unchanged and never clobber `cache`.
2656///
2657/// The returned value is the only thing the caller writes to `CurveData`; the
2658/// `cache` is UI memory and must not be read by any render/bounds/legend path.
2659fn set_symbol_visibility(
2660    current: Option<Symbol>,
2661    visible: bool,
2662    cache: &mut Option<Symbol>,
2663) -> Option<Symbol> {
2664    match (visible, current) {
2665        // Show while already visible: no-op, keep the cache untouched.
2666        (true, Some(symbol)) => Some(symbol),
2667        // Show while hidden: restore the stashed symbol, or the default if the
2668        // curve was created hidden (empty cache). Consume the cache entry.
2669        (true, None) => Some(cache.take().unwrap_or(DEFAULT_RESTORE_SYMBOL)),
2670        // Hide while visible: stash the current symbol for a lossless restore.
2671        (false, Some(symbol)) => {
2672            *cache = Some(symbol);
2673            None
2674        }
2675        // Hide while already hidden: no-op, do not clobber a prior stash.
2676        (false, None) => None,
2677    }
2678}
2679
2680/// Pure transform for the legend "Lines" checkable toggle (silx
2681/// `toggleLinesAction`). Returns the new [`CurveData::line_style`] value and
2682/// maintains a lossless restore `cache` so a hide→show round-trip restores the
2683/// exact prior [`LineStyle`] (e.g. `Dashed`), not a default.
2684///
2685/// - hide (`visible == false`) when currently drawing a line: stash `current`
2686///   into `cache`, return [`LineStyle::None`].
2687/// - show (`visible == true`) when currently hidden: take from `cache`
2688///   (falling back to [`DEFAULT_RESTORE_LINE_STYLE`] only if the cache is
2689///   empty), return that style.
2690/// - no-op cases (show-when-drawing, hide-when-hidden) return `current`
2691///   unchanged and never clobber `cache`.
2692///
2693/// The returned value is the only thing the caller writes to `CurveData`; the
2694/// `cache` is UI memory and must not be read by any render/bounds/legend path.
2695fn set_line_visibility(
2696    current: LineStyle,
2697    visible: bool,
2698    cache: &mut Option<LineStyle>,
2699) -> LineStyle {
2700    match (visible, current.draws_line()) {
2701        // Show while already drawing a line: no-op, keep the cache untouched.
2702        (true, true) => current,
2703        // Show while hidden: restore the stashed style, or the default if the
2704        // curve was created with no line (empty cache). Consume the cache.
2705        (true, false) => cache.take().unwrap_or(DEFAULT_RESTORE_LINE_STYLE),
2706        // Hide while drawing a line: stash the current style for restore.
2707        (false, true) => {
2708            *cache = Some(current);
2709            LineStyle::None
2710        }
2711        // Hide while already hidden: no-op, do not clobber a prior stash.
2712        (false, false) => current,
2713    }
2714}
2715
2716/// Per-field override style for the active-curve highlight (silx
2717/// `items/curve.py` `class CurveStyle(_Style)`).
2718///
2719/// Each field is `Some(value)` to override that aspect of the curve's own
2720/// style, or `None` to inherit the curve's base value — exactly silx's "set a
2721/// value to `None` to use the default" convention. The active-curve highlight
2722/// merges these over the curve's retained [`CurveData`] via
2723/// `current_curve_style`.
2724///
2725/// The default value used for the active-curve highlight is
2726/// `CurveStyle { line_width: Some(2.0), ..Default::default() }`, mirroring
2727/// silx's `DEFAULT_PLOT_ACTIVE_CURVE_LINEWIDTH = 2` with
2728/// `DEFAULT_PLOT_ACTIVE_CURVE_COLOR = None`: the active curve is emphasised
2729/// purely by a thicker line, leaving color and markers unchanged.
2730#[derive(Clone, Debug, PartialEq, Default)]
2731pub struct CurveStyle {
2732    /// Override line color (silx `CurveStyle` `color`). `None` inherits the
2733    /// curve's own [`CurveData::color`].
2734    pub color: Option<Color32>,
2735    /// Override line width in pixels (silx `linewidth`). `None` inherits the
2736    /// curve's own [`CurveData::width`].
2737    pub line_width: Option<f32>,
2738    /// Override line stroke style (silx `linestyle`). `None` inherits the
2739    /// curve's own [`CurveData::line_style`].
2740    pub line_style: Option<LineStyle>,
2741    /// Override marker symbol (silx `symbol`). `Some(s)` replaces the marker
2742    /// with `s`; `None` inherits the curve's own [`CurveData::symbol`]. The
2743    /// active-curve highlight emphasises, it never hides markers — a faithful
2744    /// mapping of silx, where the active style's `symbol` field is `None` by
2745    /// default and so leaves the curve's own symbol untouched.
2746    pub symbol: Option<Symbol>,
2747    /// Override marker size in pixels (silx `symbolsize`). `None` inherits the
2748    /// curve's own [`CurveData::marker_size`].
2749    pub symbol_size: Option<f32>,
2750    /// Override dashed-line gap color (silx `gapcolor`). `None` inherits the
2751    /// curve's own [`CurveData::gap_color`].
2752    pub gap_color: Option<Color32>,
2753}
2754
2755/// Resolve the render style of a curve given its retained base style, the
2756/// active-curve highlight override, and whether this curve is currently the
2757/// highlighted (active) one. Pure and headless-testable.
2758///
2759/// This is silx `Curve.getCurrentStyle()` (`items/curve.py` ~280-330): when
2760/// `highlighted`, each resolved field is the highlight's value when it is
2761/// `Some`, else the curve's own (per-field override, `None` falls through);
2762/// when not `highlighted`, the curve renders with its own base style
2763/// unchanged.
2764///
2765/// Only the *style* fields are affected (color/width/line_style/symbol/
2766/// marker_size/gap_color). The data fields (x/y/colors/fill/baseline/errors/
2767/// y_axis) are passed through from `base` untouched — they are data, not
2768/// style.
2769fn current_curve_style(base: &CurveData, highlight: &CurveStyle, highlighted: bool) -> CurveData {
2770    let mut resolved = base.clone();
2771    if highlighted {
2772        if let Some(color) = highlight.color {
2773            resolved.color = color;
2774        }
2775        if let Some(width) = highlight.line_width {
2776            resolved.width = width;
2777        }
2778        if let Some(line_style) = highlight.line_style.clone() {
2779            resolved.line_style = line_style;
2780        }
2781        if let Some(symbol) = highlight.symbol {
2782            resolved.symbol = Some(symbol);
2783        }
2784        if let Some(symbol_size) = highlight.symbol_size {
2785            resolved.marker_size = symbol_size;
2786        }
2787        if let Some(gap_color) = highlight.gap_color {
2788            resolved.gap_color = Some(gap_color);
2789        }
2790    }
2791    resolved
2792}
2793
2794/// What a single legend-row interaction returned.
2795struct LegendRowResult {
2796    /// Click anywhere in the row body (not the eye icon).
2797    row_clicked: bool,
2798    /// Click on the visibility eye icon.
2799    eye_clicked: bool,
2800    /// The row's egui [`Response`](egui::Response), so the caller can attach a
2801    /// right-click context menu while holding `&mut self`.
2802    row_response: egui::Response,
2803}
2804
2805fn legend_row_response(
2806    ui: &mut egui::Ui,
2807    width: f32,
2808    kind: PlotItemKind,
2809    label: &str,
2810    active: bool,
2811    visible: bool,
2812    visual: LegendVisual,
2813) -> LegendRowResult {
2814    let (rect, row_response) =
2815        ui.allocate_exact_size(egui::vec2(width, LEGEND_ROW_HEIGHT), egui::Sense::click());
2816    let eye_rect = egui::Rect::from_min_max(
2817        egui::pos2(rect.right() - LEGEND_CHECK_WIDTH, rect.top()),
2818        rect.right_bottom(),
2819    );
2820    let eye_response = ui.interact(eye_rect, row_response.id.with("eye"), egui::Sense::click());
2821    if ui.is_rect_visible(rect) {
2822        draw_legend_row(
2823            ui,
2824            LegendRowDraw {
2825                rect,
2826                response: &row_response,
2827                kind,
2828                label,
2829                active,
2830                visible,
2831                visual,
2832            },
2833        );
2834    }
2835    LegendRowResult {
2836        row_clicked: row_response.clicked() && !eye_response.clicked(),
2837        eye_clicked: eye_response.clicked(),
2838        row_response,
2839    }
2840}
2841
2842struct LegendRowDraw<'a> {
2843    rect: egui::Rect,
2844    response: &'a egui::Response,
2845    kind: PlotItemKind,
2846    label: &'a str,
2847    active: bool,
2848    visible: bool,
2849    visual: LegendVisual,
2850}
2851
2852fn draw_legend_row(ui: &egui::Ui, p: LegendRowDraw<'_>) {
2853    let LegendRowDraw {
2854        rect,
2855        response,
2856        kind,
2857        label,
2858        active,
2859        visible,
2860        visual,
2861    } = p;
2862    let visuals = ui.visuals();
2863    let row_rect = rect.shrink2(egui::vec2(1.0, 0.0));
2864    let row_clip = row_rect.intersect(ui.clip_rect());
2865    let painter = ui.painter().with_clip_rect(row_clip);
2866
2867    let fill = if active {
2868        visuals.selection.bg_fill
2869    } else if response.hovered() {
2870        visuals.widgets.hovered.weak_bg_fill
2871    } else {
2872        Color32::TRANSPARENT
2873    };
2874    painter.rect_filled(row_rect, 0.0, fill);
2875
2876    let check_rect = egui::Rect::from_min_max(
2877        egui::pos2(row_rect.right() - LEGEND_CHECK_WIDTH, row_rect.top()),
2878        row_rect.right_bottom(),
2879    );
2880    let swatch_right = (row_rect.left() + 4.0 + LEGEND_SWATCH_WIDTH)
2881        .min(check_rect.left() - 4.0)
2882        .max(row_rect.left() + 4.0);
2883    let swatch_rect = egui::Rect::from_min_max(
2884        egui::pos2(row_rect.left() + 4.0, row_rect.top() + 4.0),
2885        egui::pos2(swatch_right, row_rect.bottom() - 4.0),
2886    );
2887    if swatch_rect.width() >= 8.0 {
2888        draw_legend_swatch(&painter, swatch_rect, kind, visual);
2889    }
2890
2891    let text_color = if active {
2892        visuals.selection.stroke.color
2893    } else {
2894        visuals.text_color()
2895    };
2896    let text_left = swatch_rect.right() + 6.0;
2897    let text_right = check_rect.left() - 2.0;
2898    if text_right > text_left {
2899        let text_clip = egui::Rect::from_min_max(
2900            egui::pos2(text_left, row_rect.top()),
2901            egui::pos2(text_right, row_rect.bottom()),
2902        )
2903        .intersect(row_clip);
2904        painter.with_clip_rect(text_clip).text(
2905            egui::pos2(text_left, row_rect.center().y),
2906            egui::Align2::LEFT_CENTER,
2907            label,
2908            egui::FontId::proportional(12.0),
2909            text_color,
2910        );
2911    }
2912
2913    draw_legend_eye(
2914        &painter,
2915        check_rect,
2916        visible,
2917        active,
2918        visuals.widgets.inactive.fg_stroke.color,
2919    );
2920    painter.line_segment(
2921        [
2922            egui::pos2(row_rect.left(), row_rect.bottom()),
2923            egui::pos2(row_rect.right(), row_rect.bottom()),
2924        ],
2925        egui::Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
2926    );
2927}
2928
2929fn draw_legend_swatch(
2930    painter: &egui::Painter,
2931    rect: egui::Rect,
2932    kind: PlotItemKind,
2933    visual: LegendVisual,
2934) {
2935    // No bounding box around the icon (silx LegendIcon draws none): the line,
2936    // marker, color, or fill alone identifies the item.
2937    match kind {
2938        PlotItemKind::Curve => {
2939            // silx `LegendIcon`: a full-width line in the curve's style, plus the
2940            // curve's marker symbol centered on it.
2941            let y = rect.center().y;
2942            let a = egui::pos2(rect.left() + 4.0, y);
2943            let b = egui::pos2(rect.right() - 4.0, y);
2944            if visual.line_style.draws_line() {
2945                let stroke = egui::Stroke::new(2.0, visual.color);
2946                match visual.line_style.painter_dashes(stroke.width) {
2947                    None => {
2948                        painter.line_segment([a, b], stroke);
2949                    }
2950                    Some((dashes, gaps, offset)) => {
2951                        for shape in egui::Shape::dashed_line_with_offset(
2952                            &[a, b],
2953                            stroke,
2954                            &dashes,
2955                            &gaps,
2956                            offset,
2957                        ) {
2958                            painter.add(shape);
2959                        }
2960                    }
2961                }
2962            }
2963            if let Some(symbol) = visual.symbol {
2964                draw_legend_symbol(painter, rect.center(), 4.0, symbol, visual.color);
2965            }
2966        }
2967        PlotItemKind::Histogram => {
2968            let fill = visual.color.linear_multiply(0.45);
2969            let bar_width = rect.width() / 5.0;
2970            for (index, height) in [0.35, 0.65, 0.9, 0.55].iter().copied().enumerate() {
2971                let left = rect.left() + 4.0 + index as f32 * bar_width;
2972                let right = left + bar_width * 0.7;
2973                let top = rect.bottom() - 3.0 - rect.height() * height;
2974                let bar = egui::Rect::from_min_max(
2975                    egui::pos2(left, top),
2976                    egui::pos2(right, rect.bottom() - 3.0),
2977                );
2978                painter.rect_filled(bar, 0.0, fill);
2979                painter.rect_stroke(
2980                    bar,
2981                    0.0,
2982                    egui::Stroke::new(1.0, visual.color),
2983                    egui::StrokeKind::Inside,
2984                );
2985            }
2986        }
2987        PlotItemKind::Scatter => {
2988            for t in [0.25, 0.5, 0.75] {
2989                let center = egui::pos2(
2990                    egui::lerp(rect.left() + 6.0..=rect.right() - 6.0, t),
2991                    egui::lerp(rect.bottom() - 4.0..=rect.top() + 4.0, t),
2992                );
2993                painter.circle_filled(center, 3.0, visual.color);
2994            }
2995        }
2996        PlotItemKind::Image | PlotItemKind::Mask => {
2997            if let Some(secondary) = visual.secondary {
2998                let split = rect.center().x;
2999                painter.rect_filled(
3000                    egui::Rect::from_min_max(rect.left_top(), egui::pos2(split, rect.bottom())),
3001                    0.0,
3002                    visual.color,
3003                );
3004                painter.rect_filled(
3005                    egui::Rect::from_min_max(egui::pos2(split, rect.top()), rect.right_bottom()),
3006                    0.0,
3007                    secondary,
3008                );
3009            } else {
3010                painter.rect_filled(rect.shrink(2.0), 0.0, visual.color);
3011            }
3012        }
3013        PlotItemKind::Triangles => {
3014            let points = vec![
3015                egui::pos2(rect.left() + 7.0, rect.bottom() - 4.0),
3016                egui::pos2(rect.center().x, rect.top() + 4.0),
3017                egui::pos2(rect.right() - 7.0, rect.bottom() - 4.0),
3018            ];
3019            painter.add(egui::Shape::convex_polygon(
3020                points,
3021                visual.color.linear_multiply(0.45),
3022                egui::Stroke::new(1.0, visual.color),
3023            ));
3024        }
3025        PlotItemKind::Shape => {
3026            let shape = rect.shrink2(egui::vec2(12.0, 3.0));
3027            painter.rect_stroke(
3028                shape,
3029                0.0,
3030                egui::Stroke::new(1.5, visual.color),
3031                egui::StrokeKind::Inside,
3032            );
3033        }
3034        PlotItemKind::Marker => {
3035            let center = rect.center();
3036            let stroke = egui::Stroke::new(1.8, visual.color);
3037            painter.line_segment(
3038                [
3039                    egui::pos2(center.x - 7.0, center.y),
3040                    egui::pos2(center.x + 7.0, center.y),
3041                ],
3042                stroke,
3043            );
3044            painter.line_segment(
3045                [
3046                    egui::pos2(center.x, center.y - 7.0),
3047                    egui::pos2(center.x, center.y + 7.0),
3048                ],
3049                stroke,
3050            );
3051        }
3052    }
3053}
3054
3055/// Draw a curve's marker [`Symbol`] centered at `center` with half-extent
3056/// `half`, filled in `color` (silx `LegendIcon` symbol path). Filled glyphs
3057/// (circle / square / diamond / triangle / point / pixel) are drawn solid;
3058/// stroke glyphs (cross / plus / lines / ticks / carets) use a `color` stroke.
3059/// Mirrors the legend symbol the curve renderer draws at each vertex so the icon
3060/// matches the plotted marker.
3061fn draw_legend_symbol(
3062    painter: &egui::Painter,
3063    center: egui::Pos2,
3064    half: f32,
3065    symbol: Symbol,
3066    color: Color32,
3067) {
3068    let c = center;
3069    let stroke = egui::Stroke::new(1.5, color);
3070    // Apex-then-arms helper for the open carets.
3071    let caret = |apex: egui::Pos2, arm_a: egui::Pos2, arm_b: egui::Pos2| {
3072        painter.line_segment([apex, arm_a], stroke);
3073        painter.line_segment([apex, arm_b], stroke);
3074    };
3075    match symbol {
3076        Symbol::Circle => {
3077            painter.circle_filled(c, half, color);
3078        }
3079        Symbol::Point => {
3080            painter.circle_filled(c, half * 0.6, color);
3081        }
3082        Symbol::Pixel => {
3083            painter.rect_filled(
3084                egui::Rect::from_center_size(c, egui::Vec2::splat(half * 0.9)),
3085                0.0,
3086                color,
3087            );
3088        }
3089        Symbol::Square => {
3090            painter.rect_filled(
3091                egui::Rect::from_center_size(c, egui::Vec2::splat(half * 2.0)),
3092                0.0,
3093                color,
3094            );
3095        }
3096        Symbol::Diamond => {
3097            painter.add(egui::Shape::convex_polygon(
3098                vec![
3099                    egui::pos2(c.x, c.y - half),
3100                    egui::pos2(c.x + half, c.y),
3101                    egui::pos2(c.x, c.y + half),
3102                    egui::pos2(c.x - half, c.y),
3103                ],
3104                color,
3105                egui::Stroke::NONE,
3106            ));
3107        }
3108        Symbol::Triangle => {
3109            painter.add(egui::Shape::convex_polygon(
3110                vec![
3111                    egui::pos2(c.x, c.y - half),
3112                    egui::pos2(c.x + half, c.y + half),
3113                    egui::pos2(c.x - half, c.y + half),
3114                ],
3115                color,
3116                egui::Stroke::NONE,
3117            ));
3118        }
3119        Symbol::Cross => {
3120            painter.line_segment(
3121                [
3122                    egui::pos2(c.x - half, c.y - half),
3123                    egui::pos2(c.x + half, c.y + half),
3124                ],
3125                stroke,
3126            );
3127            painter.line_segment(
3128                [
3129                    egui::pos2(c.x - half, c.y + half),
3130                    egui::pos2(c.x + half, c.y - half),
3131                ],
3132                stroke,
3133            );
3134        }
3135        Symbol::Plus => {
3136            painter.line_segment(
3137                [egui::pos2(c.x, c.y - half), egui::pos2(c.x, c.y + half)],
3138                stroke,
3139            );
3140            painter.line_segment(
3141                [egui::pos2(c.x - half, c.y), egui::pos2(c.x + half, c.y)],
3142                stroke,
3143            );
3144        }
3145        Symbol::VerticalLine => {
3146            painter.line_segment(
3147                [egui::pos2(c.x, c.y - half), egui::pos2(c.x, c.y + half)],
3148                stroke,
3149            );
3150        }
3151        Symbol::HorizontalLine => {
3152            painter.line_segment(
3153                [egui::pos2(c.x - half, c.y), egui::pos2(c.x + half, c.y)],
3154                stroke,
3155            );
3156        }
3157        Symbol::TickLeft => {
3158            painter.line_segment([egui::pos2(c.x - half, c.y), c], stroke);
3159        }
3160        Symbol::TickRight => {
3161            painter.line_segment([c, egui::pos2(c.x + half, c.y)], stroke);
3162        }
3163        Symbol::TickUp => {
3164            painter.line_segment([egui::pos2(c.x, c.y - half), c], stroke);
3165        }
3166        Symbol::TickDown => {
3167            painter.line_segment([c, egui::pos2(c.x, c.y + half)], stroke);
3168        }
3169        Symbol::CaretLeft => caret(
3170            egui::pos2(c.x - half, c.y),
3171            egui::pos2(c.x + half, c.y - half),
3172            egui::pos2(c.x + half, c.y + half),
3173        ),
3174        Symbol::CaretRight => caret(
3175            egui::pos2(c.x + half, c.y),
3176            egui::pos2(c.x - half, c.y - half),
3177            egui::pos2(c.x - half, c.y + half),
3178        ),
3179        Symbol::CaretUp => caret(
3180            egui::pos2(c.x, c.y - half),
3181            egui::pos2(c.x - half, c.y + half),
3182            egui::pos2(c.x + half, c.y + half),
3183        ),
3184        Symbol::CaretDown => caret(
3185            egui::pos2(c.x, c.y + half),
3186            egui::pos2(c.x - half, c.y - half),
3187            egui::pos2(c.x + half, c.y - half),
3188        ),
3189        Symbol::Heart => {
3190            // Two upper humps + a lower point: a simplified filled heart icon for
3191            // the legend, standing in for the GPU cardioid marker (silx '♥').
3192            let hump = half * 0.5;
3193            let hy = c.y - half * 0.35;
3194            painter.circle_filled(egui::pos2(c.x - half * 0.45, hy), hump, color);
3195            painter.circle_filled(egui::pos2(c.x + half * 0.45, hy), hump, color);
3196            painter.add(egui::Shape::convex_polygon(
3197                vec![
3198                    egui::pos2(c.x - half * 0.95, hy),
3199                    egui::pos2(c.x + half * 0.95, hy),
3200                    egui::pos2(c.x, c.y + half * 0.95),
3201                ],
3202                color,
3203                egui::Stroke::NONE,
3204            ));
3205        }
3206    }
3207}
3208
3209/// Draw an eye icon in `rect` to indicate visibility. An open eye = visible;
3210/// a closed eye (dash through center) = hidden. Active items get the accent color.
3211fn draw_legend_eye(
3212    painter: &egui::Painter,
3213    rect: egui::Rect,
3214    visible: bool,
3215    active: bool,
3216    color: Color32,
3217) {
3218    let cx = rect.center().x;
3219    let cy = rect.center().y;
3220    let r = 3.5_f32;
3221    let eye_color = if active {
3222        color
3223    } else {
3224        crate::core::color::with_alpha(color, 180)
3225    };
3226    if visible {
3227        painter.circle_stroke(egui::pos2(cx, cy), r, egui::Stroke::new(1.5, eye_color));
3228        painter.circle_filled(egui::pos2(cx, cy), r * 0.45, eye_color);
3229    } else {
3230        let dim = crate::core::color::with_alpha(color, 80);
3231        painter.circle_stroke(egui::pos2(cx, cy), r, egui::Stroke::new(1.5, dim));
3232        painter.line_segment(
3233            [egui::pos2(cx - r * 1.3, cy), egui::pos2(cx + r * 1.3, cy)],
3234            egui::Stroke::new(1.5, dim),
3235        );
3236    }
3237}
3238
3239type LimitsSnapshot = ((f64, f64, f64, f64), Option<(f64, f64)>);
3240
3241/// High-level plot widget matching silx `PlotWidget`'s role.
3242///
3243/// It owns a [`WgpuBackend`] and offers item/axis methods. Use [`Self::show`] to
3244/// render it in an egui UI.
3245pub struct PlotWidget {
3246    backend: WgpuBackend,
3247    item_records: Vec<ItemRecord>,
3248    data_bounds: DataBounds,
3249    default_colormap: Colormap,
3250    auto_reset_zoom: bool,
3251    interaction_mode: PlotInteractionMode,
3252    active_item: Option<ItemHandle>,
3253    /// Per-field override style applied to the active curve when active-curve
3254    /// handling is enabled (silx `PlotWidget._activeCurveStyle`). Default is
3255    /// `line_width: Some(2.0)` with all other fields `None`, matching silx's
3256    /// `DEFAULT_PLOT_ACTIVE_CURVE_LINEWIDTH = 2` /
3257    /// `DEFAULT_PLOT_ACTIVE_CURVE_COLOR = None`.
3258    active_curve_style: CurveStyle,
3259    /// Whether the active curve is rendered with [`Self::active_curve_style`]
3260    /// applied (silx `PlotWidget.setActiveCurveHandling`). When `false`, every
3261    /// curve renders with its own base style. Enabled by default.
3262    active_curve_handling: bool,
3263    /// Plot-wide default curve line state (silx `PlotWidget._plotLines`,
3264    /// `isDefaultPlotLines` / `setDefaultPlotLines`). `true` → every curve drawn
3265    /// with a solid line, `false` → no line. silx initializes this to `True`.
3266    default_plot_lines: bool,
3267    /// Plot-wide default curve symbol state (silx `PlotWidget._defaultPlotPoints`,
3268    /// `isDefaultPlotPoints` / `setDefaultPlotPoints`). `true` → every curve drawn
3269    /// with the `o` (Circle) symbol (`silx.config.DEFAULT_PLOT_SYMBOL`), `false` →
3270    /// no symbol. silx initializes this to
3271    /// `silx.config.DEFAULT_PLOT_CURVE_SYMBOL_MODE` (`False`).
3272    default_plot_points: bool,
3273    events: Vec<PlotEvent>,
3274    /// Open legend rename popup: the item being renamed and its edit buffer
3275    /// (silx `RenameCurveDialog`). `None` when no rename is in progress.
3276    rename_state: Option<(ItemHandle, String)>,
3277    /// Printer-selection dialog opened by the toolbar Print button (silx
3278    /// `PrintAction`'s `QPrintDialog` analogue).
3279    print_dialog: crate::widget::print_dialog::PrintDialog,
3280    /// Ruler measurement tool (silx `RulerToolButton` / `_RulerROI`): while
3281    /// armed, a primary drag draws a line ROI whose name is its measured length
3282    /// (`RulerToolButton::distance_text`), recomputed live in [`show`](Self::show)
3283    /// as the line is (re)drawn.
3284    ruler_active: bool,
3285    /// Index of the live ruler line ROI in [`Plot::rois`], or `None` (no ruler
3286    /// line drawn yet / ruler disarmed). One ruler line at a time — a new draw
3287    /// replaces the previous (silx single-measurement `RulerToolButton`).
3288    ruler_roi: Option<usize>,
3289    /// Interaction mode in effect before the ruler was armed, restored on disarm.
3290    ruler_prev_mode: Option<PlotInteractionMode>,
3291}
3292
3293impl PlotWidget {
3294    /// Create a high-level plot widget backed by wgpu resources.
3295    pub fn new(render_state: &RenderState, id: PlotId) -> Self {
3296        Self::from_backend(WgpuBackend::new(render_state, id))
3297    }
3298
3299    /// Build from an existing backend.
3300    pub fn from_backend(backend: WgpuBackend) -> Self {
3301        Self {
3302            backend,
3303            item_records: Vec::new(),
3304            data_bounds: DataBounds::default(),
3305            default_colormap: Colormap::viridis(0.0, 1.0),
3306            auto_reset_zoom: true,
3307            interaction_mode: PlotInteractionMode::Zoom,
3308            active_item: None,
3309            active_curve_style: CurveStyle {
3310                line_width: Some(2.0),
3311                ..CurveStyle::default()
3312            },
3313            active_curve_handling: true,
3314            // silx PlotWidget.__init__: setDefaultPlotPoints(DEFAULT_PLOT_CURVE_SYMBOL_MODE=False),
3315            // setDefaultPlotLines(True).
3316            default_plot_lines: true,
3317            default_plot_points: false,
3318            events: Vec::new(),
3319            rename_state: None,
3320            print_dialog: crate::widget::print_dialog::PrintDialog::new(),
3321            ruler_active: false,
3322            ruler_roi: None,
3323            ruler_prev_mode: None,
3324        }
3325    }
3326
3327    /// Render the widget in `ui`, handling interaction and plot item selection.
3328    pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
3329        self.show_inner(ui, None)
3330    }
3331
3332    /// Render the widget like [`Self::show`], appending custom entries to the
3333    /// plot's built-in right-click context menu (after the Zoom Back / Reset
3334    /// Zoom items), mirroring silx `plotContextMenu.py` adding actions to the
3335    /// plot's default menu.
3336    ///
3337    /// This is the ONLY way to add custom menu entries: calling
3338    /// `Response::context_menu` on the returned response would register a
3339    /// second menu on a response that already carries the built-in one, and
3340    /// egui then closes the menu in the same frame it opens — no menu appears
3341    /// at all. The closure only renders entries and signals choices (e.g. via
3342    /// captured flags applied after `show` returns); it cannot borrow the
3343    /// widget itself while the plot is being shown.
3344    pub fn show_with_context_menu(
3345        &mut self,
3346        ui: &mut egui::Ui,
3347        mut menu_ext: impl FnMut(&mut egui::Ui),
3348    ) -> PlotResponse {
3349        self.show_inner(ui, Some(&mut menu_ext))
3350    }
3351
3352    fn show_inner(
3353        &mut self,
3354        ui: &mut egui::Ui,
3355        menu_ext: Option<&mut dyn FnMut(&mut egui::Ui)>,
3356    ) -> PlotResponse {
3357        let before = self.limits_snapshot();
3358        self.sync_active_axis_labels();
3359        let mut view = PlotView::new();
3360        if let Some(ext) = menu_ext {
3361            view = view.with_context_menu(ext);
3362        }
3363        let response =
3364            view.show_with_interaction(ui, self.backend.plot_mut(), self.interaction_mode);
3365        self.backend
3366            .set_plot_bounds_in_pixels(response.transform.area);
3367        self.select_item_from_plot_response(&response);
3368        self.emit_item_pointer_events(&response);
3369        self.push_limits_changed_if(before);
3370        if let Some(index) = response.roi_changed {
3371            self.events.push(PlotEvent::RoiChanged { index });
3372        }
3373        if let Some(index) = response.roi_created {
3374            // An interactive draw added a new ROI: emit RoiAdded then RoiCreated,
3375            // mirroring silx's `sigRoiAdded` → `sigInteractiveRoiFinalized` order.
3376            self.events.push(PlotEvent::RoiAdded { index });
3377            self.events.push(PlotEvent::RoiCreated { index });
3378        }
3379        // Ruler tool (silx `RulerToolButton` / `_RulerROI`): while armed, a
3380        // completed line draw becomes *the* ruler line, labeled with its measured
3381        // length; a new measurement replaces the previous one, and editing the
3382        // line relabels it live.
3383        if self.ruler_active {
3384            if response.roi_created.is_some() {
3385                // A new measurement replaces the previous ruler line. Remove the
3386                // old one first; the freshly-drawn line is always the last ROI, so
3387                // its index is `rois.len() - 1` whether or not a removal shifted it.
3388                if let Some(old) = self.ruler_roi.take() {
3389                    self.remove_roi(old);
3390                }
3391                let index = self.backend.plot().rois.len().saturating_sub(1);
3392                self.ruler_roi = Some(index);
3393                self.relabel_ruler(index);
3394            } else if let Some(index) = response.roi_changed
3395                && Some(index) == self.ruler_roi
3396            {
3397                self.relabel_ruler(index);
3398            }
3399        }
3400        // ROI context-menu choices (silx `_createMenuForRoi`): the plot only
3401        // signals intent; the mutation + event emission happens here through the
3402        // owning APIs (`set_current_roi` → CurrentRoiChanged, `remove_roi` →
3403        // RoiAboutToBeRemoved) so the right-click path fires the same events as the
3404        // manager. "Make current" before "Remove": they come from distinct menu
3405        // clicks (never the same frame), and applying the highlight first keeps a
3406        // remove-after-select interaction consistent.
3407        if let Some(index) = response.roi_make_current {
3408            self.set_current_roi(Some(index));
3409        }
3410        if let Some((index, mode)) = response.roi_set_interaction_mode {
3411            self.set_roi_interaction_mode(index, mode);
3412        }
3413        if let Some(index) = response.roi_removed {
3414            self.remove_roi(index);
3415        }
3416        // Draw-state events (silx drawingProgress / drawingFinished) from an
3417        // on-plot RoiCreate draw. DrawingFinished fires on the same frame as the
3418        // RoiCreated above (the ROI is built on top of the finished draw).
3419        match response.draw_event.clone() {
3420            Some(DrawEvent::InProgress { mode, points }) => {
3421                self.events
3422                    .push(PlotEvent::DrawingProgress { mode, points });
3423            }
3424            Some(DrawEvent::Finished { mode, params }) => {
3425                self.events
3426                    .push(PlotEvent::DrawingFinished { mode, params });
3427            }
3428            None => {}
3429        }
3430        // Marker drag lifecycle (silx beginDrag/drag/endDrag):
3431        // MarkerDragStarted → MarkerMoved×N → MarkerDragFinished. The start fires
3432        // on the same frame as the first MarkerMoved (the grab is also a move), so
3433        // it must be queued *before* the moved block to keep the lifecycle order.
3434        if let Some(handle) = response.marker_drag_started {
3435            self.events.push(PlotEvent::MarkerDragStarted { handle });
3436        }
3437        // Persist an on-screen marker drag: apply_interaction live-mutated the
3438        // mirror `plot.markers` for this frame's render, but the mirror is
3439        // rebuilt from the backend items on every sync, so the moved data must
3440        // be written back to the owning backend item. Read the new marker from
3441        // the mirror (located via the parallel marker_handles), then persist it.
3442        if let Some(handle) = response.marker_moved {
3443            let plot = self.backend.plot();
3444            let moved = plot
3445                .marker_handles
3446                .iter()
3447                .position(|&h| h == handle)
3448                .and_then(|index| plot.markers.get(index).cloned());
3449            if let Some(marker) = moved {
3450                self.backend.update_marker(handle, marker);
3451                self.events.push(PlotEvent::MarkerMoved { handle });
3452            }
3453        }
3454        // Drag finished on release (silx endDrag markerMoved): the final position
3455        // is already persisted by the preceding MarkerMoved frames.
3456        if let Some(handle) = response.marker_drag_finished {
3457            self.events.push(PlotEvent::MarkerDragFinished { handle });
3458        }
3459        response
3460    }
3461
3462    /// Push the active curve's per-axis labels onto the core plot's active-label
3463    /// overrides so the chrome draws them in place of the graph defaults (silx
3464    /// `_setActiveItem` → `Axis._setCurrentLabel`). Recomputed every frame from
3465    /// the current active curve; cleared to the graph defaults when no curve is
3466    /// active. The active curve's Y label is routed to the left or right (y2)
3467    /// axis by the curve's `y_axis`.
3468    fn sync_active_axis_labels(&mut self) {
3469        let (x, y, y2) = self
3470            .active_curve()
3471            .and_then(|handle| self.item_record(handle))
3472            .map(|record| {
3473                let y_axis = record
3474                    .curve_data
3475                    .as_ref()
3476                    .map(|data| data.y_axis)
3477                    .unwrap_or(YAxis::Left);
3478                active_axis_label_overrides(
3479                    record.x_label.as_deref(),
3480                    record.y_label.as_deref(),
3481                    y_axis,
3482                )
3483            })
3484            .unwrap_or((None, None, None));
3485        let plot = self.backend.plot_mut();
3486        plot.active_x_label = x;
3487        plot.active_y_label = y;
3488        plot.active_y2_label = y2;
3489    }
3490
3491    /// Access the underlying plot model.
3492    pub fn plot(&self) -> &Plot {
3493        self.backend.plot()
3494    }
3495
3496    /// Mutably access the underlying plot model.
3497    pub fn plot_mut(&mut self) -> &mut Plot {
3498        self.backend.plot_mut()
3499    }
3500
3501    /// Access the underlying backend.
3502    pub fn backend(&self) -> &WgpuBackend {
3503        &self.backend
3504    }
3505
3506    /// Mutably access the underlying backend.
3507    pub fn backend_mut(&mut self) -> &mut WgpuBackend {
3508        &mut self.backend
3509    }
3510
3511    /// Toggle whether newly added data updates the displayed data limits.
3512    pub fn set_auto_reset_zoom(&mut self, on: bool) {
3513        self.auto_reset_zoom = on;
3514    }
3515
3516    /// Whether newly added data updates the displayed data limits.
3517    pub fn auto_reset_zoom(&self) -> bool {
3518        self.auto_reset_zoom
3519    }
3520
3521    /// Set the primary pointer interaction mode used by [`Self::show`].
3522    pub fn set_interaction_mode(&mut self, mode: PlotInteractionMode) {
3523        self.interaction_mode = mode;
3524    }
3525
3526    /// Primary pointer interaction mode used by [`Self::show`].
3527    pub fn interaction_mode(&self) -> PlotInteractionMode {
3528        self.interaction_mode
3529    }
3530
3531    /// Arm on-plot creation of a new ROI of `kind` (silx
3532    /// `RegionOfInterestManager.start(roiClass)`). A convenience for
3533    /// `set_interaction_mode(PlotInteractionMode::RoiCreate(kind))`: the next
3534    /// primary drag (or click, for [`RoiDrawKind::Point`]/[`RoiDrawKind::Cross`])
3535    /// draws the shape; finishing it appends the ROI to `plot().rois` and queues
3536    /// a [`PlotEvent::RoiCreated`]. Creation re-arms continuously until the mode
3537    /// is changed.
3538    pub fn set_roi_create_mode(&mut self, kind: RoiDrawKind) {
3539        self.set_interaction_mode(PlotInteractionMode::RoiCreate(kind));
3540    }
3541
3542    /// The ROI-creation status message for the current interaction mode and ROI
3543    /// count, for display in a host status bar (silx
3544    /// `InteractiveRegionOfInterestManager.getMessage`). `Some("Select {name}s
3545    /// ({n} selected)")` while an on-plot ROI creation mode is armed (the kind
3546    /// being drawn + the current ROI count), else `None`. Delegates to
3547    /// [`PlotInteractionMode::roi_creation_message`].
3548    pub fn roi_creation_message(&self) -> Option<String> {
3549        self.interaction_mode
3550            .roi_creation_message(self.backend.plot().rois.len())
3551    }
3552
3553    /// Queued plot events since the last drain.
3554    pub fn events(&self) -> &[PlotEvent] {
3555        &self.events
3556    }
3557
3558    /// Take queued plot events.
3559    pub fn drain_events(&mut self) -> Vec<PlotEvent> {
3560        mem::take(&mut self.events)
3561    }
3562
3563    fn limits_snapshot(&self) -> LimitsSnapshot {
3564        (self.backend.plot().limits, self.backend.plot().y2)
3565    }
3566
3567    fn push_limits_changed_if(&mut self, before: LimitsSnapshot) {
3568        let after = self.limits_snapshot();
3569        if before != after {
3570            let ((xmin, xmax, ymin, ymax), y2) = after;
3571            self.events.push(PlotEvent::LimitsChanged {
3572                x: (xmin, xmax),
3573                y: (ymin, ymax),
3574                y2,
3575            });
3576        }
3577    }
3578
3579    fn select_item_from_plot_response(&mut self, response: &PlotResponse) {
3580        if !response.response.clicked_by(egui::PointerButton::Primary) {
3581            return;
3582        }
3583        let Some(pos) = response.response.interact_pointer_pos() else {
3584            return;
3585        };
3586        if !response.transform.area.contains(pos) {
3587            return;
3588        }
3589        if let Some(handle) = self.pick_topmost_item(pos) {
3590            self.set_active_item(Some(handle));
3591        }
3592    }
3593
3594    /// Pick the front-most item under `pos`, returning its handle and the
3595    /// [`PickResult`] describing what was hit (silx picking). Single owner for
3596    /// both legend/active-item selection and the item-click/hover signals:
3597    /// walks `items_back_to_front` in reverse so the top-drawn item wins.
3598    fn pick_topmost(&self, pos: egui::Pos2) -> Option<(ItemHandle, PickResult)> {
3599        self.backend
3600            .items_back_to_front()
3601            .into_iter()
3602            .rev()
3603            .find_map(|handle| {
3604                self.backend
3605                    .pick_item(pos, handle)
3606                    .map(|pick| (handle, pick))
3607            })
3608    }
3609
3610    fn pick_topmost_item(&self, pos: egui::Pos2) -> Option<ItemHandle> {
3611        self.pick_topmost(pos).map(|(handle, _)| handle)
3612    }
3613
3614    /// Map a topmost [`PickResult`] to its item-click [`PlotEvent`] (silx
3615    /// `curveClicked`/`imageClicked`/`markerClicked`). Pure — depends only on
3616    /// its arguments — so it is unit-tested without a GPU backend, which is the
3617    /// part of the click path that cannot be exercised headlessly.
3618    fn click_event_for_pick(
3619        handle: ItemHandle,
3620        pick: &PickResult,
3621        button: MouseButton,
3622    ) -> PlotEvent {
3623        match *pick {
3624            PickResult::CurvePoint { index, x, y, .. } => PlotEvent::CurveClicked {
3625                handle,
3626                index,
3627                x,
3628                y,
3629                button,
3630            },
3631            PickResult::ImagePixel { col, row } => PlotEvent::ImageClicked {
3632                handle,
3633                col,
3634                row,
3635                button,
3636            },
3637            PickResult::Item { .. } => PlotEvent::ItemClicked { handle, button },
3638        }
3639    }
3640
3641    /// Translate this frame's low-level [`PlotPointerEvent`] into item-identified
3642    /// [`PlotEvent`]s (silx `curveClicked`/`imageClicked`/`markerClicked` and the
3643    /// hover signal). A primary/secondary/middle click on an item queues the
3644    /// matching click event; a bare hover (no button held) over an item queues
3645    /// [`PlotEvent::ItemHovered`]. The pointer pixel is already gated to the data
3646    /// area by `detect_pointer_event`, so the only filter here is whether the
3647    /// pixel actually picks an item.
3648    fn emit_item_pointer_events(&mut self, response: &PlotResponse) {
3649        use crate::widget::interaction::PlotPointerEvent;
3650        let Some(event) = response.pointer_event.as_ref() else {
3651            return;
3652        };
3653        match *event {
3654            PlotPointerEvent::Clicked { button, pixel, .. } => {
3655                let pos = egui::pos2(pixel.0, pixel.1);
3656                if let Some((handle, pick)) = self.pick_topmost(pos) {
3657                    self.events
3658                        .push(Self::click_event_for_pick(handle, &pick, button));
3659                }
3660            }
3661            PlotPointerEvent::Moved {
3662                button: None,
3663                data,
3664                pixel,
3665            } => {
3666                let pos = egui::pos2(pixel.0, pixel.1);
3667                if let Some((handle, _)) = self.pick_topmost(pos)
3668                    && let Some(kind) = self.item_kind(handle)
3669                {
3670                    // Assemble the silx prepareHoverSignal payload: label from the
3671                    // item record, data/pixel cursor position from this move, and
3672                    // draggable from the marker flag (false for non-marker items).
3673                    let label = self.item_legend(handle).map(str::to_owned);
3674                    let draggable = self.backend.marker(handle).is_some_and(|m| m.is_draggable);
3675                    self.events.push(PlotEvent::ItemHovered {
3676                        handle,
3677                        kind,
3678                        label,
3679                        x: data.0,
3680                        y: data.1,
3681                        xpixel: pixel.0,
3682                        ypixel: pixel.1,
3683                        draggable,
3684                    });
3685                }
3686            }
3687            _ => {}
3688        }
3689    }
3690
3691    fn set_limits_internal(
3692        &mut self,
3693        xmin: f64,
3694        xmax: f64,
3695        ymin: f64,
3696        ymax: f64,
3697        y2: Option<(f64, f64)>,
3698    ) {
3699        let before = self.limits_snapshot();
3700        self.backend.set_limits(xmin, xmax, ymin, ymax, y2);
3701        self.push_limits_changed_if(before);
3702    }
3703
3704    fn record_item(
3705        &mut self,
3706        handle: ItemHandle,
3707        kind: PlotItemKind,
3708        bounds: DataBounds,
3709        stats: Option<ItemStats>,
3710        visual: LegendVisual,
3711    ) {
3712        self.item_records.push(ItemRecord {
3713            handle,
3714            kind,
3715            bounds,
3716            legend: None,
3717            x_label: None,
3718            y_label: None,
3719            stats,
3720            visual,
3721            data: None,
3722            curve_data: None,
3723            hidden_symbol: None,
3724            hidden_line_style: None,
3725        });
3726        self.events.push(PlotEvent::ItemAdded { handle, kind });
3727        if self.active_item.is_none() {
3728            self.set_active_item(Some(handle));
3729        }
3730        self.recompute_data_bounds();
3731        self.apply_auto_limits();
3732    }
3733
3734    fn update_item_record(
3735        &mut self,
3736        handle: ItemHandle,
3737        kind: PlotItemKind,
3738        bounds: DataBounds,
3739        stats: Option<ItemStats>,
3740        visual: LegendVisual,
3741    ) {
3742        if let Some(record) = self
3743            .item_records
3744            .iter_mut()
3745            .find(|record| record.handle == handle)
3746        {
3747            record.kind = kind;
3748            record.bounds = bounds;
3749            record.stats = stats;
3750            record.visual = visual;
3751            // `data` is set by the typed entry points after this call; an
3752            // untyped update (e.g. via update_item_record from a path with no
3753            // retained data) leaves the existing data in place.
3754            self.events.push(PlotEvent::ItemUpdated { handle, kind });
3755        } else {
3756            self.item_records.push(ItemRecord {
3757                handle,
3758                kind,
3759                bounds,
3760                legend: None,
3761                x_label: None,
3762                y_label: None,
3763                stats,
3764                visual,
3765                data: None,
3766                curve_data: None,
3767                hidden_symbol: None,
3768                hidden_line_style: None,
3769            });
3770            self.events.push(PlotEvent::ItemAdded { handle, kind });
3771        }
3772        self.recompute_data_bounds();
3773        self.apply_auto_limits();
3774    }
3775
3776    fn recompute_data_bounds(&mut self) {
3777        let mut bounds = DataBounds::default();
3778        for record in &self.item_records {
3779            bounds.include_bounds(&record.bounds);
3780        }
3781        self.data_bounds = bounds;
3782        // Keep the model's data-range cache live (silx invalidates `_dataRange`
3783        // on `_notifyContentChanged` and recomputes it in `_updateDataRange`).
3784        // This is the single funnel for every content change, so pushing the raw
3785        // per-axis bounds here makes `Plot::data_range()` reflect the data on all
3786        // paths instead of reading as all-`None`. The refit
3787        // (`apply_limits_from_data_bounds`) keeps using the non-degenerate-padded
3788        // range; only the cache content changes here.
3789        self.backend
3790            .plot_mut()
3791            .set_data_range(raw_data_range_from_bounds(&self.data_bounds));
3792    }
3793
3794    fn remove_records_by_kinds(&mut self, predicate: impl Fn(PlotItemKind) -> bool) {
3795        let removed: Vec<(ItemHandle, PlotItemKind)> = self
3796            .item_records
3797            .iter()
3798            .filter_map(|record| predicate(record.kind).then_some((record.handle, record.kind)))
3799            .collect();
3800        for (handle, _) in &removed {
3801            self.backend.remove(*handle);
3802        }
3803        self.item_records.retain(|record| !predicate(record.kind));
3804        for (handle, kind) in removed {
3805            self.events.push(PlotEvent::ItemRemoved { handle, kind });
3806        }
3807        self.clear_active_if_missing();
3808        self.recompute_data_bounds();
3809        self.apply_auto_limits();
3810    }
3811
3812    fn has_item(&self, handle: ItemHandle) -> bool {
3813        self.item_records
3814            .iter()
3815            .any(|record| record.handle == handle)
3816    }
3817
3818    fn item_record(&self, handle: ItemHandle) -> Option<&ItemRecord> {
3819        self.item_records
3820            .iter()
3821            .find(|record| record.handle == handle)
3822    }
3823
3824    fn item_record_mut(&mut self, handle: ItemHandle) -> Option<&mut ItemRecord> {
3825        self.item_records
3826            .iter_mut()
3827            .find(|record| record.handle == handle)
3828    }
3829
3830    /// Attach (or clear) the retained raw data for an item. Called by the typed
3831    /// curve/image spec entry points right after the record is created/updated,
3832    /// so live stats/fit consumers can read the active item's data.
3833    fn set_retained_data(&mut self, handle: ItemHandle, data: Option<RetainedItemData>) {
3834        if let Some(record) = self.item_record_mut(handle) {
3835            record.data = data;
3836        }
3837    }
3838
3839    /// The retained raw data for an item, if any.
3840    fn retained_data(&self, handle: ItemHandle) -> Option<&RetainedItemData> {
3841        self.item_record(handle)
3842            .and_then(|record| record.data.as_ref())
3843    }
3844
3845    /// Attach (or clear) the retained full [`CurveData`] for a curve-like item.
3846    /// Called by the curve spec entry points so the curve-style cycle action can
3847    /// clone it, change the line style, and re-apply the full curve.
3848    fn set_record_curve_data(&mut self, handle: ItemHandle, curve_data: Option<CurveData>) {
3849        if let Some(record) = self.item_record_mut(handle) {
3850            record.curve_data = curve_data;
3851        }
3852    }
3853
3854    /// The retained full [`CurveData`] for a curve-like item, if any.
3855    fn record_curve_data(&self, handle: ItemHandle) -> Option<&CurveData> {
3856        self.item_record(handle)
3857            .and_then(|record| record.curve_data.as_ref())
3858    }
3859
3860    /// Whether `handle` is the curve that should currently render with the
3861    /// active-curve highlight (silx: highlight applies only when the active
3862    /// item's kind is exactly `'curve'`).
3863    ///
3864    /// INVARIANT: a curve is highlighted iff active-curve handling is on AND it
3865    /// is the active item AND its kind is exactly [`PlotItemKind::Curve`]
3866    /// (scatter is a distinct kind and is never highlighted, matching silx
3867    /// `_setActiveItem`).
3868    fn is_highlighted_curve(&self, handle: ItemHandle) -> bool {
3869        self.active_curve_handling
3870            && self.active_item == Some(handle)
3871            && self.item_kind(handle) == Some(PlotItemKind::Curve)
3872    }
3873
3874    /// Single owner of the active-curve highlight transition. Re-pushes the GPU
3875    /// render style of `handle` as
3876    /// `current_curve_style(retained_base, active_curve_style, is_highlighted)`.
3877    ///
3878    /// INVARIANT: the retained `record.curve_data` is ALWAYS the BASE style
3879    /// (never the resolved highlight) — the single source of truth. The
3880    /// highlight is a render-time overlay only, so this never calls
3881    /// `set_record_curve_data`. Returns early when `handle` has no retained
3882    /// curve data (not a curve / no data), so non-curve handles are a no-op.
3883    fn sync_curve_highlight(&mut self, handle: ItemHandle) {
3884        let Some(base) = self.record_curve_data(handle).cloned() else {
3885            return;
3886        };
3887        let highlighted = self.is_highlighted_curve(handle);
3888        let effective = current_curve_style(&base, &self.active_curve_style, highlighted);
3889        self.backend
3890            .update_curve(handle, curve_spec_from_data(&effective));
3891    }
3892
3893    fn clear_active_if_missing(&mut self) {
3894        if self
3895            .active_item
3896            .is_some_and(|handle| !self.has_item(handle))
3897        {
3898            let previous = self.active_item.take();
3899            self.events.push(PlotEvent::ActiveItemChanged {
3900                previous,
3901                current: None,
3902            });
3903        }
3904    }
3905
3906    /// Add a curve with default silx-like styling.
3907    pub fn add_curve(&mut self, x: &[f64], y: &[f64], color: Color32) -> ItemHandle {
3908        self.add_curve_spec(CurveSpec::new(x, y, color))
3909    }
3910
3911    /// Add a curve and assign a legend label.
3912    pub fn add_curve_with_legend(
3913        &mut self,
3914        x: &[f64],
3915        y: &[f64],
3916        color: Color32,
3917        legend: impl Into<String>,
3918    ) -> ItemHandle {
3919        let handle = self.add_curve(x, y, color);
3920        self.set_item_legend(handle, legend);
3921        handle
3922    }
3923
3924    /// Add a curve from an existing [`CurveData`] value.
3925    pub fn add_curve_data(&mut self, curve: &CurveData) -> ItemHandle {
3926        self.add_curve_spec(curve_spec_from_data(curve))
3927    }
3928
3929    /// Add a curve from [`CurveData`] and assign a legend label in one call.
3930    pub fn add_curve_data_with_legend(
3931        &mut self,
3932        curve: &CurveData,
3933        legend: impl Into<String>,
3934    ) -> ItemHandle {
3935        let handle = self.add_curve_data(curve);
3936        self.set_item_legend(handle, legend);
3937        handle
3938    }
3939
3940    /// Add a curve from the full backend spec.
3941    pub fn add_curve_spec(&mut self, spec: CurveSpec<'_>) -> ItemHandle {
3942        self.add_curve_spec_as_kind(spec, PlotItemKind::Curve)
3943    }
3944
3945    fn add_curve_spec_as_kind(&mut self, spec: CurveSpec<'_>, kind: PlotItemKind) -> ItemHandle {
3946        let bounds = curve_spec_bounds(&spec);
3947        let stats = Some(curve_spec_stats(&spec));
3948        let visual = curve_spec_legend_visual(&spec, kind);
3949        let data = curve_spec_retained_data(&spec);
3950        let curve_data = curve_data_from_spec_hl(&spec);
3951        // Per-curve axis labels (silx `addCurve(xlabel=, ylabel=)`): capture
3952        // before the spec is consumed, then store on the record alongside
3953        // `legend` so they survive style/highlight re-applications.
3954        let x_label = spec.x_label.map(ToOwned::to_owned);
3955        let y_label = spec.y_label.map(ToOwned::to_owned);
3956        let handle = self.backend.add_curve(spec);
3957        self.record_item(handle, kind, bounds, stats, visual);
3958        self.set_retained_data(handle, Some(data));
3959        self.set_record_curve_data(handle, Some(curve_data));
3960        if let Some(record) = self.item_record_mut(handle) {
3961            record.x_label = x_label;
3962            record.y_label = y_label;
3963        }
3964        handle
3965    }
3966
3967    /// Replace an existing curve by handle.
3968    pub fn update_curve_spec(&mut self, handle: ItemHandle, spec: CurveSpec<'_>) -> bool {
3969        let bounds = curve_spec_bounds(&spec);
3970        let stats = Some(curve_spec_stats(&spec));
3971        let kind = self
3972            .item_kind(handle)
3973            .filter(|kind| kind.is_curve_like())
3974            .unwrap_or(PlotItemKind::Curve);
3975        let visual = curve_spec_legend_visual(&spec, kind);
3976        let data = curve_spec_retained_data(&spec);
3977        let curve_data = curve_data_from_spec_hl(&spec);
3978        if self.backend.update_curve(handle, spec) {
3979            self.update_item_record(handle, kind, bounds, stats, visual);
3980            self.set_retained_data(handle, Some(data));
3981            self.set_record_curve_data(handle, Some(curve_data));
3982            // The base push above renders the new BASE style. If this is the
3983            // active (highlighted) curve, re-overlay the highlight through the
3984            // single owner so updating it does not silently drop the highlight
3985            // (silx re-applies the current style on data/style change). For
3986            // non-active curves the base push is already correct, so skip.
3987            if self.is_highlighted_curve(handle) {
3988                self.sync_curve_highlight(handle);
3989            }
3990            true
3991        } else {
3992            false
3993        }
3994    }
3995
3996    /// Replace an existing curve by handle from [`CurveData`].
3997    pub fn update_curve_data(&mut self, handle: ItemHandle, curve: &CurveData) -> bool {
3998        self.update_curve_spec(handle, curve_spec_from_data(curve))
3999    }
4000
4001    /// Add a scatter item: markers at every `(x, y)` point, with no connecting line.
4002    pub fn add_scatter(&mut self, x: &[f64], y: &[f64], color: Color32) -> ItemHandle {
4003        self.add_scatter_with_symbol(x, y, color, Symbol::Circle, 7.0)
4004    }
4005
4006    /// Add a scatter item and assign a legend label.
4007    pub fn add_scatter_with_legend(
4008        &mut self,
4009        x: &[f64],
4010        y: &[f64],
4011        color: Color32,
4012        legend: impl Into<String>,
4013    ) -> ItemHandle {
4014        let handle = self.add_scatter(x, y, color);
4015        self.set_item_legend(handle, legend);
4016        handle
4017    }
4018
4019    /// Add a scatter item with explicit marker symbol and size.
4020    pub fn add_scatter_with_symbol(
4021        &mut self,
4022        x: &[f64],
4023        y: &[f64],
4024        color: Color32,
4025        symbol: Symbol,
4026        symbol_size: f32,
4027    ) -> ItemHandle {
4028        let mut spec = CurveSpec::new(x, y, color);
4029        spec.line_style = LineStyle::None;
4030        spec.line_width = 0.0;
4031        spec.symbol = Some(symbol);
4032        spec.symbol_size = symbol_size;
4033        self.add_curve_spec_as_kind(spec, PlotItemKind::Scatter)
4034    }
4035
4036    /// Add a histogram from bin edges and bin counts.
4037    pub fn add_histogram(
4038        &mut self,
4039        edges: &[f64],
4040        counts: &[f64],
4041        color: Color32,
4042    ) -> Result<ItemHandle, PlotDataError> {
4043        let (x, y) = histogram_step_values(edges, counts)?;
4044        let mut spec = CurveSpec::new(&x, &y, color);
4045        spec.fill = true;
4046        spec.baseline = Baseline::Scalar(0.0);
4047        Ok(self.add_curve_spec_as_kind(spec, PlotItemKind::Histogram))
4048    }
4049
4050    /// Add a histogram and assign a legend label.
4051    pub fn add_histogram_with_legend(
4052        &mut self,
4053        edges: &[f64],
4054        counts: &[f64],
4055        color: Color32,
4056        legend: impl Into<String>,
4057    ) -> Result<ItemHandle, PlotDataError> {
4058        let handle = self.add_histogram(edges, counts, color)?;
4059        self.set_item_legend(handle, legend);
4060        Ok(handle)
4061    }
4062
4063    /// Add a histogram from `N` bin positions and `N` counts, deriving the
4064    /// `N + 1` bin edges from `align` (silx `Histogram.setData(align=)`).
4065    ///
4066    /// The edges are computed by [`histogram_edges`]; `positions.len()` must equal
4067    /// `counts.len()` (mismatched lengths surface as
4068    /// [`PlotDataError::HistogramLength`] from the underlying [`Self::add_histogram`]).
4069    pub fn add_histogram_aligned(
4070        &mut self,
4071        positions: &[f64],
4072        counts: &[f64],
4073        color: Color32,
4074        align: HistogramAlign,
4075    ) -> Result<ItemHandle, PlotDataError> {
4076        let edges = histogram_edges(positions, align);
4077        self.add_histogram(&edges, counts, color)
4078    }
4079
4080    /// Add an aligned histogram (see [`Self::add_histogram_aligned`]) and assign a
4081    /// legend label.
4082    pub fn add_histogram_aligned_with_legend(
4083        &mut self,
4084        positions: &[f64],
4085        counts: &[f64],
4086        color: Color32,
4087        align: HistogramAlign,
4088        legend: impl Into<String>,
4089    ) -> Result<ItemHandle, PlotDataError> {
4090        let handle = self.add_histogram_aligned(positions, counts, color, align)?;
4091        self.set_item_legend(handle, legend);
4092        Ok(handle)
4093    }
4094
4095    /// Add a scalar image with unit scale and origin `(0, 0)`.
4096    pub fn add_image(
4097        &mut self,
4098        width: u32,
4099        height: u32,
4100        data: &[f32],
4101        colormap: Colormap,
4102    ) -> ItemHandle {
4103        self.add_image_spec(ImageSpec::scalar(width, height, data, colormap))
4104    }
4105
4106    /// Add a scalar image, returning an error instead of panicking on length mismatch.
4107    pub fn try_add_image(
4108        &mut self,
4109        width: u32,
4110        height: u32,
4111        data: &[f32],
4112        colormap: Colormap,
4113    ) -> Result<ItemHandle, PlotDataError> {
4114        validate_image_len(width, height, data.len())?;
4115        Ok(self.add_image(width, height, data, colormap))
4116    }
4117
4118    /// Add a scalar image using the widget's default colormap.
4119    pub fn add_image_default(&mut self, width: u32, height: u32, data: &[f32]) -> ItemHandle {
4120        self.add_image(width, height, data, self.default_colormap.clone())
4121    }
4122
4123    /// Add a scalar image using the widget's default colormap, returning an
4124    /// error instead of panicking on length mismatch.
4125    pub fn try_add_image_default(
4126        &mut self,
4127        width: u32,
4128        height: u32,
4129        data: &[f32],
4130    ) -> Result<ItemHandle, PlotDataError> {
4131        self.try_add_image(width, height, data, self.default_colormap.clone())
4132    }
4133
4134    /// Add a scalar image with explicit origin/scale/alpha.
4135    pub fn add_image_with_geometry(
4136        &mut self,
4137        width: u32,
4138        height: u32,
4139        data: &[f32],
4140        colormap: Colormap,
4141        geometry: ImageGeometry,
4142    ) -> Result<ItemHandle, PlotDataError> {
4143        validate_image_len(width, height, data.len())?;
4144        let mut spec = ImageSpec::scalar(width, height, data, colormap);
4145        spec.origin = geometry.origin;
4146        spec.scale = geometry.scale;
4147        spec.alpha = geometry.alpha;
4148        Ok(self.add_image_spec(spec))
4149    }
4150
4151    /// Add a scalar image using the widget's default colormap and assign a legend label.
4152    pub fn add_image_with_legend(
4153        &mut self,
4154        width: u32,
4155        height: u32,
4156        data: &[f32],
4157        legend: impl Into<String>,
4158    ) -> ItemHandle {
4159        let handle = self.add_image_default(width, height, data);
4160        self.set_item_legend(handle, legend);
4161        handle
4162    }
4163
4164    /// Add a direct RGBA image with unit scale and origin `(0, 0)`.
4165    pub fn add_rgba_image(&mut self, width: u32, height: u32, data: &[[u8; 4]]) -> ItemHandle {
4166        self.add_image_spec(ImageSpec::rgba(width, height, data))
4167    }
4168
4169    /// Add a direct RGBA image and assign a legend label.
4170    pub fn add_rgba_image_with_legend(
4171        &mut self,
4172        width: u32,
4173        height: u32,
4174        data: &[[u8; 4]],
4175        legend: impl Into<String>,
4176    ) -> ItemHandle {
4177        let handle = self.add_rgba_image(width, height, data);
4178        self.set_item_legend(handle, legend);
4179        handle
4180    }
4181
4182    /// Add a direct RGBA image, returning an error instead of panicking on length mismatch.
4183    pub fn try_add_rgba_image(
4184        &mut self,
4185        width: u32,
4186        height: u32,
4187        data: &[[u8; 4]],
4188    ) -> Result<ItemHandle, PlotDataError> {
4189        validate_image_len(width, height, data.len())?;
4190        Ok(self.add_rgba_image(width, height, data))
4191    }
4192
4193    /// Add a direct RGBA image with explicit origin/scale/alpha.
4194    pub fn add_rgba_image_with_geometry(
4195        &mut self,
4196        width: u32,
4197        height: u32,
4198        data: &[[u8; 4]],
4199        geometry: ImageGeometry,
4200    ) -> Result<ItemHandle, PlotDataError> {
4201        validate_image_len(width, height, data.len())?;
4202        let mut spec = ImageSpec::rgba(width, height, data);
4203        spec.origin = geometry.origin;
4204        spec.scale = geometry.scale;
4205        spec.alpha = geometry.alpha;
4206        Ok(self.add_image_spec(spec))
4207    }
4208
4209    /// Add an image from an existing [`ImageData`] value.
4210    pub fn add_image_data(&mut self, image: &ImageData) -> ItemHandle {
4211        self.add_image_spec(image_spec_from_data(image))
4212    }
4213
4214    /// Add an image from the full backend spec.
4215    pub fn add_image_spec(&mut self, spec: ImageSpec<'_>) -> ItemHandle {
4216        self.add_image_spec_as_kind(spec, PlotItemKind::Image)
4217    }
4218
4219    fn add_image_spec_as_kind(&mut self, spec: ImageSpec<'_>, kind: PlotItemKind) -> ItemHandle {
4220        let bounds = image_spec_bounds(&spec);
4221        let stats = Some(image_spec_stats(&spec));
4222        let visual = image_spec_legend_visual(&spec, kind);
4223        let data = image_spec_retained_data(&spec);
4224        let handle = self.backend.add_image(spec);
4225        self.record_item(handle, kind, bounds, stats, visual);
4226        self.set_retained_data(handle, data);
4227        handle
4228    }
4229
4230    /// Replace an existing image by handle.
4231    pub fn update_image_spec(&mut self, handle: ItemHandle, spec: ImageSpec<'_>) -> bool {
4232        let bounds = image_spec_bounds(&spec);
4233        let stats = Some(image_spec_stats(&spec));
4234        let kind = self
4235            .item_kind(handle)
4236            .filter(|kind| kind.is_image_like())
4237            .unwrap_or(PlotItemKind::Image);
4238        let visual = image_spec_legend_visual(&spec, kind);
4239        let data = image_spec_retained_data(&spec);
4240        if self.backend.update_image(handle, spec) {
4241            self.update_item_record(handle, kind, bounds, stats, visual);
4242            self.set_retained_data(handle, data);
4243            true
4244        } else {
4245            false
4246        }
4247    }
4248
4249    /// Replace an existing scalar image, returning an error instead of panicking
4250    /// on length mismatch.
4251    pub fn try_update_image(
4252        &mut self,
4253        handle: ItemHandle,
4254        width: u32,
4255        height: u32,
4256        data: &[f32],
4257        colormap: Colormap,
4258    ) -> Result<bool, PlotDataError> {
4259        validate_image_len(width, height, data.len())?;
4260        Ok(self.update_image_spec(handle, ImageSpec::scalar(width, height, data, colormap)))
4261    }
4262
4263    /// Replace an existing direct RGBA image, returning an error instead of
4264    /// panicking on length mismatch.
4265    pub fn try_update_rgba_image(
4266        &mut self,
4267        handle: ItemHandle,
4268        width: u32,
4269        height: u32,
4270        data: &[[u8; 4]],
4271    ) -> Result<bool, PlotDataError> {
4272        validate_image_len(width, height, data.len())?;
4273        Ok(self.update_image_spec(handle, ImageSpec::rgba(width, height, data)))
4274    }
4275
4276    /// Replace an existing image by handle from [`ImageData`].
4277    pub fn update_image_data(&mut self, handle: ItemHandle, image: &ImageData) -> bool {
4278        self.update_image_spec(handle, image_spec_from_data(image))
4279    }
4280
4281    /// Add a boolean mask as a transparent RGBA overlay.
4282    pub fn add_mask(
4283        &mut self,
4284        width: u32,
4285        height: u32,
4286        mask: &[bool],
4287        color: Color32,
4288    ) -> Result<ItemHandle, PlotDataError> {
4289        self.add_mask_with_geometry(width, height, mask, color, ImageGeometry::default())
4290    }
4291
4292    /// Add a boolean mask as a transparent RGBA overlay with explicit geometry.
4293    pub fn add_mask_with_geometry(
4294        &mut self,
4295        width: u32,
4296        height: u32,
4297        mask: &[bool],
4298        color: Color32,
4299        geometry: ImageGeometry,
4300    ) -> Result<ItemHandle, PlotDataError> {
4301        validate_image_len(width, height, mask.len())?;
4302        let rgba = mask_rgba_pixels(mask, color);
4303        let mut spec = ImageSpec::rgba(width, height, &rgba);
4304        spec.origin = geometry.origin;
4305        spec.scale = geometry.scale;
4306        spec.alpha = geometry.alpha;
4307        Ok(self.add_image_spec_as_kind(spec, PlotItemKind::Mask))
4308    }
4309
4310    /// Add raw per-pixel RGBA pixels as a mask-kind overlay item.
4311    ///
4312    /// Unlike [`add_mask`](Self::add_mask) (a boolean stencil painted in one
4313    /// color), this carries fully resolved per-pixel RGBA, so a multi-level
4314    /// mask can map each level through its own LUT entry (silx
4315    /// `_BaseMaskToolsWidget` discrete mask colormap). `pixels` is row-major,
4316    /// `width * height` long.
4317    pub fn add_rgba_mask(
4318        &mut self,
4319        width: u32,
4320        height: u32,
4321        pixels: &[[u8; 4]],
4322    ) -> Result<ItemHandle, PlotDataError> {
4323        validate_image_len(width, height, pixels.len())?;
4324        let spec = ImageSpec::rgba(width, height, pixels);
4325        Ok(self.add_image_spec_as_kind(spec, PlotItemKind::Mask))
4326    }
4327
4328    /// Add a boolean mask overlay and assign a legend label.
4329    pub fn add_mask_with_legend(
4330        &mut self,
4331        width: u32,
4332        height: u32,
4333        mask: &[bool],
4334        color: Color32,
4335        legend: impl Into<String>,
4336    ) -> Result<ItemHandle, PlotDataError> {
4337        let handle = self.add_mask(width, height, mask, color)?;
4338        self.set_item_legend(handle, legend);
4339        Ok(handle)
4340    }
4341
4342    /// Add a horizontal image profile as a curve.
4343    pub fn add_horizontal_profile_curve(
4344        &mut self,
4345        width: u32,
4346        height: u32,
4347        data: &[f32],
4348        row: u32,
4349        color: Color32,
4350    ) -> Result<ItemHandle, PlotDataError> {
4351        let y = horizontal_profile_values(width, height, data, row)?;
4352        let x: Vec<f64> = (0..width).map(|col| col as f64).collect();
4353        Ok(self.add_curve(&x, &y, color))
4354    }
4355
4356    /// Add a vertical image profile as a curve.
4357    pub fn add_vertical_profile_curve(
4358        &mut self,
4359        width: u32,
4360        height: u32,
4361        data: &[f32],
4362        column: u32,
4363        color: Color32,
4364    ) -> Result<ItemHandle, PlotDataError> {
4365        let y = vertical_profile_values(width, height, data, column)?;
4366        let x: Vec<f64> = (0..height).map(|row| row as f64).collect();
4367        Ok(self.add_curve(&x, &y, color))
4368    }
4369
4370    /// Add a triangle mesh.
4371    pub fn add_triangles(&mut self, spec: TriangleSpec<'_>) -> ItemHandle {
4372        let bounds = xy_bounds(spec.x, spec.y, YAxis::Left);
4373        let visual = triangle_spec_legend_visual(&spec);
4374        let handle = self.backend.add_triangles(spec);
4375        self.record_item(handle, PlotItemKind::Triangles, bounds, None, visual);
4376        handle
4377    }
4378
4379    /// Add a triangle mesh from an existing [`Triangles`] value.
4380    pub fn add_triangles_data(&mut self, triangles: &Triangles) -> ItemHandle {
4381        self.add_triangles(triangle_spec_from_data(triangles))
4382    }
4383
4384    /// Add a triangle mesh from [`Triangles`] and assign a legend label.
4385    pub fn add_triangles_data_with_legend(
4386        &mut self,
4387        triangles: &Triangles,
4388        legend: impl Into<String>,
4389    ) -> ItemHandle {
4390        let handle = self.add_triangles_data(triangles);
4391        self.set_item_legend(handle, legend);
4392        handle
4393    }
4394
4395    /// Add a shape overlay.
4396    pub fn add_shape(&mut self, spec: ShapeSpec<'_>) -> ItemHandle {
4397        let bounds = xy_bounds(spec.x, spec.y, YAxis::Left);
4398        let visual = shape_spec_legend_visual(&spec);
4399        let handle = self.backend.add_shape(spec);
4400        self.record_item(handle, PlotItemKind::Shape, bounds, None, visual);
4401        handle
4402    }
4403
4404    /// Add a shape overlay from an existing [`Shape`] value.
4405    pub fn add_shape_data(&mut self, shape: &Shape) -> ItemHandle {
4406        self.add_shape(shape_spec_from_data(shape))
4407    }
4408
4409    /// Add a shape overlay from [`Shape`] and assign a legend label.
4410    pub fn add_shape_data_with_legend(
4411        &mut self,
4412        shape: &Shape,
4413        legend: impl Into<String>,
4414    ) -> ItemHandle {
4415        let handle = self.add_shape_data(shape);
4416        self.set_item_legend(handle, legend);
4417        handle
4418    }
4419
4420    /// Add a rectangle shape.
4421    pub fn add_rectangle(
4422        &mut self,
4423        x0: f64,
4424        y0: f64,
4425        x1: f64,
4426        y1: f64,
4427        color: Color32,
4428        fill: bool,
4429    ) -> ItemHandle {
4430        let x = [x0, x1];
4431        let y = [y0, y1];
4432        self.add_shape(ShapeSpec {
4433            x: &x,
4434            y: &y,
4435            kind: ShapeKind::Rectangle,
4436            color,
4437            fill,
4438            overlay: false,
4439            line_style: LineStyle::Solid,
4440            line_width: 1.0,
4441            gap_color: None,
4442        })
4443    }
4444
4445    /// Add a point or line marker.
4446    pub fn add_marker(&mut self, spec: MarkerSpec<'_>) -> ItemHandle {
4447        let visual = marker_spec_legend_visual(&spec);
4448        let handle = self.backend.add_marker(spec);
4449        self.record_item(
4450            handle,
4451            PlotItemKind::Marker,
4452            DataBounds::default(),
4453            None,
4454            visual,
4455        );
4456        handle
4457    }
4458
4459    /// Add a marker from an existing [`Marker`] value.
4460    pub fn add_marker_data(&mut self, marker: &Marker) -> ItemHandle {
4461        self.add_marker(marker_spec_from_data(marker))
4462    }
4463
4464    /// Add a marker from [`Marker`] and assign a legend label.
4465    pub fn add_marker_data_with_legend(
4466        &mut self,
4467        marker: &Marker,
4468        legend: impl Into<String>,
4469    ) -> ItemHandle {
4470        let handle = self.add_marker_data(marker);
4471        self.set_item_legend(handle, legend);
4472        handle
4473    }
4474
4475    /// Add a point marker.
4476    pub fn add_point_marker(
4477        &mut self,
4478        x: f64,
4479        y: f64,
4480        color: Color32,
4481        symbol: Symbol,
4482    ) -> ItemHandle {
4483        self.add_marker(MarkerSpec {
4484            x: Some(x),
4485            y: Some(y),
4486            text: None,
4487            color,
4488            symbol: Some(symbol),
4489            symbol_size: 8.0,
4490            line_style: LineStyle::Solid,
4491            line_width: 1.0,
4492            y_axis: YAxis::Left,
4493            bg_color: None,
4494            is_draggable: false,
4495            constraint: MarkerConstraint::None,
4496        })
4497    }
4498
4499    /// Add a vertical marker line.
4500    pub fn add_x_marker(&mut self, x: f64, color: Color32) -> ItemHandle {
4501        self.add_marker(MarkerSpec {
4502            x: Some(x),
4503            y: None,
4504            text: None,
4505            color,
4506            symbol: None,
4507            symbol_size: 0.0,
4508            line_style: LineStyle::Solid,
4509            line_width: 1.0,
4510            y_axis: YAxis::Left,
4511            bg_color: None,
4512            is_draggable: false,
4513            constraint: MarkerConstraint::None,
4514        })
4515    }
4516
4517    /// Add a horizontal marker line.
4518    pub fn add_y_marker(&mut self, y: f64, color: Color32, axis: YAxis) -> ItemHandle {
4519        self.add_marker(MarkerSpec {
4520            x: None,
4521            y: Some(y),
4522            text: None,
4523            color,
4524            symbol: None,
4525            symbol_size: 0.0,
4526            line_style: LineStyle::Solid,
4527            line_width: 1.0,
4528            y_axis: axis,
4529            bg_color: None,
4530            is_draggable: false,
4531            constraint: MarkerConstraint::None,
4532        })
4533    }
4534
4535    /// The current data position `(x, y)` of the marker `handle` (silx
4536    /// `MarkerBase.getPosition`), or `None` if no marker with that handle
4537    /// exists. For a line marker the off-axis coordinate is reported as `0.0`
4538    /// (see [`Marker::position`]).
4539    pub fn marker_position(&self, handle: ItemHandle) -> Option<(f64, f64)> {
4540        self.backend.marker(handle).map(Marker::position)
4541    }
4542
4543    /// Move the marker `handle` to data position `(x, y)`, applying the marker's
4544    /// drag constraint (silx `MarkerBase.setPosition`), and emit
4545    /// [`PlotEvent::MarkerMoved`]. Returns `false` if no marker with that handle
4546    /// exists (no event is emitted in that case).
4547    ///
4548    /// The constraint is applied via [`Marker::drag`] anchored at the marker's
4549    /// current position, so a `'horizontal'` / `'vertical'` preset pins the
4550    /// constrained coordinate exactly as an on-screen drag would. A
4551    /// non-draggable marker does not move (`Marker::drag` is a no-op when
4552    /// `is_draggable` is `false`), matching silx, but the call still returns
4553    /// `true` and emits the event because the marker exists.
4554    pub fn set_marker_position(&mut self, handle: ItemHandle, x: f64, y: f64) -> bool {
4555        let Some(mut marker) = self.backend.marker(handle).cloned() else {
4556            return false;
4557        };
4558        marker.drag(marker.position(), (x, y));
4559        self.backend.update_marker(handle, marker);
4560        self.events.push(PlotEvent::MarkerMoved { handle });
4561        true
4562    }
4563
4564    /// Remove an item by handle.
4565    pub fn remove(&mut self, handle: ItemHandle) -> bool {
4566        let kind = self.item_record(handle).map(|record| record.kind);
4567        let removed = self.backend.remove(handle);
4568        if removed {
4569            self.item_records.retain(|record| record.handle != handle);
4570            if let Some(kind) = kind {
4571                self.events.push(PlotEvent::ItemRemoved { handle, kind });
4572            }
4573            self.clear_active_if_missing();
4574            self.recompute_data_bounds();
4575            self.apply_auto_limits();
4576        }
4577        removed
4578    }
4579
4580    /// Remove all items.
4581    pub fn clear(&mut self) {
4582        let removed: Vec<(ItemHandle, PlotItemKind)> = self
4583            .item_records
4584            .iter()
4585            .map(|record| (record.handle, record.kind))
4586            .collect();
4587        self.backend.clear_items();
4588        self.item_records.clear();
4589        for (handle, kind) in removed {
4590            self.events.push(PlotEvent::ItemRemoved { handle, kind });
4591        }
4592        self.clear_active_if_missing();
4593        self.recompute_data_bounds();
4594    }
4595
4596    /// Remove all curve-like items.
4597    pub fn clear_curves(&mut self) {
4598        self.remove_records_by_kinds(PlotItemKind::is_curve_like);
4599    }
4600
4601    /// Remove all image-like items.
4602    pub fn clear_images(&mut self) {
4603        self.remove_records_by_kinds(PlotItemKind::is_image_like);
4604    }
4605
4606    /// Remove all shape and triangle overlay items.
4607    pub fn clear_items(&mut self) {
4608        self.remove_records_by_kinds(|kind| {
4609            matches!(kind, PlotItemKind::Shape | PlotItemKind::Triangles)
4610        });
4611    }
4612
4613    /// Remove all marker items.
4614    pub fn clear_markers(&mut self) {
4615        self.remove_records_by_kinds(|kind| kind == PlotItemKind::Marker);
4616    }
4617
4618    /// Remove all histogram items.
4619    pub fn clear_histograms(&mut self) {
4620        self.remove_records_by_kinds(|kind| kind == PlotItemKind::Histogram);
4621    }
4622
4623    /// Remove all scatter items.
4624    pub fn clear_scatters(&mut self) {
4625        self.remove_records_by_kinds(|kind| kind == PlotItemKind::Scatter);
4626    }
4627
4628    /// Remove all mask overlay items.
4629    pub fn clear_masks(&mut self) {
4630        self.remove_records_by_kinds(|kind| kind == PlotItemKind::Mask);
4631    }
4632
4633    fn remove_if_kind(
4634        &mut self,
4635        handle: ItemHandle,
4636        predicate: impl Fn(PlotItemKind) -> bool,
4637    ) -> bool {
4638        if self.item_kind(handle).is_some_and(predicate) {
4639            self.remove(handle)
4640        } else {
4641            false
4642        }
4643    }
4644
4645    /// Remove a curve-like item by handle.
4646    pub fn remove_curve(&mut self, handle: ItemHandle) -> bool {
4647        self.remove_if_kind(handle, PlotItemKind::is_curve_like)
4648    }
4649
4650    /// Remove an image-like item by handle.
4651    pub fn remove_image(&mut self, handle: ItemHandle) -> bool {
4652        self.remove_if_kind(handle, PlotItemKind::is_image_like)
4653    }
4654
4655    /// Remove a histogram item by handle.
4656    pub fn remove_histogram(&mut self, handle: ItemHandle) -> bool {
4657        self.remove_if_kind(handle, |kind| kind == PlotItemKind::Histogram)
4658    }
4659
4660    /// Remove a scatter item by handle.
4661    pub fn remove_scatter(&mut self, handle: ItemHandle) -> bool {
4662        self.remove_if_kind(handle, |kind| kind == PlotItemKind::Scatter)
4663    }
4664
4665    /// Remove a mask item by handle.
4666    pub fn remove_mask(&mut self, handle: ItemHandle) -> bool {
4667        self.remove_if_kind(handle, |kind| kind == PlotItemKind::Mask)
4668    }
4669
4670    /// Remove a marker item by handle.
4671    pub fn remove_marker(&mut self, handle: ItemHandle) -> bool {
4672        self.remove_if_kind(handle, |kind| kind == PlotItemKind::Marker)
4673    }
4674
4675    /// Remove a shape or triangle overlay item by handle.
4676    pub fn remove_overlay_item(&mut self, handle: ItemHandle) -> bool {
4677        self.remove_if_kind(handle, |kind| {
4678            matches!(kind, PlotItemKind::Shape | PlotItemKind::Triangles)
4679        })
4680    }
4681
4682    /// Return every backend item handle in draw order.
4683    pub fn get_items(&self) -> Vec<ItemHandle> {
4684        self.backend.items_back_to_front()
4685    }
4686
4687    /// Return curve-like handles in draw order.
4688    pub fn get_all_curves(&self) -> Vec<ItemHandle> {
4689        self.handles_by_predicate(PlotItemKind::is_curve_like)
4690    }
4691
4692    /// Return image-like handles in draw order.
4693    pub fn get_all_images(&self) -> Vec<ItemHandle> {
4694        self.handles_by_predicate(PlotItemKind::is_image_like)
4695    }
4696
4697    /// Return marker handles in draw order.
4698    pub fn get_all_markers(&self) -> Vec<ItemHandle> {
4699        self.handles_by_kind(PlotItemKind::Marker)
4700    }
4701
4702    /// Return histogram handles in draw order.
4703    pub fn get_all_histograms(&self) -> Vec<ItemHandle> {
4704        self.handles_by_kind(PlotItemKind::Histogram)
4705    }
4706
4707    /// Return scatter handles in draw order.
4708    pub fn get_all_scatters(&self) -> Vec<ItemHandle> {
4709        self.handles_by_kind(PlotItemKind::Scatter)
4710    }
4711
4712    /// Return mask handles in draw order.
4713    pub fn get_all_masks(&self) -> Vec<ItemHandle> {
4714        self.handles_by_kind(PlotItemKind::Mask)
4715    }
4716
4717    fn handles_by_kind(&self, kind: PlotItemKind) -> Vec<ItemHandle> {
4718        self.handles_by_predicate(|record_kind| record_kind == kind)
4719    }
4720
4721    fn handles_by_predicate(&self, predicate: impl Fn(PlotItemKind) -> bool) -> Vec<ItemHandle> {
4722        self.item_records
4723            .iter()
4724            .filter_map(|record| predicate(record.kind).then_some(record.handle))
4725            .collect()
4726    }
4727
4728    fn handle_by_legend_and_kind(
4729        &self,
4730        legend: &str,
4731        predicate: impl Fn(PlotItemKind) -> bool,
4732    ) -> Option<ItemHandle> {
4733        self.item_records.iter().find_map(|record| {
4734            (record.legend.as_deref() == Some(legend) && predicate(record.kind))
4735                .then_some(record.handle)
4736        })
4737    }
4738
4739    /// Return the first item handle with this legend label.
4740    pub fn item_by_legend(&self, legend: &str) -> Option<ItemHandle> {
4741        self.handle_by_legend_and_kind(legend, |_| true)
4742    }
4743
4744    /// Return the first curve-like item handle with this legend label.
4745    pub fn curve_by_legend(&self, legend: &str) -> Option<ItemHandle> {
4746        self.handle_by_legend_and_kind(legend, PlotItemKind::is_curve_like)
4747    }
4748
4749    /// Return the first image-like item handle with this legend label.
4750    pub fn image_by_legend(&self, legend: &str) -> Option<ItemHandle> {
4751        self.handle_by_legend_and_kind(legend, PlotItemKind::is_image_like)
4752    }
4753
4754    /// Return the first histogram item handle with this legend label.
4755    pub fn histogram_by_legend(&self, legend: &str) -> Option<ItemHandle> {
4756        self.handle_by_legend_and_kind(legend, |kind| kind == PlotItemKind::Histogram)
4757    }
4758
4759    /// Return the first scatter item handle with this legend label.
4760    pub fn scatter_by_legend(&self, legend: &str) -> Option<ItemHandle> {
4761        self.handle_by_legend_and_kind(legend, |kind| kind == PlotItemKind::Scatter)
4762    }
4763
4764    /// Return the first mask item handle with this legend label.
4765    pub fn mask_by_legend(&self, legend: &str) -> Option<ItemHandle> {
4766        self.handle_by_legend_and_kind(legend, |kind| kind == PlotItemKind::Mask)
4767    }
4768
4769    /// Return the high-level family of an item.
4770    pub fn item_kind(&self, handle: ItemHandle) -> Option<PlotItemKind> {
4771        self.item_record(handle).map(|record| record.kind)
4772    }
4773
4774    /// Attach or replace the legend label for an item.
4775    pub fn set_item_legend(&mut self, handle: ItemHandle, legend: impl Into<String>) -> bool {
4776        let Some(record) = self.item_record_mut(handle) else {
4777            return false;
4778        };
4779        record.legend = Some(legend.into());
4780        let kind = record.kind;
4781        self.events.push(PlotEvent::ItemUpdated { handle, kind });
4782        true
4783    }
4784
4785    /// Remove the legend label from an item.
4786    pub fn clear_item_legend(&mut self, handle: ItemHandle) -> bool {
4787        let Some(record) = self.item_record_mut(handle) else {
4788            return false;
4789        };
4790        record.legend = None;
4791        let kind = record.kind;
4792        self.events.push(PlotEvent::ItemUpdated { handle, kind });
4793        true
4794    }
4795
4796    /// Legend label assigned to an item.
4797    pub fn item_legend(&self, handle: ItemHandle) -> Option<&str> {
4798        self.item_record(handle)
4799            .and_then(|record| record.legend.as_deref())
4800    }
4801
4802    /// Set the curve's X-axis label, shown on the X axis while it is the active
4803    /// curve (silx `Curve.setXLabel`). The active curve's label overrides the
4804    /// graph default ([`Self::set_graph_x_label`]). Returns `false` for an
4805    /// unknown handle.
4806    pub fn set_curve_x_label(&mut self, handle: ItemHandle, label: impl Into<String>) -> bool {
4807        let Some(record) = self.item_record_mut(handle) else {
4808            return false;
4809        };
4810        record.x_label = Some(label.into());
4811        let kind = record.kind;
4812        self.events.push(PlotEvent::ItemUpdated { handle, kind });
4813        true
4814    }
4815
4816    /// Remove the curve's X-axis label, so the graph default shows when it is
4817    /// active (silx setting the label back to `None`).
4818    pub fn clear_curve_x_label(&mut self, handle: ItemHandle) -> bool {
4819        let Some(record) = self.item_record_mut(handle) else {
4820            return false;
4821        };
4822        record.x_label = None;
4823        let kind = record.kind;
4824        self.events.push(PlotEvent::ItemUpdated { handle, kind });
4825        true
4826    }
4827
4828    /// The curve's X-axis label, if set (silx `Curve.getXLabel`).
4829    pub fn curve_x_label(&self, handle: ItemHandle) -> Option<&str> {
4830        self.item_record(handle)
4831            .and_then(|record| record.x_label.as_deref())
4832    }
4833
4834    /// Set the curve's Y-axis label, shown on the left or right (y2) axis (per
4835    /// the curve's Y-axis binding) while it is the active curve (silx
4836    /// `Curve.setYLabel`). Returns `false` for an unknown handle.
4837    pub fn set_curve_y_label(&mut self, handle: ItemHandle, label: impl Into<String>) -> bool {
4838        let Some(record) = self.item_record_mut(handle) else {
4839            return false;
4840        };
4841        record.y_label = Some(label.into());
4842        let kind = record.kind;
4843        self.events.push(PlotEvent::ItemUpdated { handle, kind });
4844        true
4845    }
4846
4847    /// Remove the curve's Y-axis label, so the graph default shows when it is
4848    /// active.
4849    pub fn clear_curve_y_label(&mut self, handle: ItemHandle) -> bool {
4850        let Some(record) = self.item_record_mut(handle) else {
4851            return false;
4852        };
4853        record.y_label = None;
4854        let kind = record.kind;
4855        self.events.push(PlotEvent::ItemUpdated { handle, kind });
4856        true
4857    }
4858
4859    /// The curve's Y-axis label, if set (silx `Curve.getYLabel`).
4860    pub fn curve_y_label(&self, handle: ItemHandle) -> Option<&str> {
4861        self.item_record(handle)
4862            .and_then(|record| record.y_label.as_deref())
4863    }
4864
4865    fn legend_label(&self, record: &ItemRecord) -> String {
4866        record
4867            .legend
4868            .clone()
4869            .unwrap_or_else(|| format!("{} #{}", record.kind.as_str(), record.handle))
4870    }
4871
4872    /// Currently active item.
4873    pub fn active_item(&self) -> Option<ItemHandle> {
4874        self.active_item
4875    }
4876
4877    /// Set the active item, emitting [`PlotEvent::ActiveItemChanged`] when it changes.
4878    pub fn set_active_item(&mut self, item: Option<ItemHandle>) -> bool {
4879        if item.is_some_and(|handle| !self.has_item(handle)) {
4880            return false;
4881        }
4882        if self.active_item == item {
4883            return true;
4884        }
4885        let previous = self.active_item;
4886        self.active_item = item;
4887        // Re-apply the highlight through the single owner now that the active
4888        // item changed: revert the old curve to its base, apply the highlight
4889        // to the new one (silx `_setActiveItem`: setHighlighted(False) on the
4890        // old curve, setHighlighted(True) on the new). `sync_curve_highlight`
4891        // no-ops on non-curve handles, so images/scatter are unaffected.
4892        if let Some(previous) = previous {
4893            self.sync_curve_highlight(previous);
4894        }
4895        if let Some(current) = item {
4896            self.sync_curve_highlight(current);
4897        }
4898        self.events.push(PlotEvent::ActiveItemChanged {
4899            previous,
4900            current: item,
4901        });
4902        true
4903    }
4904
4905    /// Currently active curve-like item.
4906    pub fn active_curve(&self) -> Option<ItemHandle> {
4907        self.active_item.filter(|handle| {
4908            self.item_kind(*handle)
4909                .is_some_and(PlotItemKind::is_curve_like)
4910        })
4911    }
4912
4913    /// The override style applied to the active curve when active-curve
4914    /// handling is enabled (silx `PlotWidget.getActiveCurveStyle`).
4915    pub fn active_curve_style(&self) -> &CurveStyle {
4916        &self.active_curve_style
4917    }
4918
4919    /// Whether the active curve is highlighted with the active-curve style
4920    /// (silx `PlotWidget.isActiveCurveHandling`).
4921    pub fn is_active_curve_handling(&self) -> bool {
4922        self.active_curve_handling
4923    }
4924
4925    /// Set the override style applied to the active curve (silx
4926    /// `PlotWidget.setActiveCurveStyle`), then re-apply it to the active curve
4927    /// through the single highlight owner so the change takes effect at once.
4928    pub fn set_active_curve_style(&mut self, style: CurveStyle) {
4929        self.active_curve_style = style;
4930        if let Some(handle) = self.active_curve() {
4931            self.sync_curve_highlight(handle);
4932        }
4933    }
4934
4935    /// Enable or disable active-curve highlighting (silx
4936    /// `PlotWidget.setActiveCurveHandling`), then re-sync the active curve:
4937    /// enabling applies the highlight, disabling reverts it to its base style.
4938    pub fn set_active_curve_handling(&mut self, enabled: bool) {
4939        self.active_curve_handling = enabled;
4940        if let Some(handle) = self.active_curve() {
4941            self.sync_curve_highlight(handle);
4942        }
4943    }
4944
4945    /// The current line style of the active curve-like item, if one is active
4946    /// and its style is retained.
4947    pub fn active_curve_line_style(&self) -> Option<LineStyle> {
4948        let handle = self.active_curve()?;
4949        self.record_curve_data(handle)
4950            .map(|data| data.line_style.clone())
4951    }
4952
4953    /// Whether curves default to being drawn with a connecting line (silx
4954    /// `PlotWidget.isDefaultPlotLines`).
4955    pub fn is_default_plot_lines(&self) -> bool {
4956        self.default_plot_lines
4957    }
4958
4959    /// Whether curves default to being drawn with point markers (silx
4960    /// `PlotWidget.isDefaultPlotPoints`).
4961    pub fn is_default_plot_points(&self) -> bool {
4962        self.default_plot_points
4963    }
4964
4965    /// Set the default line style of every curve, mirroring silx
4966    /// `PlotWidget.setDefaultPlotLines`: `true` applies a solid line (silx
4967    /// `"-"`), `false` removes the line (silx `" "`). Like silx, this resets the
4968    /// line style of all existing curves (silx iterates `getAllCurves`; siplot
4969    /// iterates [`PlotItemKind::Curve`] items, the equivalent set — histograms
4970    /// and scatters are excluded). Returns the number of curves whose line style
4971    /// actually changed.
4972    pub fn set_default_plot_lines(&mut self, flag: bool) -> usize {
4973        self.default_plot_lines = flag;
4974        let line_style = if flag {
4975            LineStyle::Solid
4976        } else {
4977            LineStyle::None
4978        };
4979        let mut changed = 0;
4980        for handle in self.handles_by_kind(PlotItemKind::Curve) {
4981            let Some(mut data) = self.record_curve_data(handle).cloned() else {
4982                continue;
4983            };
4984            if data.line_style == line_style {
4985                continue;
4986            }
4987            data.line_style = line_style.clone();
4988            if self.update_curve_data(handle, &data) {
4989                changed += 1;
4990            }
4991        }
4992        changed
4993    }
4994
4995    /// Set the default symbol of every curve, mirroring silx
4996    /// `PlotWidget.setDefaultPlotPoints`: `true` applies the `o` (Circle) symbol
4997    /// (`silx.config.DEFAULT_PLOT_SYMBOL`), `false` removes the symbol. Resets
4998    /// the symbol of all existing curves (same item set as
4999    /// [`Self::set_default_plot_lines`]). Returns the number of curves whose
5000    /// symbol actually changed.
5001    pub fn set_default_plot_points(&mut self, flag: bool) -> usize {
5002        self.default_plot_points = flag;
5003        let symbol = if flag { Some(Symbol::Circle) } else { None };
5004        let mut changed = 0;
5005        for handle in self.handles_by_kind(PlotItemKind::Curve) {
5006            let Some(mut data) = self.record_curve_data(handle).cloned() else {
5007                continue;
5008            };
5009            if data.symbol == symbol {
5010                continue;
5011            }
5012            data.symbol = symbol;
5013            if self.update_curve_data(handle, &data) {
5014                changed += 1;
5015            }
5016        }
5017        changed
5018    }
5019
5020    /// Cycle the plot-wide default curve style, mirroring silx
5021    /// `CurveStyleAction`: advances the `(lines, points)` state line-only →
5022    /// line+symbol → symbol-only → line-only (via
5023    /// [`crate::widget::actions::control::next_curve_style_state`]), then applies
5024    /// the new defaults to every curve through [`Self::set_default_plot_lines`]
5025    /// and [`Self::set_default_plot_points`]. Returns the new `(lines, points)`
5026    /// state.
5027    pub fn cycle_curve_style(&mut self) -> (bool, bool) {
5028        let next = crate::widget::actions::control::next_curve_style_state((
5029            self.default_plot_lines,
5030            self.default_plot_points,
5031        ));
5032        self.set_default_plot_lines(next.0);
5033        self.set_default_plot_points(next.1);
5034        next
5035    }
5036
5037    /// Move a curve between the left (`YAxis::Left`) and right (`YAxis::Right`)
5038    /// Y axis (silx legend `Map to left` / `Map to right`). Clones the retained
5039    /// [`CurveData`], sets `y_axis`, re-applies it through
5040    /// [`Self::update_curve_data`], then recomputes auto limits because the
5041    /// curve's bounds now contribute to a different Y/Y2 range. Returns `false`
5042    /// if the handle is unknown or has no retained curve data (non-curve item).
5043    pub fn set_curve_y_axis(&mut self, handle: ItemHandle, axis: YAxis) -> bool {
5044        let Some(mut data) = self.record_curve_data(handle).cloned() else {
5045            return false;
5046        };
5047        data.y_axis = axis;
5048        if !self.update_curve_data(handle, &data) {
5049            return false;
5050        }
5051        // Moving a curve between the Left and Right axes changes which axis its
5052        // data feeds, so the left-Y / right-Y data bounds must be re-evaluated
5053        // (same auto-limit path `remove` uses).
5054        self.recompute_data_bounds();
5055        self.apply_auto_limits();
5056        true
5057    }
5058
5059    /// Show or hide a curve's point markers (silx legend checkable `Points`).
5060    /// Toggling is lossless: hiding stashes the current [`Symbol`] in a UI-only
5061    /// restore cache and showing restores that exact variant (falling back to a
5062    /// default only if the curve was created with no symbol). The drawn state
5063    /// is decided solely by [`CurveData::symbol`]; the cache is never read by
5064    /// any render/bounds/legend path. Returns `false` if the handle is unknown
5065    /// or has no retained curve data (non-curve item).
5066    pub fn set_curve_points_visible(&mut self, handle: ItemHandle, visible: bool) -> bool {
5067        let Some(mut data) = self.record_curve_data(handle).cloned() else {
5068            return false;
5069        };
5070        // Read (do not yet consume) the record's restore cache, transform with
5071        // the pure free fn, then commit the cache write-back ONLY after the
5072        // drawn-state transition (`update_curve_data`) succeeds. This keeps the
5073        // UI restore cache in lockstep with `CurveData.symbol`: a failed update
5074        // leaves both the drawn symbol and the cache untouched.
5075        let mut cache = self
5076            .item_record(handle)
5077            .and_then(|record| record.hidden_symbol);
5078        let next = set_symbol_visibility(data.symbol, visible, &mut cache);
5079        data.symbol = next;
5080        if !self.update_curve_data(handle, &data) {
5081            return false;
5082        }
5083        if let Some(record) = self.item_record_mut(handle) {
5084            record.hidden_symbol = cache;
5085        }
5086        true
5087    }
5088
5089    /// Show or hide a curve's connecting line (silx legend checkable `Lines`).
5090    /// Toggling is lossless: hiding stashes the current [`LineStyle`] in a
5091    /// UI-only restore cache and showing restores that exact style (e.g.
5092    /// `Dashed`), falling back to a solid line only if the curve was created
5093    /// with no line. The drawn state is decided solely by
5094    /// [`CurveData::line_style`]; the cache is never read by any
5095    /// render/bounds/legend path. Returns `false` if the handle is unknown or
5096    /// has no retained curve data (non-curve item).
5097    pub fn set_curve_lines_visible(&mut self, handle: ItemHandle, visible: bool) -> bool {
5098        let Some(mut data) = self.record_curve_data(handle).cloned() else {
5099            return false;
5100        };
5101        // Same finalizer ordering as `set_curve_points_visible`: read the cache
5102        // without consuming it, transform, and commit the write-back only once
5103        // `update_curve_data` confirms the drawn line style actually changed.
5104        let mut cache = self
5105            .item_record(handle)
5106            .and_then(|record| record.hidden_line_style.clone());
5107        let next = set_line_visibility(data.line_style.clone(), visible, &mut cache);
5108        data.line_style = next;
5109        if !self.update_curve_data(handle, &data) {
5110            return false;
5111        }
5112        if let Some(record) = self.item_record_mut(handle) {
5113            record.hidden_line_style = cache;
5114        }
5115        true
5116    }
5117
5118    /// Handles of every curve-like item that carries retained [`CurveData`]
5119    /// (curve, histogram, scatter) — the siplot equivalent of the silx
5120    /// `SymbolMixIn` items a `SymbolToolButton` iterates over.
5121    fn symbol_bearing_handles(&self) -> Vec<ItemHandle> {
5122        self.item_records
5123            .iter()
5124            .filter(|record| record.curve_data.is_some())
5125            .map(|record| record.handle)
5126            .collect()
5127    }
5128
5129    /// Set the marker symbol of every curve-like item (curve, histogram,
5130    /// scatter) in one call, mirroring silx `SymbolToolButton` /
5131    /// `_SymbolToolButtonBase._markerChanged`, which calls `setSymbol` on every
5132    /// `SymbolMixIn` item in the plot. `Some(symbol)` draws that marker at each
5133    /// point; `None` hides the markers (silx's "None" entry, an empty symbol
5134    /// string). Returns the number of items whose symbol actually changed.
5135    pub fn set_all_symbols(&mut self, symbol: Option<Symbol>) -> usize {
5136        let mut changed = 0;
5137        for handle in self.symbol_bearing_handles() {
5138            let Some(mut data) = self.record_curve_data(handle).cloned() else {
5139                continue;
5140            };
5141            if data.symbol == symbol {
5142                continue;
5143            }
5144            data.symbol = symbol;
5145            if self.update_curve_data(handle, &data) {
5146                changed += 1;
5147            }
5148        }
5149        changed
5150    }
5151
5152    /// Set the marker size (logical points) of every curve-like item, mirroring
5153    /// silx `SymbolToolButton` / `_SymbolToolButtonBase._sizeChanged`, which
5154    /// calls `setSymbolSize` on every single-symbol-size `SymbolMixIn` item.
5155    /// siplot curves carry one size per item (no per-point sizes), so every
5156    /// curve-like item qualifies. Returns the number of items whose size
5157    /// actually changed.
5158    pub fn set_all_symbol_sizes(&mut self, size: f32) -> usize {
5159        let mut changed = 0;
5160        for handle in self.symbol_bearing_handles() {
5161            let Some(mut data) = self.record_curve_data(handle).cloned() else {
5162                continue;
5163            };
5164            if data.marker_size == size {
5165                continue;
5166            }
5167            data.marker_size = size;
5168            if self.update_curve_data(handle, &data) {
5169                changed += 1;
5170            }
5171        }
5172        changed
5173    }
5174
5175    /// Set the active curve-like item.
5176    pub fn set_active_curve(&mut self, item: Option<ItemHandle>) -> bool {
5177        if item.is_some_and(|handle| {
5178            !self
5179                .item_kind(handle)
5180                .is_some_and(PlotItemKind::is_curve_like)
5181        }) {
5182            return false;
5183        }
5184        self.set_active_item(item)
5185    }
5186
5187    /// Currently active image-like item.
5188    pub fn active_image(&self) -> Option<ItemHandle> {
5189        self.active_item.filter(|handle| {
5190            self.item_kind(*handle)
5191                .is_some_and(PlotItemKind::is_image_like)
5192        })
5193    }
5194
5195    /// Set the active image-like item.
5196    pub fn set_active_image(&mut self, item: Option<ItemHandle>) -> bool {
5197        if item.is_some_and(|handle| {
5198            !self
5199                .item_kind(handle)
5200                .is_some_and(PlotItemKind::is_image_like)
5201        }) {
5202            return false;
5203        }
5204        self.set_active_item(item)
5205    }
5206
5207    /// Show or hide an item. Hidden items are excluded from all draw passes.
5208    /// Returns `false` if the handle is unknown.
5209    pub fn set_item_visible(&mut self, handle: ItemHandle, visible: bool) -> bool {
5210        self.backend.set_item_visible(handle, visible)
5211    }
5212
5213    /// Whether an item is currently visible.
5214    pub fn is_item_visible(&self, handle: ItemHandle) -> bool {
5215        self.backend.is_item_visible(handle)
5216    }
5217
5218    /// Set the draw-order z-value for an item. Within each GPU item layer
5219    /// (images, curves), higher-z items are drawn on top.
5220    /// Returns `false` if the handle is unknown.
5221    pub fn set_item_z(&mut self, handle: ItemHandle, z: f32) -> bool {
5222        self.backend.set_item_z(handle, z)
5223    }
5224
5225    /// Current z-value for an item.
5226    pub fn item_z_value(&self, handle: ItemHandle) -> f32 {
5227        self.backend.item_z(handle)
5228    }
5229
5230    /// Return retained statistics for an item.
5231    pub fn item_stats(&self, handle: ItemHandle) -> Option<&ItemStats> {
5232        self.item_record(handle)
5233            .and_then(|record| record.stats.as_ref())
5234    }
5235
5236    /// Return retained statistics for a curve-like item.
5237    pub fn curve_stats(&self, handle: ItemHandle) -> Option<&CurveStats> {
5238        match self.item_stats(handle)? {
5239            ItemStats::Curve(stats) => Some(stats),
5240            ItemStats::Image(_) => None,
5241        }
5242    }
5243
5244    /// Return retained statistics for an image-like item.
5245    pub fn image_stats(&self, handle: ItemHandle) -> Option<&ImageStats> {
5246        match self.item_stats(handle)? {
5247            ItemStats::Curve(_) => None,
5248            ItemStats::Image(stats) => Some(stats),
5249        }
5250    }
5251
5252    /// Draw a selectable legend list. Clicking a row body makes it active;
5253    /// clicking the eye icon toggles visibility.
5254    pub fn show_legend(&mut self, ui: &mut egui::Ui) -> LegendResponse {
5255        let rows: Vec<(ItemHandle, PlotItemKind, String, bool, bool, LegendVisual)> = self
5256            .item_records
5257            .iter()
5258            .map(|record| {
5259                let visible = self.backend.is_item_visible(record.handle);
5260                (
5261                    record.handle,
5262                    record.kind,
5263                    self.legend_label(record),
5264                    self.active_item == Some(record.handle),
5265                    visible,
5266                    record.visual.clone(),
5267                )
5268            })
5269            .collect();
5270
5271        let mut out = LegendResponse::default();
5272        if rows.is_empty() {
5273            ui.label("no items");
5274            return out;
5275        }
5276
5277        egui::Frame::new()
5278            .inner_margin(1)
5279            .stroke(ui.visuals().widgets.noninteractive.bg_stroke)
5280            .show(ui, |ui| {
5281                ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
5282                let width = legend_row_width(ui.available_width());
5283                for (handle, kind, label, active, visible, visual) in rows {
5284                    let result =
5285                        legend_row_response(ui, width, kind, &label, active, visible, visual);
5286                    if result.row_clicked {
5287                        out.selected = Some(handle);
5288                        if self.set_active_item(Some(handle)) {
5289                            out.activated = Some(handle);
5290                        }
5291                    }
5292                    if result.eye_clicked {
5293                        self.backend.set_item_visible(handle, !visible);
5294                        out.visibility_changed = Some(handle);
5295                    }
5296                    // Right-click context menu (silx LegendListContextMenu).
5297                    // The closure both self-applies the action and records it.
5298                    result.row_response.context_menu(|ui| {
5299                        if let Some(action) = self.legend_context_menu_ui(ui, handle, kind, active)
5300                        {
5301                            out.context_action = Some((handle, action));
5302                        }
5303                    });
5304                }
5305            });
5306        // The rename popup (silx RenameCurveDialog) is rendered after the rows
5307        // so it floats above the legend, and from inside show_legend so the
5308        // legend stays self-contained.
5309        self.show_rename_popup(ui);
5310        out
5311    }
5312
5313    /// Build the legend right-click context menu for one row, self-applying the
5314    /// chosen action and returning the [`LegendAction`] that fired (if any).
5315    /// Checkable / current-axis state is re-read from the record on every call
5316    /// so a reopened menu reflects the up-to-date Points/Lines/axis state.
5317    fn legend_context_menu_ui(
5318        &mut self,
5319        ui: &mut egui::Ui,
5320        handle: ItemHandle,
5321        kind: PlotItemKind,
5322        active: bool,
5323    ) -> Option<LegendAction> {
5324        let mut fired: Option<LegendAction> = None;
5325
5326        // Set Active: disabled when the item is already active (silx omits
5327        // re-activating the active curve; we degrade to a disabled entry).
5328        if ui
5329            .add_enabled(!active, egui::Button::new("Set Active"))
5330            .clicked()
5331        {
5332            self.set_active_item(Some(handle));
5333            fired = Some(LegendAction::SetActive);
5334            ui.close();
5335        }
5336
5337        if matches!(kind, PlotItemKind::Curve) {
5338            // Read the live curve state for the checkable / current-axis marks.
5339            let (y_axis, symbol_visible, line_visible) = self
5340                .record_curve_data(handle)
5341                .map(|data| {
5342                    (
5343                        data.y_axis,
5344                        data.symbol.is_some(),
5345                        data.line_style.draws_line(),
5346                    )
5347                })
5348                .unwrap_or((YAxis::Left, false, false));
5349
5350            // Map to Y Left / Right: the current axis is disabled (it is already
5351            // the target), matching silx's intent of moving to the *other* axis.
5352            if ui
5353                .add_enabled(y_axis != YAxis::Left, egui::Button::new("Map to Y Left"))
5354                .clicked()
5355            {
5356                self.set_curve_y_axis(handle, YAxis::Left);
5357                fired = Some(LegendAction::MapToLeft);
5358                ui.close();
5359            }
5360            if ui
5361                .add_enabled(y_axis != YAxis::Right, egui::Button::new("Map to Y Right"))
5362                .clicked()
5363            {
5364                self.set_curve_y_axis(handle, YAxis::Right);
5365                fired = Some(LegendAction::MapToRight);
5366                ui.close();
5367            }
5368
5369            // Checkable Points / Lines: the checkmark reflects current visibility
5370            // read above; clicking toggles to the opposite state.
5371            let mut points = symbol_visible;
5372            if ui.checkbox(&mut points, "Points").clicked() {
5373                self.set_curve_points_visible(handle, points);
5374                fired = Some(LegendAction::TogglePoints);
5375                ui.close();
5376            }
5377            let mut lines = line_visible;
5378            if ui.checkbox(&mut lines, "Lines").clicked() {
5379                self.set_curve_lines_visible(handle, lines);
5380                fired = Some(LegendAction::ToggleLines);
5381                ui.close();
5382            }
5383        }
5384        // Non-curve items (Image/Scatter/Histogram/Mask/...) degrade gracefully:
5385        // only Set Active / Rename / Remove are offered, since Points/Lines/Map-Y
5386        // are curve-only (silx's legend is curve-centric).
5387
5388        ui.separator();
5389
5390        if ui.button("Rename").clicked() {
5391            // Seed the edit buffer with the current label so the popup opens
5392            // pre-filled (silx RenameCurveDialog sets the line edit text).
5393            let current = self
5394                .item_record(handle)
5395                .map(|record| self.legend_label(record))
5396                .unwrap_or_default();
5397            self.rename_state = Some((handle, current));
5398            fired = Some(LegendAction::Rename);
5399            ui.close();
5400        }
5401        if ui.button("Remove").clicked() {
5402            self.remove(handle);
5403            fired = Some(LegendAction::Remove);
5404            ui.close();
5405        }
5406
5407        fired
5408    }
5409
5410    /// Render the legend rename popup (silx `RenameCurveDialog`) when a rename
5411    /// is in progress: a single-line text field with Apply / Cancel. Apply
5412    /// commits the buffer via [`Self::set_item_legend`] and clears the state;
5413    /// Cancel or Escape clears it without committing. No-op when no rename is
5414    /// pending.
5415    fn show_rename_popup(&mut self, ui: &mut egui::Ui) {
5416        let Some((handle, mut buffer)) = self.rename_state.take() else {
5417            return;
5418        };
5419        // `keep_open` decides whether the popup state survives this frame; any
5420        // terminal action (Apply / Cancel / Escape / window close) leaves it
5421        // false so `rename_state` stays cleared.
5422        let mut keep_open = true;
5423        let mut apply = false;
5424        let id = ui.id().with(("legend_rename", handle));
5425        let signals = crate::widget::detached::show_detached(
5426            ui.ctx(),
5427            id,
5428            "Rename",
5429            egui::vec2(260.0, 110.0),
5430            None,
5431            |ui| {
5432                let edit = ui.add(egui::TextEdit::singleline(&mut buffer).desired_width(200.0));
5433                // Enter in the field applies, matching a dialog's default button.
5434                if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
5435                    apply = true;
5436                }
5437                ui.horizontal(|ui| {
5438                    if ui.button("Apply").clicked() {
5439                        apply = true;
5440                    }
5441                    if ui.button("Cancel").clicked() {
5442                        keep_open = false;
5443                    }
5444                });
5445            },
5446        );
5447        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
5448            keep_open = false;
5449        }
5450        if apply {
5451            self.set_item_legend(handle, buffer.clone());
5452            keep_open = false;
5453        }
5454        // The detached window's own close button is a Cancel.
5455        if signals.close_requested {
5456            keep_open = false;
5457        }
5458        if keep_open {
5459            self.rename_state = Some((handle, buffer));
5460        }
5461    }
5462
5463    /// Draw retained statistics for an item. Returns `false` if the handle is unknown.
5464    pub fn show_stats(&self, ui: &mut egui::Ui, handle: ItemHandle) -> bool {
5465        let Some(record) = self.item_record(handle) else {
5466            return false;
5467        };
5468        ui.label(self.legend_label(record));
5469        match record.stats {
5470            Some(ItemStats::Curve(stats)) => {
5471                show_value_stats(ui, "x", stats.x);
5472                show_value_stats(ui, "y", stats.y);
5473                ui.label(format!("axis: {:?}", stats.y_axis));
5474            }
5475            Some(ItemStats::Image(stats)) => {
5476                ui.label(format!("size: {} x {}", stats.width, stats.height));
5477                ui.label(format!("pixels: {}", stats.pixel_count));
5478                if let Some(scalar) = stats.scalar {
5479                    show_value_stats(ui, "value", scalar);
5480                }
5481            }
5482            None => {
5483                ui.label("no retained statistics");
5484            }
5485        }
5486        true
5487    }
5488
5489    /// Draw retained statistics for the active item.
5490    pub fn show_active_stats(&self, ui: &mut egui::Ui) -> bool {
5491        self.active_item
5492            .is_some_and(|handle| self.show_stats(ui, handle))
5493    }
5494
5495    /// Build a borrowed [`StatsInput`] for an item's retained raw data (silx
5496    /// `StatsWidget` per-item data), or `None` when the item is unknown / has no
5497    /// retained scalar data (e.g. an RGBA image).
5498    fn stats_input(
5499        &self,
5500        handle: ItemHandle,
5501    ) -> Option<crate::widget::stats_widget::StatsInput<'_>> {
5502        self.retained_data(handle).map(retained_data_to_stats_input)
5503    }
5504
5505    /// Feed the active item's retained data into a [`StatsWidget`](crate::StatsWidget), recomputing
5506    /// its rows from the live data (silx `StatsWidget` bound to the active
5507    /// item). The row is labelled with the item's legend.
5508    ///
5509    /// `viewport` is the visible data rectangle `((x0, x1), (y0, y1))`, used only
5510    /// when the widget's on-visible-data toggle is enabled. Returns `true` when
5511    /// there is an active item with retained scalar data to feed; `false`
5512    /// otherwise (the widget is then fed an empty input and shows no rows).
5513    pub fn feed_active_stats(
5514        &self,
5515        stats: &mut crate::widget::stats_widget::StatsWidget,
5516        viewport: Option<((f64, f64), (f64, f64))>,
5517    ) -> bool {
5518        let Some(handle) = self.active_item else {
5519            stats.recompute(&[], viewport);
5520            return false;
5521        };
5522        let label = self
5523            .item_record(handle)
5524            .map(|record| self.legend_label(record))
5525            .unwrap_or_else(|| "item".to_owned());
5526        match self.stats_input(handle) {
5527            Some(input) => {
5528                stats.recompute(&[(label.as_str(), input)], viewport);
5529                true
5530            }
5531            None => {
5532                stats.recompute(&[], viewport);
5533                false
5534            }
5535        }
5536    }
5537
5538    /// Feed *every* plot item that has retained scalar data into a
5539    /// [`StatsWidget`](crate::StatsWidget), one row per item labelled by its legend — silx
5540    /// `StatsWidget` in its default all-items mode, the counterpart to the
5541    /// active-only [`Self::feed_active_stats`] (silx `setDisplayOnlyActiveItem`
5542    /// chooses between them). Items with no retained scalar data (RGBA images,
5543    /// triangles, shapes, markers) are skipped. Returns the number of rows fed.
5544    ///
5545    /// `viewport` is the visible data rectangle `((x0, x1), (y0, y1))`, used only
5546    /// when the widget's on-visible-data toggle is enabled.
5547    pub fn feed_all_stats(
5548        &self,
5549        stats: &mut crate::widget::stats_widget::StatsWidget,
5550        viewport: Option<((f64, f64), (f64, f64))>,
5551    ) -> usize {
5552        let labeled = self.all_stats_labeled_data();
5553        let inputs: Vec<(&str, crate::widget::stats_widget::StatsInput<'_>)> = labeled
5554            .iter()
5555            .map(|(label, data)| (label.as_str(), retained_data_to_stats_input(data)))
5556            .collect();
5557        stats.recompute(&inputs, viewport);
5558        inputs.len()
5559    }
5560
5561    /// `(legend, retained data)` for every item with retained scalar data, in
5562    /// item order — the shared selection behind [`Self::feed_all_stats`] /
5563    /// [`Self::show_all_stats_widget`] (silx all-items `StatsWidget`).
5564    fn all_stats_labeled_data(&self) -> Vec<(String, &RetainedItemData)> {
5565        self.item_records
5566            .iter()
5567            .filter_map(|record| {
5568                record
5569                    .data
5570                    .as_ref()
5571                    .map(|data| (self.legend_label(record), data))
5572            })
5573            .collect()
5574    }
5575
5576    /// Compute per-ROI statistics over the active item's retained data and store
5577    /// them in `widget` (silx `ROIStatsWidget` bound to the active item): one
5578    /// row per ROI on the plot, reduced inside that ROI via [`image_roi_stats`]
5579    /// (image) / [`curve_roi_stats`] (curve). Returns `true` when there is an
5580    /// active item with retained data to reduce; `false` otherwise (the widget
5581    /// is then cleared).
5582    ///
5583    /// [`image_roi_stats`]: crate::widget::roi_stats::image_roi_stats
5584    /// [`curve_roi_stats`]: crate::widget::roi_stats::curve_roi_stats
5585    pub fn feed_roi_stats(
5586        &self,
5587        widget: &mut crate::widget::roi_stats_widget::RoiStatsWidget,
5588    ) -> bool {
5589        match self
5590            .active_item
5591            .and_then(|handle| self.retained_data(handle))
5592        {
5593            Some(data) => {
5594                widget.set_rows(roi_stats_rows(self.rois(), data));
5595                true
5596            }
5597            None => {
5598                widget.set_rows(Vec::new());
5599                false
5600            }
5601        }
5602    }
5603
5604    /// Feed the active item's per-ROI statistics into `widget` and render its
5605    /// table (silx `ROIStatsWidget`). Combines [`Self::feed_roi_stats`] with
5606    /// [`RoiStatsWidget::ui`]; the table follows the active item and the live
5607    /// ROI list.
5608    ///
5609    /// [`RoiStatsWidget::ui`]: crate::widget::roi_stats_widget::RoiStatsWidget::ui
5610    pub fn show_roi_stats_widget(
5611        &self,
5612        ui: &mut egui::Ui,
5613        widget: &mut crate::widget::roi_stats_widget::RoiStatsWidget,
5614    ) {
5615        self.feed_roi_stats(widget);
5616        widget.ui(ui);
5617    }
5618
5619    /// Compute per-ROI raw/net counts and raw/net area over the active **curve**
5620    /// and store them in `widget` (silx `CurvesROIWidget`): one row per curve ROI
5621    /// (those with an `x`-span), reduced via [`curve_roi_counts`]. Returns `true`
5622    /// when the active item is a curve with retained data; `false` otherwise
5623    /// (the active item is an image, or there is none — the widget is cleared,
5624    /// since these counts are curve-specific).
5625    ///
5626    /// [`curve_roi_counts`]: crate::widget::roi_stats::curve_roi_counts
5627    pub fn feed_curves_roi_stats(
5628        &self,
5629        widget: &mut crate::widget::curves_roi_widget::CurvesRoiWidget,
5630    ) -> bool {
5631        match self
5632            .active_item
5633            .and_then(|handle| self.retained_data(handle))
5634        {
5635            Some(RetainedItemData::Curve { x, y }) => {
5636                widget.set_rows(curve_roi_rows(self.rois(), x, y));
5637                true
5638            }
5639            _ => {
5640                widget.set_rows(Vec::new());
5641                false
5642            }
5643        }
5644    }
5645
5646    /// Feed the active curve's per-ROI counts into `widget` and render its table
5647    /// (silx `CurvesROIWidget`). Combines [`Self::feed_curves_roi_stats`] with
5648    /// [`CurvesRoiWidget::ui`]; the table follows the active curve and the live
5649    /// ROI list.
5650    ///
5651    /// [`CurvesRoiWidget::ui`]: crate::widget::curves_roi_widget::CurvesRoiWidget::ui
5652    pub fn show_curves_roi_widget(
5653        &self,
5654        ui: &mut egui::Ui,
5655        widget: &mut crate::widget::curves_roi_widget::CurvesRoiWidget,
5656    ) {
5657        self.feed_curves_roi_stats(widget);
5658        widget.ui(ui);
5659    }
5660
5661    /// Feed an item's retained curve `(x, y)` into a [`FitWidget`](crate::FitWidget) as its fit
5662    /// target, so a fit runs against the live curve (silx `FitWidget.setData`
5663    /// bound to a plot curve). Returns `true` when the item is a curve with
5664    /// retained data; `false` for an unknown handle or a non-curve item (the fit
5665    /// widget is left unchanged in that case).
5666    pub fn set_fit_target(
5667        &self,
5668        fit: &mut crate::widget::fit_widget::FitWidget,
5669        handle: ItemHandle,
5670    ) -> bool {
5671        match self.retained_data(handle).and_then(retained_curve_xy) {
5672            Some((x, y)) => {
5673                fit.set_data(x, y);
5674                true
5675            }
5676            None => false,
5677        }
5678    }
5679
5680    /// Feed the active item's retained curve `(x, y)` into a [`FitWidget`](crate::FitWidget) as its
5681    /// fit target (silx `FitWidget` bound to the active curve). Returns `true`
5682    /// when the active item is a curve with retained data.
5683    pub fn set_active_fit_target(&self, fit: &mut crate::widget::fit_widget::FitWidget) -> bool {
5684        self.active_item
5685            .is_some_and(|handle| self.set_fit_target(fit, handle))
5686    }
5687
5688    /// Feed the active item's retained data into a [`StatsWidget`](crate::StatsWidget) and render
5689    /// its table (silx `StatsWidget`). Combines [`Self::feed_active_stats`] with
5690    /// [`StatsWidget::ui`](crate::StatsWidget::ui); the widget recomputes as the active item changes.
5691    pub fn show_active_stats_widget(
5692        &self,
5693        ui: &mut egui::Ui,
5694        stats: &mut crate::widget::stats_widget::StatsWidget,
5695        viewport: Option<((f64, f64), (f64, f64))>,
5696    ) {
5697        match self.active_item.and_then(|handle| {
5698            self.stats_input(handle).map(|input| {
5699                let label = self
5700                    .item_record(handle)
5701                    .map(|record| self.legend_label(record))
5702                    .unwrap_or_else(|| "item".to_owned());
5703                (label, input)
5704            })
5705        }) {
5706            Some((label, input)) => {
5707                stats.ui(ui, &[(label.as_str(), input)], viewport);
5708            }
5709            None => {
5710                stats.ui(ui, &[], viewport);
5711            }
5712        }
5713    }
5714
5715    /// Feed *all* items with retained scalar data into a [`StatsWidget`](crate::StatsWidget) and
5716    /// render its table (silx all-items `StatsWidget`). Combines
5717    /// [`Self::feed_all_stats`]'s selection with [`StatsWidget::ui`](crate::StatsWidget::ui); the table
5718    /// follows every plot item, recomputing as items are added/removed.
5719    ///
5720    /// [`StatsWidget::ui`]: crate::widget::stats_widget::StatsWidget::ui
5721    pub fn show_all_stats_widget(
5722        &self,
5723        ui: &mut egui::Ui,
5724        stats: &mut crate::widget::stats_widget::StatsWidget,
5725        viewport: Option<((f64, f64), (f64, f64))>,
5726    ) {
5727        let labeled = self.all_stats_labeled_data();
5728        let inputs: Vec<(&str, crate::widget::stats_widget::StatsInput<'_>)> = labeled
5729            .iter()
5730            .map(|(label, data)| (label.as_str(), retained_data_to_stats_input(data)))
5731            .collect();
5732        stats.ui(ui, &inputs, viewport);
5733    }
5734
5735    /// Draw an egui-native plot toolbar.
5736    pub fn show_toolbar(&mut self, ui: &mut egui::Ui) -> ToolbarResponse {
5737        let mut out = ToolbarResponse::default();
5738        ui.scope(|ui| {
5739            ui.spacing_mut().item_spacing.x = 2.0;
5740            ui.horizontal_wrapped(|ui| {
5741                self.show_toolbar_controls(ui, &mut out);
5742            });
5743        });
5744        out
5745    }
5746
5747    /// Draw the silx `LimitsToolBar` (`tools/LimitsToolBar.py`): editable
5748    /// X-min/X-max/Y-min/Y-max fields that display and control the plot's
5749    /// display limits.
5750    ///
5751    /// The fields always reflect the current effective limits (silx's
5752    /// `limitsChanged` slot, which refreshes the edits on every plot limit
5753    /// change). Committing an edit applies it through
5754    /// [`set_graph_x_limits`](Self::set_graph_x_limits) /
5755    /// [`set_graph_y_limits`](Self::set_graph_y_limits), ordering the two bounds
5756    /// so min ≤ max (silx `_xFloatEditChanged` swaps when `max < min`). The Y
5757    /// fields edit the primary (left) axis.
5758    pub fn show_limits_toolbar(&mut self, ui: &mut egui::Ui) {
5759        let (xmin0, xmax0, ymin0, ymax0) = self.backend.plot().limits;
5760        ui.horizontal(|ui| {
5761            ui.label("Limits:");
5762            ui.label("X:");
5763            let mut xmin = xmin0;
5764            let mut xmax = xmax0;
5765            let x_changed = ui.add(egui::DragValue::new(&mut xmin)).changed()
5766                | ui.add(egui::DragValue::new(&mut xmax)).changed();
5767            if x_changed {
5768                let (lo, hi) = ordered_limits(xmin, xmax);
5769                self.set_graph_x_limits(lo, hi);
5770            }
5771            ui.label("Y:");
5772            let mut ymin = ymin0;
5773            let mut ymax = ymax0;
5774            let y_changed = ui.add(egui::DragValue::new(&mut ymin)).changed()
5775                | ui.add(egui::DragValue::new(&mut ymax)).changed();
5776            if y_changed {
5777                let (lo, hi) = ordered_limits(ymin, ymax);
5778                self.set_graph_y_limits(lo, hi, YAxis::Left);
5779            }
5780        });
5781    }
5782
5783    /// Draw the standard toolbar and append caller-provided controls in the
5784    /// same toolbar row.
5785    ///
5786    /// The closure receives this plot after the built-in controls have been
5787    /// drawn, so custom actions can mutate plot state while still sharing the
5788    /// standard toolbar layout.
5789    pub fn show_toolbar_with<R>(
5790        &mut self,
5791        ui: &mut egui::Ui,
5792        add_contents: impl FnOnce(&mut egui::Ui, &mut Self) -> R,
5793    ) -> (ToolbarResponse, R) {
5794        let mut out = ToolbarResponse::default();
5795        let mut extra = None;
5796        ui.scope(|ui| {
5797            ui.spacing_mut().item_spacing.x = 2.0;
5798            ui.horizontal_wrapped(|ui| {
5799                self.show_toolbar_controls(ui, &mut out);
5800                ui.separator();
5801                extra = Some(add_contents(ui, self));
5802            });
5803        });
5804        (
5805            out,
5806            extra.expect("egui horizontal layout closure should run exactly once"),
5807        )
5808    }
5809
5810    /// Draw toolbar then plot in the remaining UI.
5811    pub fn show_with_toolbar(&mut self, ui: &mut egui::Ui) -> PlotWithToolbarResponse {
5812        let toolbar = self.show_toolbar(ui);
5813        let plot = self.show(ui);
5814        PlotWithToolbarResponse { toolbar, plot }
5815    }
5816
5817    /// Draw the standard toolbar plus caller controls, then draw the plot.
5818    pub fn show_with_toolbar_with<R>(
5819        &mut self,
5820        ui: &mut egui::Ui,
5821        add_toolbar_contents: impl FnOnce(&mut egui::Ui, &mut Self) -> R,
5822    ) -> (PlotWithToolbarResponse, R) {
5823        let (toolbar, extra) = self.show_toolbar_with(ui, add_toolbar_contents);
5824        let plot = self.show(ui);
5825        (PlotWithToolbarResponse { toolbar, plot }, extra)
5826    }
5827
5828    /// Show a compact profile-mode toolbar with None / Horizontal / Vertical buttons.
5829    ///
5830    /// The selected mode is stored in egui temp-memory keyed by the plot id so it
5831    /// persists across frames. Returns the current mode.
5832    ///
5833    /// Place it inside `show_toolbar_with` so it sits in the same toolbar row:
5834    ///
5835    /// ```ignore
5836    /// let (_, mode) = plot.show_toolbar_with(ui, |ui, plot| {
5837    ///     ui.separator();
5838    ///     plot.show_profile_toolbar(ui)
5839    /// });
5840    /// ```
5841    pub fn show_profile_toolbar(&self, ui: &mut egui::Ui) -> ProfileMode {
5842        let id = egui::Id::new(self.backend().plot().id).with("profile_mode");
5843        let mut mode = ui
5844            .data(|d| d.get_temp::<ProfileMode>(id))
5845            .unwrap_or_default();
5846
5847        ui.horizontal(|ui| {
5848            if ui
5849                .selectable_label(mode == ProfileMode::None, "○")
5850                .on_hover_text("No profile")
5851                .clicked()
5852            {
5853                mode = ProfileMode::None;
5854            }
5855            if ui
5856                .selectable_label(mode == ProfileMode::Horizontal, "H")
5857                .on_hover_text("Horizontal profile (row slice)")
5858                .clicked()
5859            {
5860                mode = ProfileMode::Horizontal;
5861            }
5862            if ui
5863                .selectable_label(mode == ProfileMode::Vertical, "V")
5864                .on_hover_text("Vertical profile (column slice)")
5865                .clicked()
5866            {
5867                mode = ProfileMode::Vertical;
5868            }
5869            if ui
5870                .selectable_label(mode == ProfileMode::Line, "L")
5871                .on_hover_text("Line profile (draw line ROI)")
5872                .clicked()
5873            {
5874                mode = ProfileMode::Line;
5875            }
5876            if ui
5877                .selectable_label(mode == ProfileMode::Rectangle, "R")
5878                .on_hover_text("Rectangle profile (draw rect ROI)")
5879                .clicked()
5880            {
5881                mode = ProfileMode::Rectangle;
5882            }
5883        });
5884
5885        ui.data_mut(|d| d.insert_temp(id, mode));
5886        mode
5887    }
5888
5889    fn show_toolbar_controls(&mut self, ui: &mut egui::Ui, out: &mut ToolbarResponse) {
5890        if toolbar_icon_button(ui, ToolbarIcon::Home, false, "Reset zoom").clicked() {
5891            self.reset_zoom();
5892            out.reset_zoom = true;
5893        }
5894        if toolbar_icon_button(ui, ToolbarIcon::ZoomIn, false, "Zoom in").clicked() {
5895            crate::widget::actions::control::zoom_in(self);
5896            out.zoom_in = true;
5897        }
5898        if toolbar_icon_button(ui, ToolbarIcon::ZoomOut, false, "Zoom out").clicked() {
5899            crate::widget::actions::control::zoom_out(self);
5900            out.zoom_out = true;
5901        }
5902        if toolbar_icon_button(ui, ToolbarIcon::ZoomBack, false, "Zoom back").clicked() {
5903            crate::widget::actions::control::zoom_back(self);
5904            out.zoom_back = true;
5905        }
5906
5907        ui.separator();
5908
5909        let mode = self.interaction_mode();
5910        if toolbar_icon_button(
5911            ui,
5912            ToolbarIcon::Select,
5913            mode == PlotInteractionMode::Select,
5914            "Select items and edit handles",
5915        )
5916        .clicked()
5917        {
5918            self.set_interaction_mode(PlotInteractionMode::Select);
5919            out.interaction_mode_changed = true;
5920        }
5921        if toolbar_icon_button(
5922            ui,
5923            ToolbarIcon::Zoom,
5924            mode == PlotInteractionMode::Zoom,
5925            "Box zoom",
5926        )
5927        .clicked()
5928        {
5929            self.set_interaction_mode(PlotInteractionMode::Zoom);
5930            out.interaction_mode_changed = true;
5931        }
5932        // Zoom-axes menu (silx `ZoomEnabledAxesMenu`): choose which axes a box
5933        // zoom affects. Both checked by default; unchecking one keeps that
5934        // axis's range when a box zoom is applied. siplot's box zoom is left-axis
5935        // only, so there is no y2 entry (unlike silx's three).
5936        let mut zoom_x = self.plot().zoom_x_enabled();
5937        let mut zoom_y = self.plot().zoom_y_enabled();
5938        ui.menu_button("Zoom axes", |ui| {
5939            let cx = ui.checkbox(&mut zoom_x, "X axis").changed();
5940            let cy = ui.checkbox(&mut zoom_y, "Y axis").changed();
5941            if cx || cy {
5942                self.plot_mut().set_zoom_enabled_axes(zoom_x, zoom_y);
5943                out.zoom_axes_changed = true;
5944            }
5945        })
5946        .response
5947        .on_hover_text("Choose which axes a box zoom affects");
5948        if toolbar_icon_button(
5949            ui,
5950            ToolbarIcon::Pan,
5951            mode == PlotInteractionMode::Pan,
5952            "Pan",
5953        )
5954        .clicked()
5955        {
5956            self.set_interaction_mode(PlotInteractionMode::Pan);
5957            out.interaction_mode_changed = true;
5958        }
5959
5960        ui.separator();
5961
5962        let mut x_inv = self.is_x_inverted();
5963        if toolbar_icon_button(ui, ToolbarIcon::InvertX, x_inv, "Invert X axis").clicked() {
5964            x_inv = !x_inv;
5965            self.set_x_inverted(x_inv);
5966            out.x_inverted_changed = true;
5967        }
5968
5969        let mut y_inv = self.is_y_inverted();
5970        if toolbar_icon_button(ui, ToolbarIcon::InvertY, y_inv, "Invert Y axis").clicked() {
5971            y_inv = !y_inv;
5972            self.set_y_inverted(y_inv);
5973            out.y_inverted_changed = true;
5974        }
5975
5976        ui.separator();
5977
5978        let mut x_log = self.is_x_logarithmic();
5979        if toolbar_icon_button(ui, ToolbarIcon::LogX, x_log, "Toggle X log scale").clicked() {
5980            x_log = !x_log;
5981            self.set_x_log(x_log);
5982            out.x_log_changed = true;
5983        }
5984
5985        let mut y_log = self.is_y_logarithmic();
5986        if toolbar_icon_button(ui, ToolbarIcon::LogY, y_log, "Toggle Y log scale").clicked() {
5987            y_log = !y_log;
5988            self.set_y_log(y_log);
5989            out.y_log_changed = true;
5990        }
5991
5992        ui.separator();
5993
5994        let x_auto = self.plot().x_autoscale();
5995        if toolbar_icon_button(
5996            ui,
5997            ToolbarIcon::AutoscaleX,
5998            x_auto,
5999            "Auto-scale X axis on reset zoom",
6000        )
6001        .clicked()
6002        {
6003            crate::widget::actions::control::toggle_x_autoscale(self);
6004            out.autoscale_x_changed = true;
6005        }
6006
6007        let y_auto = self.plot().y_autoscale();
6008        if toolbar_icon_button(
6009            ui,
6010            ToolbarIcon::AutoscaleY,
6011            y_auto,
6012            "Auto-scale Y axis on reset zoom",
6013        )
6014        .clicked()
6015        {
6016            crate::widget::actions::control::toggle_y_autoscale(self);
6017            out.autoscale_y_changed = true;
6018        }
6019
6020        ui.separator();
6021
6022        let mut grid = self.graph_grid();
6023        if toolbar_icon_button(ui, ToolbarIcon::Grid, grid, "Toggle grid").clicked() {
6024            grid = !grid;
6025            self.set_graph_grid(grid);
6026            out.grid_changed = true;
6027        }
6028
6029        let mut minor = self.graph_minor_grid();
6030        let minor_response = ui
6031            .add_enabled_ui(grid, |ui| {
6032                toolbar_icon_button(ui, ToolbarIcon::MinorGrid, minor, "Toggle minor grid")
6033            })
6034            .inner;
6035        if minor_response.clicked() {
6036            minor = !minor;
6037            self.set_graph_minor_grid(minor);
6038            out.minor_grid_changed = true;
6039        }
6040
6041        ui.separator();
6042
6043        let mut aspect = self.is_keep_data_aspect_ratio();
6044        if toolbar_icon_button(ui, ToolbarIcon::Aspect, aspect, "Keep data aspect ratio").clicked()
6045        {
6046            aspect = !aspect;
6047            self.set_keep_data_aspect_ratio(aspect);
6048            out.aspect_changed = true;
6049        }
6050
6051        let mut cursor = self.graph_cursor();
6052        if toolbar_icon_button(ui, ToolbarIcon::Cursor, cursor, "Show cursor coordinates").clicked()
6053        {
6054            cursor = !cursor;
6055            self.set_graph_cursor(cursor);
6056            out.cursor_changed = true;
6057        }
6058
6059        ui.separator();
6060
6061        let show_axis = self.plot().axes_displayed();
6062        if toolbar_icon_button(ui, ToolbarIcon::ShowAxis, show_axis, "Show/hide axes").clicked() {
6063            crate::widget::actions::control::show_axis_toggle(self);
6064            out.show_axis_changed = true;
6065        }
6066
6067        let has_curve = self.active_curve().is_some();
6068        let curve_style_response = ui
6069            .add_enabled_ui(has_curve, |ui| {
6070                toolbar_icon_button(
6071                    ui,
6072                    ToolbarIcon::CurveStyle,
6073                    false,
6074                    "Cycle active curve line style",
6075                )
6076            })
6077            .inner;
6078        if curve_style_response.clicked() {
6079            crate::widget::actions::control::curve_style_cycle(self);
6080            out.curve_style_changed = true;
6081        }
6082
6083        ui.separator();
6084
6085        if toolbar_icon_button(ui, ToolbarIcon::Save, false, "Save figure or curve data").clicked()
6086        {
6087            // The save dialog + GPU readback are native shims; ignore the
6088            // result here (the toolbar only reports the click).
6089            let _ = self.save_dialog(DEFAULT_SAVE_SIZE);
6090            out.save = true;
6091        }
6092        if toolbar_icon_button(ui, ToolbarIcon::Copy, false, "Copy figure to clipboard").clicked() {
6093            // GPU readback + clipboard are native shims; ignore the result here.
6094            let _ = self.copy_to_clipboard(DEFAULT_SAVE_SIZE);
6095            out.copy = true;
6096        }
6097        if toolbar_icon_button(ui, ToolbarIcon::Print, false, "Print figure").clicked() {
6098            // silx `PrintAction` opens QPrintDialog; here the click opens the
6099            // printer-selection dialog (printer list + "Save to file…"), and the
6100            // dialog's choice is applied below.
6101            self.print_dialog.open_with_system_printers();
6102            out.print = true;
6103        }
6104        // The print dialog only signals the choice; this owner performs it.
6105        // GPU readback, printer submission, and the rfd file dialog are native
6106        // shims; their results are ignored here (the toolbar only reports).
6107        if let Some(action) = self.print_dialog.show(ui.ctx()) {
6108            match action {
6109                crate::widget::print_dialog::PrintDialogAction::Print { printer } => {
6110                    let _ = self.print_graph_to(&printer, DEFAULT_SAVE_SIZE);
6111                }
6112                crate::widget::print_dialog::PrintDialogAction::SaveToFile => {
6113                    let _ = self.save_dialog(DEFAULT_SAVE_SIZE);
6114                }
6115            }
6116        }
6117    }
6118
6119    /// Set graph limits.
6120    pub fn set_limits(
6121        &mut self,
6122        xmin: f64,
6123        xmax: f64,
6124        ymin: f64,
6125        ymax: f64,
6126        y2: Option<(f64, f64)>,
6127    ) {
6128        self.set_limits_internal(xmin, xmax, ymin, ymax, y2);
6129    }
6130
6131    /// Reset the displayed limits from accumulated data bounds.
6132    pub fn reset_zoom(&mut self) {
6133        self.reset_zoom_to_data();
6134    }
6135
6136    /// Restore the most recently pushed view from the limits history (silx
6137    /// `LimitsHistory.pop`), emitting [`PlotEvent::LimitsChanged`] if the view
6138    /// changed. Returns `true` if a stored view was restored, or `false` if the
6139    /// history was empty (callers fall back to [`Self::reset_zoom`], matching
6140    /// silx `LimitsHistory.pop`).
6141    pub fn zoom_back(&mut self) -> bool {
6142        let before = self.limits_snapshot();
6143        let restored = self.backend.plot_mut().zoom_back();
6144        self.push_limits_changed_if(before);
6145        restored
6146    }
6147
6148    /// Return the current X axis limits `(min, max)`.
6149    pub fn x_limits(&self) -> (f64, f64) {
6150        self.backend.x_limits()
6151    }
6152
6153    /// Return the current X axis limits `(min, max)` (alias for [`x_limits`](Self::x_limits)).
6154    pub fn get_graph_x_limits(&self) -> (f64, f64) {
6155        self.x_limits()
6156    }
6157
6158    /// Set the X axis display limits.
6159    pub fn set_graph_x_limits(&mut self, xmin: f64, xmax: f64) {
6160        let (_, _, ymin, ymax) = self.backend.plot().limits;
6161        self.set_limits_internal(xmin, xmax, ymin, ymax, self.backend.plot().y2);
6162    }
6163
6164    /// Return the current Y axis limits `(min, max)` for `axis`, or `None` if
6165    /// the axis has not been given explicit limits.
6166    pub fn y_limits(&self, axis: YAxis) -> Option<(f64, f64)> {
6167        self.backend.y_limits(axis)
6168    }
6169
6170    /// Return Y axis limits (alias for [`y_limits`](Self::y_limits)).
6171    pub fn get_graph_y_limits(&self, axis: YAxis) -> Option<(f64, f64)> {
6172        self.y_limits(axis)
6173    }
6174
6175    /// Set the Y axis display limits.  Pass [`YAxis::Right`] for the secondary axis.
6176    pub fn set_graph_y_limits(&mut self, ymin: f64, ymax: f64, axis: YAxis) {
6177        match axis {
6178            YAxis::Left => {
6179                let (xmin, xmax, _, _) = self.backend.plot().limits;
6180                self.set_limits_internal(xmin, xmax, ymin, ymax, self.backend.plot().y2);
6181            }
6182            YAxis::Right => {
6183                let before = self.limits_snapshot();
6184                self.backend.plot_mut().y2 = Some((ymin, ymax));
6185                self.push_limits_changed_if(before);
6186            }
6187            YAxis::Extra(n) => {
6188                if let Some(ax) = self.backend.plot_mut().extra_axis_mut(n) {
6189                    ax.range = Some((ymin, ymax));
6190                }
6191            }
6192        }
6193    }
6194
6195    /// Add an extra (stacked) Y axis on `side` and return its index, usable as
6196    /// [`YAxis::Extra(index)`](YAxis::Extra) with [`set_curve_y_axis`],
6197    /// [`set_graph_y_limits`], [`set_graph_y_label`], and the `extra_y_*` methods
6198    /// below. The axis starts linear, autoscaling, with no range or label; bind
6199    /// curves to it and either set an explicit range or let reset-zoom autoscale
6200    /// fit it. Same-side extra axes stack outward in creation order
6201    /// (silx-style multi-axis).
6202    ///
6203    /// [`set_curve_y_axis`]: Self::set_curve_y_axis
6204    /// [`set_graph_y_limits`]: Self::set_graph_y_limits
6205    /// [`set_graph_y_label`]: Self::set_graph_y_label
6206    pub fn add_extra_y_axis(&mut self, side: AxisSide) -> usize {
6207        self.backend.plot_mut().add_extra_axis(side)
6208    }
6209
6210    /// The number of extra (stacked) Y axes (`Plot::extra` length).
6211    pub fn extra_y_axis_count(&self) -> usize {
6212        self.backend.plot().extra_axes().len()
6213    }
6214
6215    /// Set whether extra axis `index` refits to its curves' data on reset-zoom
6216    /// (silx per-axis `setAutoScale`). Returns `false` for an unknown index.
6217    pub fn set_extra_y_autoscale(&mut self, index: usize, on: bool) -> bool {
6218        match self.backend.plot_mut().extra_axis_mut(index) {
6219            Some(ax) => {
6220                ax.autoscale = on;
6221                true
6222            }
6223            None => false,
6224        }
6225    }
6226
6227    /// Enable or disable a log10 scale on extra axis `index` (its range must be
6228    /// strictly positive when on). Returns `false` for an unknown index.
6229    pub fn set_extra_y_log(&mut self, index: usize, on: bool) -> bool {
6230        match self.backend.plot_mut().extra_axis_mut(index) {
6231            Some(ax) => {
6232                ax.scale = if on { Scale::Log10 } else { Scale::Linear };
6233                true
6234            }
6235            None => false,
6236        }
6237    }
6238
6239    /// Return `true` if extra axis `index` is logarithmic (`false` for an unknown
6240    /// index).
6241    pub fn is_extra_y_log(&self, index: usize) -> bool {
6242        self.backend
6243            .plot()
6244            .extra_axis(index)
6245            .map(|ax| ax.scale == Scale::Log10)
6246            .unwrap_or(false)
6247    }
6248
6249    /// Enable or disable a log10 X axis.  Limits must be strictly positive when on.
6250    pub fn set_x_log(&mut self, on: bool) {
6251        self.backend.set_x_log(on);
6252    }
6253
6254    /// Enable or disable a log10 X axis (alias for [`set_x_log`](Self::set_x_log)).
6255    pub fn set_graph_x_log(&mut self, on: bool) {
6256        self.set_x_log(on);
6257    }
6258
6259    /// Return `true` if the X axis is logarithmic.
6260    pub fn is_x_logarithmic(&self) -> bool {
6261        self.backend.plot().x_scale == Scale::Log10
6262    }
6263
6264    /// Return `true` if the X axis is logarithmic (alias for [`is_x_logarithmic`](Self::is_x_logarithmic)).
6265    pub fn is_graph_x_log(&self) -> bool {
6266        self.is_x_logarithmic()
6267    }
6268
6269    /// Enable or disable a log10 Y axis.  Limits must be strictly positive when on.
6270    pub fn set_y_log(&mut self, on: bool) {
6271        self.backend.set_y_log(on);
6272    }
6273
6274    /// Enable or disable a log10 Y axis (alias for [`set_y_log`](Self::set_y_log)).
6275    pub fn set_graph_y_log(&mut self, on: bool) {
6276        self.set_y_log(on);
6277    }
6278
6279    /// Return `true` if the Y axis is logarithmic.
6280    pub fn is_y_logarithmic(&self) -> bool {
6281        self.backend.plot().y_scale == Scale::Log10
6282    }
6283
6284    /// Return `true` if the Y axis is logarithmic (alias for [`is_y_logarithmic`](Self::is_y_logarithmic)).
6285    pub fn is_graph_y_log(&self) -> bool {
6286        self.is_y_logarithmic()
6287    }
6288
6289    /// Invert the X axis direction (right-to-left).
6290    pub fn set_x_inverted(&mut self, on: bool) {
6291        self.backend.set_x_inverted(on);
6292    }
6293
6294    /// Return `true` if the X axis is inverted.
6295    pub fn is_x_inverted(&self) -> bool {
6296        self.backend.plot().x_inverted
6297    }
6298
6299    // --- Axis range constraints (silx Axis.setRangeConstraints / setLimitsConstraints) ---
6300
6301    /// Minimum allowed X span (prevents zooming in below this width).
6302    pub fn set_x_min_range(&mut self, min: Option<f64>) {
6303        self.backend.plot_mut().x_constraints.min_range = min;
6304    }
6305
6306    /// Maximum allowed X span (prevents zooming out above this width).
6307    pub fn set_x_max_range(&mut self, max: Option<f64>) {
6308        self.backend.plot_mut().x_constraints.max_range = max;
6309    }
6310
6311    /// Minimum allowed X lower bound (prevents panning below this value).
6312    pub fn set_x_min_pos(&mut self, min: Option<f64>) {
6313        self.backend.plot_mut().x_constraints.min_pos = min;
6314    }
6315
6316    /// Maximum allowed X upper bound (prevents panning above this value).
6317    pub fn set_x_max_pos(&mut self, max: Option<f64>) {
6318        self.backend.plot_mut().x_constraints.max_pos = max;
6319    }
6320
6321    /// Minimum allowed Y span (prevents zooming in below this height).
6322    pub fn set_y_min_range(&mut self, min: Option<f64>) {
6323        self.backend.plot_mut().y_constraints.min_range = min;
6324    }
6325
6326    /// Maximum allowed Y span (prevents zooming out above this height).
6327    pub fn set_y_max_range(&mut self, max: Option<f64>) {
6328        self.backend.plot_mut().y_constraints.max_range = max;
6329    }
6330
6331    /// Minimum allowed Y lower bound (prevents panning below this value).
6332    pub fn set_y_min_pos(&mut self, min: Option<f64>) {
6333        self.backend.plot_mut().y_constraints.min_pos = min;
6334    }
6335
6336    /// Maximum allowed Y upper bound (prevents panning above this value).
6337    pub fn set_y_max_pos(&mut self, max: Option<f64>) {
6338        self.backend.plot_mut().y_constraints.max_pos = max;
6339    }
6340
6341    /// Read back current X axis constraints.
6342    pub fn x_constraints(&self) -> crate::core::plot::AxisConstraints {
6343        self.backend.plot().x_constraints
6344    }
6345
6346    /// Read back current Y axis constraints.
6347    pub fn y_constraints(&self) -> crate::core::plot::AxisConstraints {
6348        self.backend.plot().y_constraints
6349    }
6350
6351    /// Invert the Y axis direction (top-to-bottom, as in image coordinates).
6352    pub fn set_y_inverted(&mut self, on: bool) {
6353        self.backend.set_y_inverted(on);
6354    }
6355
6356    /// Return `true` if the Y axis is inverted.
6357    pub fn is_y_inverted(&self) -> bool {
6358        self.backend.plot().y_inverted
6359    }
6360
6361    /// Set the maximum number of major ticks on the X axis.
6362    ///
6363    /// The chrome calls [`nice_ticks`](crate::widget::chrome::nice_ticks) with this
6364    /// cap, so the actual count may be lower to keep round step sizes.
6365    /// Pass `None` to restore the default (8 ticks).
6366    pub fn set_x_tick_count(&mut self, n: Option<usize>) {
6367        self.backend.plot_mut().x_max_ticks = n;
6368    }
6369
6370    /// Return the current X tick-count cap, or `None` for the default (8).
6371    pub fn x_tick_count(&self) -> Option<usize> {
6372        self.backend.plot().x_max_ticks
6373    }
6374
6375    /// Set the maximum number of major ticks on the Y axis.
6376    ///
6377    /// Pass `None` to restore the default (6 ticks).
6378    pub fn set_y_tick_count(&mut self, n: Option<usize>) {
6379        self.backend.plot_mut().y_max_ticks = n;
6380    }
6381
6382    /// Return the current Y tick-count cap, or `None` for the default (6).
6383    pub fn y_tick_count(&self) -> Option<usize> {
6384        self.backend.plot().y_max_ticks
6385    }
6386
6387    /// Keep data square on screen by expanding the tighter axis' display range.
6388    pub fn set_keep_data_aspect_ratio(&mut self, on: bool) {
6389        self.backend.set_keep_data_aspect_ratio(on);
6390    }
6391
6392    pub fn is_keep_data_aspect_ratio(&self) -> bool {
6393        self.backend.plot().keep_aspect
6394    }
6395
6396    pub fn set_axes_margins(&mut self, margins: Margins) {
6397        self.backend.set_axes_margins(margins);
6398    }
6399
6400    pub fn axes_margins(&self) -> Margins {
6401        self.backend.plot().margins
6402    }
6403
6404    pub fn set_graph_title(&mut self, title: impl Into<String>) {
6405        let title = title.into();
6406        self.backend.set_title(Some(&title));
6407    }
6408
6409    pub fn graph_title(&self) -> Option<&str> {
6410        self.backend.plot().title.as_deref()
6411    }
6412
6413    pub fn clear_graph_title(&mut self) {
6414        self.backend.set_title(None);
6415    }
6416
6417    pub fn set_graph_x_label(&mut self, label: impl Into<String>) {
6418        let label = label.into();
6419        self.backend.set_x_label(Some(&label));
6420    }
6421
6422    pub fn graph_x_label(&self) -> Option<&str> {
6423        self.backend.plot().x_label.as_deref()
6424    }
6425
6426    pub fn clear_graph_x_label(&mut self) {
6427        self.backend.set_x_label(None);
6428    }
6429
6430    pub fn set_graph_y_label(&mut self, label: impl Into<String>, axis: YAxis) {
6431        let label = label.into();
6432        self.backend.set_y_label(Some(&label), axis);
6433    }
6434
6435    pub fn graph_y_label(&self, axis: YAxis) -> Option<&str> {
6436        match axis {
6437            YAxis::Left => self.backend.plot().y_label.as_deref(),
6438            YAxis::Right => self.backend.plot().y2_label.as_deref(),
6439            YAxis::Extra(n) => self
6440                .backend
6441                .plot()
6442                .extra_axis(n)
6443                .and_then(|a| a.label.as_deref()),
6444        }
6445    }
6446
6447    pub fn clear_graph_y_label(&mut self, axis: YAxis) {
6448        self.backend.set_y_label(None, axis);
6449    }
6450
6451    pub fn set_foreground_colors(&mut self, foreground: Color32, grid: Color32) {
6452        self.backend.set_foreground_colors(foreground, grid);
6453    }
6454
6455    pub fn set_background_colors(&mut self, background: Color32, data_background: Color32) {
6456        self.backend
6457            .set_background_colors(background, data_background);
6458    }
6459
6460    pub fn data_background_color(&self) -> Color32 {
6461        self.backend.plot().data_background
6462    }
6463
6464    pub fn foreground_color(&self) -> Option<Color32> {
6465        self.backend.plot().foreground
6466    }
6467
6468    pub fn grid_color(&self) -> Option<Color32> {
6469        self.backend.plot().grid_color
6470    }
6471
6472    pub fn set_graph_grid(&mut self, on: bool) {
6473        self.backend.plot_mut().grid = if on {
6474            GraphGrid::Major
6475        } else {
6476            GraphGrid::None
6477        };
6478    }
6479
6480    pub fn graph_grid(&self) -> bool {
6481        self.backend.plot().grid.major()
6482    }
6483
6484    pub fn set_graph_grid_mode(&mut self, mode: GraphGrid) {
6485        self.backend.plot_mut().grid = mode;
6486    }
6487
6488    pub fn graph_grid_mode(&self) -> GraphGrid {
6489        self.backend.plot().grid
6490    }
6491
6492    /// Whether the built-in colorbar is drawn when the plot has a colormap
6493    /// (silx colorbar visibility). Defaults to `true`.
6494    pub fn show_colorbar(&self) -> bool {
6495        self.backend.plot().show_colorbar
6496    }
6497
6498    /// Set whether the built-in colorbar is drawn when the plot has a colormap.
6499    /// Composite views that render their own dedicated colorbar (e.g.
6500    /// `ImageView`) set this `false` on their internal image plot so the
6501    /// colorbar is not drawn twice.
6502    pub fn set_show_colorbar(&mut self, show: bool) {
6503        self.backend.plot_mut().show_colorbar = show;
6504    }
6505
6506    /// Whether the colorbar is the interactive pyqtgraph-style histogram colorbar
6507    /// (drag the handles to set the colormap `vmin`/`vmax`) rather than a static
6508    /// strip. See [`Self::set_interactive_colorbar`].
6509    pub fn interactive_colorbar(&self) -> bool {
6510        self.backend.plot().colorbar_interactive
6511    }
6512
6513    /// Make the colorbar an interactive histogram colorbar (drag-to-set-levels).
6514    /// The drag is surfaced via [`PlotResponse::colorbar_dragged_levels`] for the
6515    /// caller to apply to the colormap (and re-upload the image); supply the
6516    /// value-distribution histogram with [`Self::set_colorbar_histogram`] and the
6517    /// axis range with [`Self::set_colorbar_value_range`].
6518    pub fn set_interactive_colorbar(&mut self, interactive: bool) {
6519        self.backend.plot_mut().colorbar_interactive = interactive;
6520    }
6521
6522    /// Set the value-distribution histogram drawn beside the interactive
6523    /// colorbar's gradient (`(counts, edges)` from
6524    /// [`crate::core::histogram::compute_histogram`]); `None` draws gradient +
6525    /// handles only.
6526    pub fn set_colorbar_histogram(&mut self, histogram: Option<(Vec<u64>, Vec<f64>)>) {
6527        self.backend.plot_mut().colorbar_histogram = histogram;
6528    }
6529
6530    /// Set the value range `(min, max)` the interactive colorbar's axis spans
6531    /// (handles move within it); `None` falls back to the colormap's
6532    /// `vmin`/`vmax`.
6533    pub fn set_colorbar_value_range(&mut self, range: Option<(f64, f64)>) {
6534        self.backend.plot_mut().colorbar_value_range = range;
6535    }
6536
6537    pub fn set_graph_minor_grid(&mut self, on: bool) {
6538        self.backend.plot_mut().grid = if on {
6539            GraphGrid::MajorAndMinor
6540        } else if self.graph_grid() {
6541            GraphGrid::Major
6542        } else {
6543            GraphGrid::None
6544        };
6545    }
6546
6547    pub fn graph_minor_grid(&self) -> bool {
6548        self.backend.plot().grid.minor()
6549    }
6550
6551    pub fn default_colormap(&self) -> &Colormap {
6552        &self.default_colormap
6553    }
6554
6555    pub fn set_default_colormap(&mut self, colormap: Colormap) {
6556        self.default_colormap = colormap;
6557    }
6558
6559    pub fn colorbar_colormap(&self) -> Option<&Colormap> {
6560        self.backend.plot().colormap.as_ref()
6561    }
6562
6563    /// The active image's raw scalar pixels as `f64`, row-major (silx
6564    /// `ImageData.getData`), or `None` when the active item is not a scalar
6565    /// image with retained data.
6566    ///
6567    /// Used by [`Self::autoscale_active_image`] to drive a raw-pixel autoscale,
6568    /// and exposed so callers can compute their own value statistics over the
6569    /// exact pixels the image was uploaded with (NaN-preserving).
6570    pub fn get_image_pixels_raw(&self) -> Option<Vec<f64>> {
6571        match self
6572            .active_item
6573            .and_then(|handle| self.retained_data(handle))
6574        {
6575            Some(RetainedItemData::Image { data, .. }) => Some(data.clone()),
6576            _ => None,
6577        }
6578    }
6579
6580    /// Autoscale the active image's colormap value limits from its raw pixels
6581    /// using `mode` (silx `ColormapDialog` Stddev3 / Percentile autoscale,
6582    /// ColormapDialog.py:450-480).
6583    ///
6584    /// Computes the `(vmin, vmax)` range over the active image's raw scalar
6585    /// pixels (NaN-ignoring; [`AutoscaleMode::Stddev3`] = mean ± 3·std,
6586    /// [`AutoscaleMode::Percentile`] = the colormap's percentile pair), then
6587    /// re-uploads the image with a colormap carrying those limits (preserving
6588    /// the LUT / normalization / gamma). Returns the applied `(vmin, vmax)`, or
6589    /// `None` when the active item is not a scalar image with retained data.
6590    pub fn autoscale_active_image(&mut self, mode: AutoscaleMode) -> Option<(f64, f64)> {
6591        let handle = self.active_item?;
6592        let (data, width, height, base, attrs) = match self.retained_data(handle)? {
6593            retained @ RetainedItemData::Image {
6594                data,
6595                width,
6596                height,
6597                colormap,
6598                ..
6599            } => (
6600                data.clone(),
6601                *width,
6602                *height,
6603                (**colormap).clone(),
6604                retained.image_display_attrs()?,
6605            ),
6606            RetainedItemData::Curve { .. } => return None,
6607        };
6608        let cm = autoscaled_colormap(&base, mode, &data);
6609        let limits = (cm.vmin, cm.vmax);
6610        let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
6611        let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, cm);
6612        attrs.apply(&mut spec);
6613        self.update_image_spec(handle, spec);
6614        Some(limits)
6615    }
6616
6617    /// Set the active image's colormap value limits to an explicit `(vmin, vmax)`,
6618    /// re-uploading the image with those levels (preserving the LUT /
6619    /// normalization / gamma / geometry, like [`Self::autoscale_active_image`]).
6620    ///
6621    /// This is the apply path for the interactive colorbar drag: feed the
6622    /// `(vmin, vmax)` from [`PlotResponse::colorbar_dragged_levels`] back here so a
6623    /// bare `Plot2D`/`Plot1D` updates its contrast live. Returns `true` when a
6624    /// scalar image with retained data was updated, `false` otherwise.
6625    pub fn set_active_image_levels(&mut self, vmin: f64, vmax: f64) -> bool {
6626        let Some(handle) = self.active_item else {
6627            return false;
6628        };
6629        let (data, width, height, mut cm, attrs) = match self.retained_data(handle) {
6630            Some(
6631                retained @ RetainedItemData::Image {
6632                    data,
6633                    width,
6634                    height,
6635                    colormap,
6636                    ..
6637                },
6638            ) => (
6639                data.clone(),
6640                *width,
6641                *height,
6642                (**colormap).clone(),
6643                retained.image_display_attrs().expect("image has attrs"),
6644            ),
6645            _ => return false,
6646        };
6647        cm.vmin = vmin;
6648        cm.vmax = vmax;
6649        let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
6650        let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, cm);
6651        attrs.apply(&mut spec);
6652        self.update_image_spec(handle, spec)
6653    }
6654
6655    /// The global opacity of the scalar image at `handle` (silx image
6656    /// `getAlpha`, `AlphaMixIn`), or `None` when the item is not a scalar image
6657    /// with retained data.
6658    ///
6659    /// This is the read side of the [`AlphaSlider`](crate::widget::alpha_slider::AlphaSlider)
6660    /// item bindings ([`ActiveImageAlphaSlider`]/[`NamedItemAlphaSlider`]):
6661    /// `getItem().getAlpha()`. Only images carry a retained, re-applicable
6662    /// alpha — curves bake opacity into their color and scatters retain no
6663    /// data, so neither is addressable here (the bindings disable for them,
6664    /// mirroring silx's "no item → disabled" rule).
6665    ///
6666    /// [`ActiveImageAlphaSlider`]: crate::widget::alpha_slider::ActiveImageAlphaSlider
6667    /// [`NamedItemAlphaSlider`]: crate::widget::alpha_slider::NamedItemAlphaSlider
6668    pub fn image_alpha(&self, handle: ItemHandle) -> Option<f32> {
6669        match self.retained_data(handle) {
6670            Some(RetainedItemData::Image { alpha, .. }) => Some(*alpha),
6671            _ => None,
6672        }
6673    }
6674
6675    /// Set the global opacity of the scalar image at `handle`, re-uploading it
6676    /// with the new alpha and the same pixels / geometry / colormap (silx image
6677    /// `setAlpha`, `AlphaMixIn`). Returns `true` when a scalar image with
6678    /// retained data was updated, `false` otherwise.
6679    ///
6680    /// `alpha` is clamped to `[0, 1]` (silx `AlphaMixIn.setAlpha`). This is the
6681    /// write side of the [`ActiveImageAlphaSlider`]/[`NamedItemAlphaSlider`]
6682    /// bindings (`getItem().setAlpha(value)`).
6683    ///
6684    /// [`ActiveImageAlphaSlider`]: crate::widget::alpha_slider::ActiveImageAlphaSlider
6685    /// [`NamedItemAlphaSlider`]: crate::widget::alpha_slider::NamedItemAlphaSlider
6686    pub fn set_image_alpha(&mut self, handle: ItemHandle, alpha: f32) -> bool {
6687        let (data, width, height, cm, mut attrs) = match self.retained_data(handle) {
6688            Some(
6689                retained @ RetainedItemData::Image {
6690                    data,
6691                    width,
6692                    height,
6693                    colormap,
6694                    ..
6695                },
6696            ) => (
6697                data.clone(),
6698                *width,
6699                *height,
6700                (**colormap).clone(),
6701                retained.image_display_attrs().expect("image has attrs"),
6702            ),
6703            _ => return false,
6704        };
6705        // Override only the opacity; every other display attribute is carried
6706        // over unchanged (silx setAlpha leaves interpolation/aggregation intact).
6707        attrs.alpha = alpha.clamp(0.0, 1.0);
6708        let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
6709        let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, cm);
6710        attrs.apply(&mut spec);
6711        self.update_image_spec(handle, spec)
6712    }
6713
6714    /// The active item's handle when it is a scalar image with retained data,
6715    /// else `None` (silx `getActiveImage()` restricted to images that carry an
6716    /// addressable alpha). Used by [`ActiveImageAlphaSlider`] to detect when the
6717    /// active-image binding changes and re-seed the slider from the new item.
6718    ///
6719    /// [`ActiveImageAlphaSlider`]: crate::widget::alpha_slider::ActiveImageAlphaSlider
6720    pub fn active_image_handle(&self) -> Option<ItemHandle> {
6721        let handle = self.active_item?;
6722        matches!(
6723            self.retained_data(handle),
6724            Some(RetainedItemData::Image { .. })
6725        )
6726        .then_some(handle)
6727    }
6728
6729    /// The active image's global opacity (silx `ActiveImageAlphaSlider`
6730    /// `getItem().getAlpha()` = `getActiveImage().getAlpha()`), or `None` when
6731    /// the active item is not a scalar image with retained data.
6732    pub fn active_image_alpha(&self) -> Option<f32> {
6733        self.active_item.and_then(|handle| self.image_alpha(handle))
6734    }
6735
6736    /// Set the active image's global opacity (silx `ActiveImageAlphaSlider`
6737    /// `getItem().setAlpha(value)`). Returns `true` when a scalar active image
6738    /// was updated, `false` otherwise. `alpha` is clamped to `[0, 1]`.
6739    pub fn set_active_image_alpha(&mut self, alpha: f32) -> bool {
6740        match self.active_item {
6741            Some(handle) => self.set_image_alpha(handle, alpha),
6742            None => false,
6743        }
6744    }
6745
6746    /// Apply a median filter to the active image and replace it in place (silx
6747    /// `MedianFilterAction` / `MedianFilter2DAction` re-adding the filtered image
6748    /// with `addImage(replace=True)`).
6749    ///
6750    /// Reads the active image's raw scalar pixels and geometry, runs
6751    /// [`crate::widget::actions::analysis::median_filter_2d`] with a square
6752    /// `(kernel_width, kernel_width)` kernel (silx `MedianFilter2DAction`) and
6753    /// the default `mode='nearest'` edge handling, then re-uploads the result
6754    /// with the same origin / scale / colormap. `kernel_width` is forced odd
6755    /// (silx `MedianFilterDialog` spinbox step 2, min 1) by rounding up to the
6756    /// next odd value; a width of 1 (or 0) leaves the image unchanged.
6757    ///
6758    /// Returns `true` if a scalar image was filtered and replaced, or `false`
6759    /// when the active item is not a scalar image with retained data.
6760    pub fn apply_median_filter(&mut self, kernel_width: usize, conditional: bool) -> bool {
6761        self.apply_median_filter_kernel(kernel_width, kernel_width, conditional)
6762    }
6763
6764    /// Apply a 1D median filter to the active image (silx `MedianFilter1DAction`,
6765    /// kernel `(kernel_width, 1)`), replacing it in place.
6766    ///
6767    /// Same contract as [`Self::apply_median_filter`] but with a column-1 kernel
6768    /// that filters along image rows (the height / y direction).
6769    pub fn apply_median_filter_1d(&mut self, kernel_width: usize, conditional: bool) -> bool {
6770        self.apply_median_filter_kernel(kernel_width, 1, conditional)
6771    }
6772
6773    /// Shared median-filter apply path for the 1D `(k,1)` and 2D `(k,k)` actions.
6774    fn apply_median_filter_kernel(
6775        &mut self,
6776        kernel_h: usize,
6777        kernel_w: usize,
6778        conditional: bool,
6779    ) -> bool {
6780        // Force odd kernel dimensions (silx asserts odd; the dialog steps by 2).
6781        let kernel_h = force_odd(kernel_h);
6782        let kernel_w = force_odd(kernel_w);
6783
6784        let handle = match self.active_item {
6785            Some(h) => h,
6786            None => return false,
6787        };
6788        let (data, width, height, colormap, attrs) = match self.retained_data(handle) {
6789            Some(
6790                retained @ RetainedItemData::Image {
6791                    data,
6792                    width,
6793                    height,
6794                    colormap,
6795                    ..
6796                },
6797            ) => (
6798                data.clone(),
6799                *width,
6800                *height,
6801                (**colormap).clone(),
6802                retained.image_display_attrs().expect("image has attrs"),
6803            ),
6804            _ => return false,
6805        };
6806
6807        let filtered = crate::widget::actions::analysis::median_filter_2d(
6808            &data,
6809            width,
6810            height,
6811            kernel_h,
6812            kernel_w,
6813            conditional,
6814        );
6815
6816        let pixels: Vec<f32> = filtered.iter().map(|&v| v as f32).collect();
6817        let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, colormap);
6818        attrs.apply(&mut spec);
6819        self.update_image_spec(handle, spec);
6820        true
6821    }
6822
6823    /// Compute the pixel-intensity histogram of the active image (silx
6824    /// `PixelIntensitiesHistoAction`).
6825    ///
6826    /// Reads the active image's raw scalar pixels and runs
6827    /// [`crate::widget::actions::analysis::pixel_intensity_histogram`] with the
6828    /// silx defaults (bin count `min(1024, floor(sqrt(finite_count)))`, finite
6829    /// range, `last_bin_closed`, non-finite excluded). Pass `n_bins` to override
6830    /// the bin count (mirroring the widget's editable bin-count field), or
6831    /// `None` for the silx default.
6832    ///
6833    /// Returns `None` when the active item is not a scalar image with retained
6834    /// data, or when the image has no finite pixel.
6835    pub fn active_image_histogram(
6836        &self,
6837        n_bins: Option<usize>,
6838    ) -> Option<crate::widget::actions::analysis::PixelHistogram> {
6839        let pixels = self.get_image_pixels_raw()?;
6840        crate::widget::actions::analysis::pixel_intensity_histogram(&pixels, n_bins)
6841    }
6842
6843    pub fn set_graph_cursor(&mut self, on: bool) {
6844        self.backend.plot_mut().crosshair = on;
6845    }
6846
6847    pub fn graph_cursor(&self) -> bool {
6848        self.backend.plot().crosshair
6849    }
6850
6851    pub fn data_to_pixel(&self, x: f64, y: f64, axis: YAxis) -> Option<egui::Pos2> {
6852        self.backend.data_to_pixel(x, y, axis)
6853    }
6854
6855    pub fn pixel_to_data(&self, p: egui::Pos2, axis: YAxis) -> Option<(f64, f64)> {
6856        self.backend.pixel_to_data(p, axis)
6857    }
6858
6859    pub fn plot_bounds_in_pixels(&self) -> Option<egui::Rect> {
6860        self.backend.plot_bounds_in_pixels()
6861    }
6862
6863    /// Snap the data-space `cursor` to the nearest data point under the live
6864    /// `mode`, porting silx `PositionInfo._updateStatusBar`'s snap
6865    /// (PositionInfo.py:196-292) against this widget's retained items.
6866    ///
6867    /// Builds a [`SnapItem`] per item (kind, `is_item_visible`, whether it shows
6868    /// a symbol, whether it is the active item), runs [`snapping_candidates`] to
6869    /// pick the participating items for `mode`, projects every candidate's
6870    /// retained vertices to pixels through the cached display transform
6871    /// (axis-aware, so a y2 curve snaps in its own axis), and returns the
6872    /// [`snap_to_nearest`] result within [`SNAP_THRESHOLD_DIST`] logical pixels —
6873    /// its `data` is the snapped coordinate to show in a [`PositionInfo`](crate::PositionInfo), with a
6874    /// `None` result meaning "no snap" (feed `false` to
6875    /// [`PositionInfo::ui_snapped`](crate::widget::position_info::PositionInfo::ui_snapped)).
6876    ///
6877    /// Curves, histograms, and scatters all participate: each is stored with
6878    /// retained `RetainedItemData::Curve { x, y }` vertices (a scatter is a
6879    /// symbol-only curve-kind item, [`Self::add_scatter`]), so a `SCATTER`-mode
6880    /// snap matches scatter points and a `CURVE`-mode snap matches curve and
6881    /// histogram vertices. Items whose data is not retained as vertices (images,
6882    /// triangles, shapes, markers) classify as [`SnapItemKind::Other`] and are
6883    /// never candidates.
6884    ///
6885    /// Returns `None` when snapping is not engaged (no `CURVE`/`SCATTER` flag),
6886    /// nothing is within the radius, or no frame has been rendered yet (the
6887    /// transform is uncached). The [`SnappingMode::CROSSHAIR`] gate is the
6888    /// caller's precondition (silx :198), as is the empty/`None`-cursor case.
6889    pub fn snap_cursor(&self, cursor: [f64; 2], mode: SnappingMode) -> Option<Snap> {
6890        // SnapItems in item-record order so candidate indices map back to records.
6891        let items: Vec<SnapItem> = self
6892            .item_records
6893            .iter()
6894            .map(|record| SnapItem {
6895                kind: snap_item_kind(record.kind),
6896                visible: self.is_item_visible(record.handle),
6897                has_symbol: record
6898                    .curve_data
6899                    .as_ref()
6900                    .and_then(|curve| curve.symbol)
6901                    .is_some(),
6902                active: self.active_item == Some(record.handle),
6903            })
6904            .collect();
6905        let candidates = snapping_candidates(mode, &items);
6906        if candidates.is_empty() {
6907            return None;
6908        }
6909
6910        // Project every candidate curve's vertices to pixels, paired with their
6911        // data coordinate (silx projects each item's points then snaps).
6912        let mut points: Vec<([f64; 2], [f64; 2])> = Vec::new();
6913        for &index in &candidates {
6914            let record = &self.item_records[index];
6915            let axis = record
6916                .curve_data
6917                .as_ref()
6918                .map_or(YAxis::Left, |curve| curve.y_axis);
6919            // Curves, histograms, and scatters all retain Curve{x,y} vertices;
6920            // items without retained vertices (images/shapes/markers) never
6921            // reach here because they classify as SnapItemKind::Other.
6922            if let Some(RetainedItemData::Curve { x, y }) = &record.data {
6923                for (&dx, &dy) in x.iter().zip(y) {
6924                    if let Some(px) = self.data_to_pixel(dx, dy, axis) {
6925                        points.push(([px.x as f64, px.y as f64], [dx, dy]));
6926                    }
6927                }
6928            }
6929        }
6930
6931        let cursor_px = self.data_to_pixel(cursor[0], cursor[1], YAxis::Left)?;
6932        snap_to_nearest(
6933            [cursor_px.x as f64, cursor_px.y as f64],
6934            &points,
6935            SNAP_THRESHOLD_DIST,
6936        )
6937    }
6938
6939    pub fn add_roi(&mut self, roi: Roi) -> usize {
6940        self.backend.plot_mut().rois.push(ManagedRoi::new(roi));
6941        let index = self.backend.plot().rois.len() - 1;
6942        self.events.push(PlotEvent::RoiAdded { index });
6943        index
6944    }
6945
6946    pub fn rois(&self) -> &[ManagedRoi] {
6947        &self.backend.plot().rois
6948    }
6949
6950    pub fn rois_mut(&mut self) -> &mut [ManagedRoi] {
6951        &mut self.backend.plot_mut().rois
6952    }
6953
6954    pub fn clear_rois(&mut self) {
6955        self.backend.plot_mut().clear_rois();
6956        self.events.push(PlotEvent::RoisCleared);
6957    }
6958
6959    /// Remove the ROI at `index`, keeping the current-ROI selection consistent
6960    /// via the [`Plot`] owner (silx `RegionOfInterestManager.removeRoi`). Emits
6961    /// [`PlotEvent::RoiAboutToBeRemoved`] *before* the removal (silx
6962    /// `sigRoiAboutToBeRemoved`), so a listener can still read the ROI being
6963    /// dropped; an out-of-range index is ignored (no event).
6964    pub fn remove_roi(&mut self, index: usize) {
6965        if index >= self.backend.plot().rois.len() {
6966            return; // out of range: nothing removed, no signal
6967        }
6968        // silx emits sigRoiAboutToBeRemoved before the ROI leaves the list.
6969        self.events.push(PlotEvent::RoiAboutToBeRemoved { index });
6970        self.backend.plot_mut().remove_roi(index);
6971    }
6972
6973    /// Append a fully-specified [`ManagedRoi`] (geometry + appearance) and
6974    /// return its index, emitting [`PlotEvent::RoiAdded`] (silx
6975    /// `RegionOfInterestManager.addRoi` → `sigRoiAdded`). Use this to add a
6976    /// styled/named ROI in one call; [`Self::add_roi`] adds bare geometry with
6977    /// default appearance.
6978    pub fn add_managed_roi(&mut self, managed: ManagedRoi) -> usize {
6979        self.backend.plot_mut().rois.push(managed);
6980        let index = self.backend.plot().rois.len() - 1;
6981        self.events.push(PlotEvent::RoiAdded { index });
6982        index
6983    }
6984
6985    /// Whether the ruler measurement tool is armed (silx
6986    /// `RulerToolButton.isChecked`).
6987    pub fn ruler_active(&self) -> bool {
6988        self.ruler_active
6989    }
6990
6991    /// The index of the live ruler line ROI in [`rois`](Self::rois), or `None`
6992    /// when no ruler line has been drawn (or the ruler is disarmed).
6993    pub fn ruler_roi(&self) -> Option<usize> {
6994        self.ruler_roi
6995    }
6996
6997    /// Arm or disarm the ruler measurement tool (silx `RulerToolButton` toggle).
6998    ///
6999    /// Arming enters a line-ROI draw
7000    /// ([`PlotInteractionMode::RoiCreate(RoiDrawKind::Line)`](PlotInteractionMode::RoiCreate)),
7001    /// remembering the prior mode; each completed drag draws a line ROI whose
7002    /// name is its measured length ([`RulerToolButton::distance_text`]), recomputed
7003    /// in [`show`](Self::show) — a new measurement replaces the previous ruler
7004    /// line. Disarming removes the ruler line and restores the prior interaction
7005    /// mode (silx deselect). A no-op if already in the requested state.
7006    ///
7007    /// [`RulerToolButton::distance_text`]: crate::widget::tool_buttons::RulerToolButton::distance_text
7008    pub fn set_ruler_active(&mut self, active: bool) {
7009        if active == self.ruler_active {
7010            return;
7011        }
7012        self.ruler_active = active;
7013        if active {
7014            self.ruler_prev_mode = Some(self.interaction_mode);
7015            self.set_interaction_mode(PlotInteractionMode::RoiCreate(RoiDrawKind::Line));
7016        } else {
7017            if let Some(index) = self.ruler_roi.take() {
7018                self.remove_roi(index);
7019            }
7020            let restore = self
7021                .ruler_prev_mode
7022                .take()
7023                .unwrap_or(PlotInteractionMode::Zoom);
7024            self.set_interaction_mode(restore);
7025        }
7026    }
7027
7028    /// Recompute the ruler line ROI's name from its current endpoints (silx
7029    /// `RulerToolButton.buildDistanceText` on `_RulerROI`). No-op if the index is
7030    /// out of range or the ROI is not a [`Roi::Line`].
7031    fn relabel_ruler(&mut self, index: usize) {
7032        let label = match self.backend.plot().rois.get(index).map(|r| &r.roi) {
7033            Some(Roi::Line { start, end }) => {
7034                crate::widget::tool_buttons::RulerToolButton::distance_text(
7035                    [start.0, start.1],
7036                    [end.0, end.1],
7037                )
7038            }
7039            _ => return,
7040        };
7041        self.set_roi_name(index, label);
7042    }
7043
7044    /// The current handle-editing interaction mode of the ROI at `index` (silx
7045    /// `InteractionModeMixIn.getInteractionMode`). `None` for an out-of-range
7046    /// index or a ROI kind without interaction modes (everything but Arc/Band).
7047    #[must_use]
7048    pub fn roi_interaction_mode(&self, index: usize) -> Option<RoiInteractionMode> {
7049        self.backend.plot().rois.get(index)?.interaction_mode()
7050    }
7051
7052    /// Switch the interaction mode of the ROI at `index` (silx
7053    /// `InteractionModeMixIn.setInteractionMode`). Emits
7054    /// [`PlotEvent::RoiInteractionModeChanged`] and returns `true` only when the
7055    /// index is valid and `mode` is one of that ROI's
7056    /// [`Roi::available_interaction_modes`](crate::Roi::available_interaction_modes);
7057    /// an out-of-range index or a mode foreign to the kind is ignored (no event).
7058    pub fn set_roi_interaction_mode(&mut self, index: usize, mode: RoiInteractionMode) -> bool {
7059        let Some(roi) = self.backend.plot_mut().rois.get_mut(index) else {
7060            return false;
7061        };
7062        if roi.set_interaction_mode(mode) {
7063            self.events
7064                .push(PlotEvent::RoiInteractionModeChanged { index, mode });
7065            true
7066        } else {
7067            false
7068        }
7069    }
7070
7071    /// Set the per-ROI color override at `index` (silx `RegionOfInterest.setColor`).
7072    /// An out-of-range index is ignored.
7073    pub fn set_roi_color(&mut self, index: usize, color: Color32) {
7074        if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
7075            r.color = Some(color);
7076        }
7077    }
7078
7079    /// Set the display name of the ROI at `index` (silx `RegionOfInterest.setName`).
7080    /// An out-of-range index is ignored.
7081    pub fn set_roi_name(&mut self, index: usize, name: impl Into<String>) {
7082        if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
7083            r.name = name.into();
7084        }
7085    }
7086
7087    /// Set the outline line width of the ROI at `index` (silx
7088    /// `RegionOfInterest.setLineWidth`). An out-of-range index is ignored.
7089    pub fn set_roi_line_width(&mut self, index: usize, width: f32) {
7090        if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
7091            r.line_width = width;
7092        }
7093    }
7094
7095    /// Set the outline stroke style of the ROI at `index` (silx
7096    /// `RegionOfInterest.setLineStyle`). An out-of-range index is ignored.
7097    pub fn set_roi_line_style(&mut self, index: usize, style: RoiLineStyle) {
7098        if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
7099            r.line_style = style;
7100        }
7101    }
7102
7103    /// Set the gap fill color of a dashed/dotted ROI outline at `index` (silx
7104    /// `LineMixIn.setLineGapColor`); `None` leaves the gaps transparent. Only
7105    /// visible on a dashed/dotted line style. An out-of-range index is ignored.
7106    pub fn set_roi_line_gap_color(&mut self, index: usize, gap_color: Option<Color32>) {
7107        if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
7108            r.gap_color = gap_color;
7109        }
7110    }
7111
7112    /// Set whether the ROI at `index` fills its interior (silx
7113    /// `RegionOfInterest.setFill`). An out-of-range index is ignored.
7114    pub fn set_roi_fill(&mut self, index: usize, fill: bool) {
7115        if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
7116            r.fill = fill;
7117        }
7118    }
7119
7120    /// The index of the current/highlighted ROI, or `None` (silx
7121    /// `RegionOfInterestManager.getCurrentRoi`).
7122    pub fn current_roi(&self) -> Option<usize> {
7123        self.backend.plot().current_roi()
7124    }
7125
7126    /// Set the current/highlighted ROI by index, or `None` to clear it (silx
7127    /// `RegionOfInterestManager.setCurrentRoi`). Highlights exactly that ROI on
7128    /// the plot; an out-of-range index clears the selection. Emits
7129    /// [`PlotEvent::CurrentRoiChanged`] when the current ROI actually changes
7130    /// (silx `sigCurrentRoiChanged`).
7131    pub fn set_current_roi(&mut self, index: Option<usize>) {
7132        let previous = self.backend.plot().current_roi();
7133        self.backend.plot_mut().set_current_roi(index);
7134        let current = self.backend.plot().current_roi();
7135        if current != previous {
7136            self.events
7137                .push(PlotEvent::CurrentRoiChanged { previous, current });
7138        }
7139    }
7140
7141    /// Show a compact ROI manager panel: a table listing all current ROIs — each
7142    /// row carries an editable name (silx label column), the geometry shown as a
7143    /// make-current selector (silx row selection → `sigCurrentRoiChanged`,
7144    /// highlighted when current), and a remove button — followed by buttons to add
7145    /// each ROI kind and a clear-all button. Mirrors silx
7146    /// `RegionOfInterestTableWidget` / `RegionOfInterestManager`.
7147    ///
7148    /// Per-row edits are routed through the owner APIs ([`Self::set_roi_name`],
7149    /// [`Self::set_current_roi`], [`Self::remove_roi`]) so events fire and the
7150    /// current-ROI index stays consistent.
7151    ///
7152    /// New ROIs are centered on the current plot view. Returns the index of any
7153    /// newly added ROI, or `None` when none was added this frame.
7154    pub fn show_roi_manager(&mut self, ui: &mut egui::Ui) -> Option<usize> {
7155        let mut added: Option<usize> = None;
7156        let mut remove_idx: Option<usize> = None;
7157        let mut make_current: Option<usize> = None;
7158        let mut rename: Option<(usize, String)> = None;
7159
7160        let current = self.current_roi();
7161
7162        // --- ROI table (silx `RegionOfInterestTableWidget`): one row per ROI, with
7163        // an editable name (silx label column), the geometry as a make-current
7164        // selector (silx row selection → `sigCurrentRoiChanged`), and a remove
7165        // button. Mutations are collected here under the immutable `rois` borrow
7166        // and applied through the owner APIs once the borrow ends. ---
7167        egui::ScrollArea::vertical()
7168            .max_height(200.0)
7169            .show(ui, |ui| {
7170                egui::Grid::new("roi_manager_table")
7171                    .num_columns(3)
7172                    .striped(true)
7173                    .show(ui, |ui| {
7174                        ui.label("Name");
7175                        ui.label("Region");
7176                        ui.label("");
7177                        ui.end_row();
7178
7179                        for (i, managed) in self.backend.plot().rois.iter().enumerate() {
7180                            // Editable name (silx editable label column). Bound to a
7181                            // per-row clone; a change is recorded and applied via the
7182                            // owner after the borrow ends.
7183                            let mut name = managed.name.clone();
7184                            if ui
7185                                .add(
7186                                    egui::TextEdit::singleline(&mut name)
7187                                        .desired_width(90.0)
7188                                        .hint_text("(unnamed)"),
7189                                )
7190                                .changed()
7191                            {
7192                                rename = Some((i, name));
7193                            }
7194
7195                            // Geometry, clickable to make this the current ROI
7196                            // (highlighted when current).
7197                            let desc = roi_description(&managed.roi);
7198                            if ui
7199                                .selectable_label(current == Some(i), desc)
7200                                .on_hover_text("Make current")
7201                                .clicked()
7202                            {
7203                                make_current = Some(i);
7204                            }
7205
7206                            if ui.small_button("×").on_hover_text("Remove").clicked() {
7207                                remove_idx = Some(i);
7208                            }
7209                            ui.end_row();
7210                        }
7211                    });
7212            });
7213
7214        if let Some((idx, name)) = rename {
7215            self.set_roi_name(idx, name);
7216        }
7217        if let Some(idx) = make_current {
7218            // Route through the owner so `sigCurrentRoiChanged` fires.
7219            self.set_current_roi(Some(idx));
7220        }
7221        if let Some(idx) = remove_idx {
7222            // Route through the owner so the current-ROI index stays consistent.
7223            self.remove_roi(idx);
7224        }
7225
7226        // --- add buttons ---
7227        let (x0, x1, y0, y1) = self.backend.plot().limits;
7228        let cx = (x0 + x1) * 0.5;
7229        let cy = (y0 + y1) * 0.5;
7230        let dx = (x1 - x0) * 0.2;
7231        let dy = (y1 - y0) * 0.2;
7232
7233        ui.horizontal_wrapped(|ui| {
7234            if ui.button("+ Rect").clicked() {
7235                let idx = self.add_roi(Roi::Rect {
7236                    x: (cx - dx, cx + dx),
7237                    y: (cy - dy, cy + dy),
7238                });
7239                added = Some(idx);
7240            }
7241            if ui.button("+ HRange").clicked() {
7242                let idx = self.add_roi(Roi::HRange {
7243                    y: (cy - dy, cy + dy),
7244                });
7245                added = Some(idx);
7246            }
7247            if ui.button("+ VRange").clicked() {
7248                let idx = self.add_roi(Roi::VRange {
7249                    x: (cx - dx, cx + dx),
7250                });
7251                added = Some(idx);
7252            }
7253            if ui.button("+ Point").clicked() {
7254                let idx = self.add_roi(Roi::Point { x: cx, y: cy });
7255                added = Some(idx);
7256            }
7257            if ui.button("+ Line").clicked() {
7258                let idx = self.add_roi(Roi::Line {
7259                    start: (cx - dx, cy),
7260                    end: (cx + dx, cy),
7261                });
7262                added = Some(idx);
7263            }
7264        });
7265
7266        // --- clear all ---
7267        if !self.backend.plot().rois.is_empty() && ui.button("Clear all").clicked() {
7268            self.clear_rois();
7269        }
7270
7271        added
7272    }
7273
7274    pub fn pick_item(&self, p: egui::Pos2, item: ItemHandle) -> Option<PickResult> {
7275        self.backend.pick_item(p, item)
7276    }
7277
7278    pub fn items_back_to_front(&self) -> Vec<ItemHandle> {
7279        self.backend.items_back_to_front()
7280    }
7281
7282    pub fn replot(&mut self) {
7283        self.backend.replot();
7284    }
7285
7286    pub fn save_graph(&self, path: &Path, size: (u32, u32)) -> Result<(), SaveError> {
7287        self.backend.save_graph(path, size)
7288    }
7289
7290    /// Render the figure to `path` in the given [`SaveFormat`] at `dpi`,
7291    /// generalizing [`Self::save_graph`] (PNG-only) over silx's raster save
7292    /// formats (PNG/PPM/SVG/TIFF) plus the raster-embedding EPS/PDF. Faithful to silx
7293    /// `BackendBase.saveGraph(fileName, fileFormat, dpi)`. The GPU readback +
7294    /// file write are native shims; the per-format encoding is unit-tested in
7295    /// [`crate::render::save`].
7296    pub fn save_graph_with_format(
7297        &self,
7298        path: &Path,
7299        size: (u32, u32),
7300        format: SaveFormat,
7301        dpi: u32,
7302    ) -> Result<(), SaveError> {
7303        self.backend.save_graph_with_format(path, size, format, dpi)
7304    }
7305
7306    /// Save to `path`, dispatching by its extension (silx `SaveAction`):
7307    /// a `.csv` path writes the active curve's `(x, y)` data; a figure
7308    /// extension (`png`/`ppm`/`svg`/`tif`/`tiff`/`eps`/`pdf`) renders the figure
7309    /// to a `size` pixel image in the matching [`SaveFormat`]. Returns `Ok(true)` when a file
7310    /// was written, `Ok(false)` when the path's extension is not a recognized save
7311    /// target or (for CSV) there is no active curve to save.
7312    ///
7313    /// All recognized figure formats are routed through
7314    /// [`Self::save_graph_with_format`] at `DEFAULT_SAVE_DPI`; PNG remains
7315    /// byte-identical to [`Self::save_graph`] (both go through
7316    /// [`crate::render::save::encode_png`]). The extension-to-target decision is
7317    /// the pure, unit-tested [`SaveTarget::from_path`](crate::SaveTarget::from_path).
7318    pub fn save_to_path(&self, path: &Path, size: (u32, u32)) -> Result<bool, SaveError> {
7319        use crate::widget::actions::io::{SaveTarget, curve_to_csv};
7320
7321        match SaveTarget::from_path(path) {
7322            Some(SaveTarget::Figure(format)) => {
7323                self.save_graph_with_format(path, size, format, DEFAULT_SAVE_DPI)?;
7324                Ok(true)
7325            }
7326            Some(SaveTarget::CurveCsv) => {
7327                let Some(handle) = self.active_curve() else {
7328                    return Ok(false);
7329                };
7330                let Some((x, y)) = self.retained_data(handle).and_then(retained_curve_xy) else {
7331                    return Ok(false);
7332                };
7333                let csv = curve_to_csv(x, y);
7334                std::fs::write(path, csv)?;
7335                Ok(true)
7336            }
7337            None => Ok(false),
7338        }
7339    }
7340
7341    /// Open a native save-file dialog (silx `SaveAction` file dialog) and save
7342    /// the figure or active-curve data to the chosen path via
7343    /// [`Self::save_to_path`]. Returns `Ok(true)` when a file was written,
7344    /// `Ok(false)` when the dialog was cancelled or the chosen path was not a
7345    /// recognized target. The dialog is a native shim; the save logic it calls is
7346    /// covered by unit tests.
7347    pub fn save_dialog(&self, size: (u32, u32)) -> Result<bool, SaveError> {
7348        let Some(path) = rfd::FileDialog::new()
7349            .add_filter("PNG figure", &["png"])
7350            .add_filter("PPM figure", &["ppm"])
7351            .add_filter("SVG figure", &["svg"])
7352            .add_filter("TIFF figure", &["tif", "tiff"])
7353            .add_filter("JPEG figure", &["jpg", "jpeg"])
7354            .add_filter("EPS figure", &["eps"])
7355            .add_filter("PDF figure", &["pdf"])
7356            .add_filter("Curve CSV", &["csv"])
7357            .save_file()
7358        else {
7359            return Ok(false);
7360        };
7361        self.save_to_path(&path, size)
7362    }
7363
7364    /// Save the current ROIs to `path` in the siplot ROI text format (silx
7365    /// `CurvesROIWidget.save(filename)`) via [`crate::save_rois`]. The encoder
7366    /// is unit-tested (`core::roi_io`); this is the widget-level wrapper.
7367    pub fn save_rois_to_path(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
7368        crate::core::roi_io::save_rois(path, self.rois())
7369    }
7370
7371    /// Replace the current ROIs with those loaded from `path` (silx
7372    /// `CurvesROIWidget.load(filename)`) via [`crate::load_rois`]. The previous
7373    /// set is cleared first, so this emits [`PlotEvent::RoisCleared`] followed by
7374    /// one [`PlotEvent::RoiAdded`] per loaded ROI.
7375    pub fn load_rois_from_path(
7376        &mut self,
7377        path: impl AsRef<std::path::Path>,
7378    ) -> std::io::Result<()> {
7379        let loaded = crate::core::roi_io::load_rois(path)?;
7380        self.clear_rois(); // RoisCleared
7381        for roi in loaded {
7382            self.add_managed_roi(roi); // RoiAdded { index }
7383        }
7384        Ok(())
7385    }
7386
7387    /// Open a native save-file dialog (silx `CurvesROIWidget` save button) and
7388    /// write the current ROIs to the chosen path via [`Self::save_rois_to_path`].
7389    /// Returns `Ok(true)` when a file was written, `Ok(false)` on cancel. The
7390    /// dialog is a native shim; the save logic it calls is unit-tested.
7391    pub fn save_rois_dialog(&self) -> std::io::Result<bool> {
7392        let Some(path) = rfd::FileDialog::new()
7393            .add_filter("siplot ROIs", &["rois", "txt"])
7394            .save_file()
7395        else {
7396            return Ok(false);
7397        };
7398        self.save_rois_to_path(&path)?;
7399        Ok(true)
7400    }
7401
7402    /// Open a native open-file dialog (silx `CurvesROIWidget` load button) and
7403    /// replace the current ROIs with those from the chosen path via
7404    /// [`Self::load_rois_from_path`]. Returns `Ok(true)` when a file was loaded,
7405    /// `Ok(false)` on cancel. The dialog is a native shim.
7406    pub fn load_rois_dialog(&mut self) -> std::io::Result<bool> {
7407        let Some(path) = rfd::FileDialog::new()
7408            .add_filter("siplot ROIs", &["rois", "txt"])
7409            .pick_file()
7410        else {
7411            return Ok(false);
7412        };
7413        self.load_rois_from_path(&path)?;
7414        Ok(true)
7415    }
7416
7417    /// Copy a `size` pixel snapshot of the figure to the system clipboard as an
7418    /// image (silx `CopyAction`, which renders the plot to a bitmap and calls
7419    /// `QApplication.clipboard().setImage`).
7420    ///
7421    /// The figure is rendered to a PNG (the only in-memory figure encoding
7422    /// available), decoded back to RGBA via
7423    /// [`decode_png_to_rgba`](crate::widget::actions::io::decode_png_to_rgba),
7424    /// shaped into an [`arboard::ImageData`] via
7425    /// [`rgba_to_clipboard_image`](crate::widget::actions::io::rgba_to_clipboard_image),
7426    /// then placed on the clipboard. The GPU readback and the clipboard call are
7427    /// untested native shims; the PNG-decode and RGBA-shaping logic between them
7428    /// is unit-tested.
7429    pub fn copy_to_clipboard(&self, size: (u32, u32)) -> Result<bool, SaveError> {
7430        use crate::widget::actions::io::{decode_png_to_rgba, rgba_to_clipboard_image};
7431
7432        // Render the figure to a temp PNG, then read it back. save_graph is the
7433        // only public figure-encoding entry point (it writes a PNG file).
7434        let mut path = std::env::temp_dir();
7435        path.push(format!("siplot-copy-{}.png", std::process::id()));
7436        self.save_graph(&path, size)?;
7437        let png = std::fs::read(&path)?;
7438        let _ = std::fs::remove_file(&path);
7439
7440        let (w, h, rgba) = decode_png_to_rgba(&png)?;
7441        let Some(image) = rgba_to_clipboard_image(&rgba, w, h) else {
7442            return Err(SaveError::Readback("clipboard image shaping failed".into()));
7443        };
7444        let mut clipboard = arboard::Clipboard::new()
7445            .map_err(|e| SaveError::Readback(format!("clipboard open: {e}")))?;
7446        clipboard
7447            .set_image(image)
7448            .map_err(|e| SaveError::Readback(format!("clipboard set_image: {e}")))?;
7449        Ok(true)
7450    }
7451
7452    /// Print a `size` pixel snapshot of the figure to the default system printer
7453    /// (silx `PrintAction.printPlot`).
7454    ///
7455    /// Mirrors silx's raster print path: silx renders the plot to a PNG
7456    /// (`_plotAsPNG`) and draws that bitmap onto the printer via
7457    /// `QPainter`/`QPrinter` — not vector graphics. Here the figure is rasterized
7458    /// to a temp PNG via [`Self::save_graph`] (the only public figure-encoding
7459    /// entry point), then submitted to the default printer with the
7460    /// [`printers`] crate. Returns `Ok(true)` when a print job was queued,
7461    /// `Ok(false)` when no default printer is available (the silx
7462    /// `getDefaultPrinter` analogue).
7463    ///
7464    /// The GPU readback and the printer submission are untested native shims (a
7465    /// real printer / spooler is required); the rasterization step reuses the
7466    /// unit-tested [`crate::render::save`] encoders, and the temp-path naming is
7467    /// unit-tested via `print_temp_png_path`. The toolbar Print button opens a
7468    /// printer-selection dialog ([`crate::widget::print_dialog::PrintDialog`])
7469    /// that routes to [`Self::print_graph_to`]; this method is the
7470    /// dialog-less direct path to the default printer.
7471    pub fn print_graph(&self, size: (u32, u32)) -> Result<bool, SaveError> {
7472        let Some(printer) = printers::get_default_printer() else {
7473            return Ok(false);
7474        };
7475        self.print_to_printer(&printer, size)
7476    }
7477
7478    /// Print a `size` pixel snapshot of the figure to the system printer with
7479    /// the given system name (the print dialog's chosen target). Returns
7480    /// `Ok(false)` when no printer of that name exists (e.g. it disappeared
7481    /// between the dialog opening and the click).
7482    pub fn print_graph_to(&self, printer_name: &str, size: (u32, u32)) -> Result<bool, SaveError> {
7483        let Some(printer) = printers::get_printer_by_name(printer_name) else {
7484            return Ok(false);
7485        };
7486        self.print_to_printer(&printer, size)
7487    }
7488
7489    /// Single submit owner for both print entry points: rasterize to a temp
7490    /// PNG, hand the file to `printer`, and always remove the temp file.
7491    fn print_to_printer(
7492        &self,
7493        printer: &printers::common::base::printer::Printer,
7494        size: (u32, u32),
7495    ) -> Result<bool, SaveError> {
7496        // Rasterize to a temp PNG, then hand the file to the printer. save_graph
7497        // is the only public figure-encoding entry point (it writes a PNG file),
7498        // and silx prints a PNG bitmap, so PNG is the faithful intermediate.
7499        let path = print_temp_png_path(&std::env::temp_dir(), std::process::id());
7500        self.save_graph(&path, size)?;
7501        let submit = printer.print_file(
7502            &path.to_string_lossy(),
7503            printers::common::base::job::PrinterJobOptions::none(),
7504        );
7505        let _ = std::fs::remove_file(&path);
7506        submit.map_err(|e| SaveError::Readback(format!("print submit: {}", e.message)))?;
7507        Ok(true)
7508    }
7509
7510    /// Apply accumulated data bounds to the current view.
7511    pub fn reset_zoom_to_data(&mut self) {
7512        self.apply_limits_from_data_bounds();
7513    }
7514
7515    fn apply_auto_limits(&mut self) {
7516        if self.auto_reset_zoom {
7517            self.apply_limits_from_data_bounds();
7518        }
7519    }
7520
7521    fn apply_limits_from_data_bounds(&mut self) {
7522        // Refit the extra axes first, independent of the left/right guard below:
7523        // each autoscale-on extra axis fits its own curves (the multi-axis
7524        // sibling of the left/right refit). Extra axes are not part of the
7525        // limits-history snapshot (interactive pan/zoom of them is not
7526        // supported), so this needs no `LimitsChanged` bookkeeping.
7527        let extra = extra_data_ranges(&self.data_bounds);
7528        if !extra.is_empty() {
7529            self.backend.plot_mut().reset_extra_axes_to(&extra);
7530        }
7531        // Preserve the original guard: a reset-to-data needs both X and left-Y
7532        // data accumulated before it does anything.
7533        if self.data_bounds.x.is_none() || self.data_bounds.y_left.is_none() {
7534            return;
7535        }
7536        // Delegate the per-axis refit decision to the single flag-aware owner
7537        // (`Plot::reset_zoom_to_data_range`): only autoscale-on axes refit from
7538        // data, off axes keep their current limits, and log axes force a refit
7539        // when their lower limit is <= 0. `WgpuBackend::set_limits` (the prior
7540        // path) only assigned `plot.limits`/`plot.y2` — the same two fields the
7541        // model owner writes — so delegating regresses no widget-side
7542        // bookkeeping; the `LimitsChanged` event is still raised here.
7543        let range = data_range_from_bounds(&self.data_bounds);
7544        let before = self.limits_snapshot();
7545        self.backend.plot_mut().reset_zoom_to_data_range(range);
7546        self.push_limits_changed_if(before);
7547    }
7548}
7549
7550/// High-level 1D plot. Methods are inherited from [`PlotWidget`] via `Deref`.
7551pub struct Plot1D {
7552    inner: PlotWidget,
7553}
7554
7555impl Plot1D {
7556    /// Create a 1D plot with default X/Y labels and major grid enabled.
7557    pub fn new(render_state: &RenderState, id: PlotId) -> Self {
7558        let mut inner = PlotWidget::new(render_state, id);
7559        inner.set_graph_x_label("X");
7560        inner.set_graph_y_label("Y", YAxis::Left);
7561        inner.set_graph_grid(true);
7562        Self { inner }
7563    }
7564
7565    /// Add a histogram to this 1D plot.
7566    pub fn add_histogram(
7567        &mut self,
7568        edges: &[f64],
7569        counts: &[f64],
7570        color: Color32,
7571    ) -> Result<ItemHandle, PlotDataError> {
7572        self.inner.add_histogram(edges, counts, color)
7573    }
7574
7575    /// Add a marker-only scatter item to this 1D plot.
7576    pub fn add_scatter(&mut self, x: &[f64], y: &[f64], color: Color32) -> ItemHandle {
7577        self.inner.add_scatter(x, y, color)
7578    }
7579
7580    /// Extract and add a horizontal image profile as a 1D curve.
7581    pub fn add_horizontal_profile_curve(
7582        &mut self,
7583        width: u32,
7584        height: u32,
7585        data: &[f32],
7586        row: u32,
7587        color: Color32,
7588    ) -> Result<ItemHandle, PlotDataError> {
7589        self.inner
7590            .add_horizontal_profile_curve(width, height, data, row, color)
7591    }
7592
7593    /// Extract and add a vertical image profile as a 1D curve.
7594    pub fn add_vertical_profile_curve(
7595        &mut self,
7596        width: u32,
7597        height: u32,
7598        data: &[f32],
7599        column: u32,
7600        color: Color32,
7601    ) -> Result<ItemHandle, PlotDataError> {
7602        self.inner
7603            .add_vertical_profile_curve(width, height, data, column, color)
7604    }
7605
7606    /// Unwrap to the underlying [`PlotWidget`].
7607    pub fn into_inner(self) -> PlotWidget {
7608        self.inner
7609    }
7610}
7611
7612impl Deref for Plot1D {
7613    type Target = PlotWidget;
7614
7615    fn deref(&self) -> &Self::Target {
7616        &self.inner
7617    }
7618}
7619
7620impl DerefMut for Plot1D {
7621    fn deref_mut(&mut self) -> &mut Self::Target {
7622        &mut self.inner
7623    }
7624}
7625
7626/// High-level 2D plot. Methods are inherited from [`PlotWidget`] via `Deref`.
7627pub struct Plot2D {
7628    inner: PlotWidget,
7629}
7630
7631impl Plot2D {
7632    /// Create a 2D plot with column/row labels, no grid, aspect lock, and Y-axis inverted.
7633    pub fn new(render_state: &RenderState, id: PlotId) -> Self {
7634        let mut inner = PlotWidget::new(render_state, id);
7635        inner.set_graph_x_label("Columns");
7636        inner.set_graph_y_label("Rows", YAxis::Left);
7637        inner.set_graph_grid(false);
7638        inner.set_keep_data_aspect_ratio(true);
7639        inner.set_y_inverted(true);
7640        Self { inner }
7641    }
7642
7643    /// Add a scalar image using this plot's default colormap.
7644    pub fn add_default_image(&mut self, width: u32, height: u32, data: &[f32]) -> ItemHandle {
7645        self.inner.add_image_default(width, height, data)
7646    }
7647
7648    /// Add a scalar image using this plot's default colormap, returning an
7649    /// error instead of panicking on length mismatch.
7650    pub fn try_add_default_image(
7651        &mut self,
7652        width: u32,
7653        height: u32,
7654        data: &[f32],
7655    ) -> Result<ItemHandle, PlotDataError> {
7656        self.inner.try_add_image_default(width, height, data)
7657    }
7658
7659    /// Add a scalar image whose pixels are masked to `NaN` before upload, using
7660    /// this plot's default colormap.
7661    ///
7662    /// Mirrors silx `items/image.py` `getValueData` (mask → NaN): every pixel
7663    /// flagged in `mask` ([`ScalarMask::is_masked`]) becomes `f32::NAN` *before*
7664    /// the data is handed to the backend, so the scalar pipeline's `nan_color`
7665    /// renders it as a hole — exactly as if the data had arrived with NaNs. The
7666    /// mask is optional and applied here, keeping the upload path additive.
7667    ///
7668    /// `mask` must describe a `width × height` image (its own
7669    /// [`ScalarMask::width`]/[`ScalarMask::height`]); a mismatch with the
7670    /// supplied `(width, height)` or `data.len()` returns
7671    /// [`PlotDataError::ImageDataLength`].
7672    pub fn try_add_masked_image(
7673        &mut self,
7674        width: u32,
7675        height: u32,
7676        data: &[f32],
7677        mask: &ScalarMask,
7678    ) -> Result<ItemHandle, PlotDataError> {
7679        // silx getValueData: masked pixels become NaN before reaching the
7680        // backend (the existing NaN rendering then displays the hole).
7681        let masked = apply_image_mask(width, height, data, mask)?;
7682        self.inner.try_add_image_default(width, height, &masked)
7683    }
7684
7685    /// Add a boolean mask overlay.
7686    pub fn add_mask(
7687        &mut self,
7688        width: u32,
7689        height: u32,
7690        mask: &[bool],
7691        color: Color32,
7692    ) -> Result<ItemHandle, PlotDataError> {
7693        self.inner.add_mask(width, height, mask, color)
7694    }
7695
7696    /// Add a boolean mask overlay with explicit image geometry.
7697    pub fn add_mask_with_geometry(
7698        &mut self,
7699        width: u32,
7700        height: u32,
7701        mask: &[bool],
7702        color: Color32,
7703        geometry: ImageGeometry,
7704    ) -> Result<ItemHandle, PlotDataError> {
7705        self.inner
7706            .add_mask_with_geometry(width, height, mask, color, geometry)
7707    }
7708
7709    /// Extract a row profile from image data.
7710    pub fn horizontal_profile(
7711        &self,
7712        width: u32,
7713        height: u32,
7714        data: &[f32],
7715        row: u32,
7716    ) -> Result<Vec<f64>, PlotDataError> {
7717        horizontal_profile_values(width, height, data, row)
7718    }
7719
7720    /// Extract a column profile from image data.
7721    pub fn vertical_profile(
7722        &self,
7723        width: u32,
7724        height: u32,
7725        data: &[f32],
7726        column: u32,
7727    ) -> Result<Vec<f64>, PlotDataError> {
7728        vertical_profile_values(width, height, data, column)
7729    }
7730
7731    /// Extract a profile at the cursor position from `plot_response`.
7732    ///
7733    /// Returns `Some((x_axis, y_values))` when `mode` is active and the cursor is over
7734    /// a valid pixel, `None` otherwise.  `pixels` must be a row-major `f32` array of
7735    /// `width * height` elements.
7736    ///
7737    /// Typical use in the frame loop:
7738    ///
7739    /// ```ignore
7740    /// if let Some((x, y)) = image_plot.profile_at_cursor(&resp, &pixels, w, h, mode) {
7741    ///     profile_plot.update_curve_data(handle, &CurveData::new(x, y, Color32::YELLOW));
7742    /// }
7743    /// ```
7744    pub fn profile_at_cursor(
7745        &self,
7746        plot_response: &PlotResponse,
7747        pixels: &[f32],
7748        width: u32,
7749        height: u32,
7750        mode: ProfileMode,
7751    ) -> Option<(Vec<f64>, Vec<f64>)> {
7752        if mode == ProfileMode::None {
7753            return None;
7754        }
7755        let hover_px = plot_response.response.hover_pos()?;
7756        let (data_x, data_y) = plot_response.transform.pixel_to_data(hover_px);
7757
7758        let col = data_x.floor() as i64;
7759        let row = data_y.floor() as i64;
7760
7761        match mode {
7762            ProfileMode::None => None,
7763            ProfileMode::Horizontal => {
7764                if row < 0 || row >= height as i64 {
7765                    return None;
7766                }
7767                horizontal_profile_values(width, height, pixels, row as u32)
7768                    .ok()
7769                    .map(|y| {
7770                        let x: Vec<f64> = (0..width as usize).map(|i| i as f64).collect();
7771                        (x, y)
7772                    })
7773            }
7774            ProfileMode::Vertical => {
7775                if col < 0 || col >= width as i64 {
7776                    return None;
7777                }
7778                vertical_profile_values(width, height, pixels, col as u32)
7779                    .ok()
7780                    .map(|y| {
7781                        let x: Vec<f64> = (0..height as usize).map(|i| i as f64).collect();
7782                        (x, y)
7783                    })
7784            }
7785            _ => None,
7786        }
7787    }
7788
7789    /// Draw the median-filter controls (silx `MedianFilterDialog`): an odd
7790    /// kernel-width drag, a conditional checkbox, and an Apply button.
7791    ///
7792    /// `params` holds the popup state (held by the caller, e.g. in egui
7793    /// temp-memory); the widgets mutate it. On Apply this runs
7794    /// [`PlotWidget::apply_median_filter`] on the active image (square
7795    /// `(width, width)` kernel, silx `MedianFilter2DAction`, default
7796    /// `mode='nearest'`), replacing it in place, and returns `true`. Returns
7797    /// `false` on any frame Apply was not clicked or no scalar image was active.
7798    ///
7799    /// Render it into any `Ui`; the toolbar shows it in a detachable native
7800    /// window via [`crate::widget::detached::show_detached`] for the silx popup
7801    /// feel:
7802    ///
7803    /// ```ignore
7804    /// plot.show_median_filter(ui, &mut params);
7805    /// ```
7806    pub fn show_median_filter(
7807        &mut self,
7808        ui: &mut egui::Ui,
7809        params: &mut MedianFilterParams,
7810    ) -> bool {
7811        ui.horizontal(|ui| {
7812            ui.label("Kernel width:");
7813            // silx MedianFilterDialog spinbox: min 1, step 2 (odd). The drag steps
7814            // by 2 and we re-force odd in case the value is typed/clamped even.
7815            let mut width = params.kernel_width.max(1);
7816            if ui
7817                .add(egui::DragValue::new(&mut width).range(1..=99).speed(2.0))
7818                .changed()
7819            {
7820                params.kernel_width = force_odd(width);
7821            }
7822        });
7823        ui.checkbox(&mut params.conditional, "Conditional")
7824            .on_hover_text("Replace a pixel only if it is the window min or max");
7825
7826        let mut applied = false;
7827        let has_image = self.get_image_pixels_raw().is_some();
7828        if ui
7829            .add_enabled(has_image, egui::Button::new("Apply"))
7830            .on_hover_text("Replace the active image with its median-filtered copy")
7831            .clicked()
7832        {
7833            applied = self.apply_median_filter(params.kernel_width, params.conditional);
7834        }
7835        applied
7836    }
7837
7838    /// Draw a median-filter toolbar button that toggles a popup window with the
7839    /// kernel/conditional/Apply controls (silx `MedianFilterAction`, a checkable
7840    /// toolbar action opening `MedianFilterDialog`).
7841    ///
7842    /// The popup open-state and [`MedianFilterParams`] are stored in egui
7843    /// temp-memory keyed by this plot's id, so the button is self-contained:
7844    /// callers can drop it into any toolbar row. Returns `true` on a frame the
7845    /// Apply button replaced the active image.
7846    ///
7847    /// Place it inside [`PlotWidget::show_toolbar_with`] to share the standard
7848    /// toolbar row:
7849    ///
7850    /// ```ignore
7851    /// let (_, applied) = plot.show_toolbar_with(ui, |ui, _| {
7852    ///     // (plot is borrowed by the closure as the same Plot2D's inner)
7853    /// });
7854    /// ```
7855    pub fn show_median_filter_toolbar(&mut self, ui: &mut egui::Ui) -> bool {
7856        let plot_id = self.backend().plot().id;
7857        let open_id = egui::Id::new(plot_id).with("median_filter_open");
7858        let params_id = egui::Id::new(plot_id).with("median_filter_params");
7859
7860        let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
7861        let has_image = self.get_image_pixels_raw().is_some();
7862
7863        let button = ui
7864            .add_enabled_ui(has_image, |ui| {
7865                toolbar_icon_button(ui, ToolbarIcon::MedianFilter, open, "Median filter")
7866            })
7867            .inner;
7868        if button.clicked() {
7869            open = !open;
7870        }
7871
7872        let mut applied = false;
7873        if open {
7874            let mut params = ui
7875                .data(|d| d.get_temp::<MedianFilterParams>(params_id))
7876                .unwrap_or_default();
7877            let signals = crate::widget::detached::show_detached(
7878                ui.ctx(),
7879                open_id.with("window"),
7880                "Median filter",
7881                egui::vec2(320.0, 220.0),
7882                None,
7883                |ui| {
7884                    applied = self.show_median_filter(ui, &mut params);
7885                },
7886            );
7887            ui.data_mut(|d| d.insert_temp(params_id, params));
7888            if signals.close_requested {
7889                open = false;
7890            }
7891        }
7892
7893        ui.data_mut(|d| d.insert_temp(open_id, open));
7894        applied
7895    }
7896
7897    /// A drop-down tool button that sets the marker symbol and size of *every*
7898    /// curve-like item in the plot, mirroring silx `SymbolToolButton`
7899    /// (`PlotToolButtons.py:458-478`, instant-popup menu). The menu carries a
7900    /// size [`egui::DragValue`] (silx's 1..20 size slider) followed by a "None"
7901    /// entry (silx's empty symbol) and one entry per [`Symbol`]
7902    /// ([`Symbol::ALL`], silx order). Picking a size drives
7903    /// [`PlotWidget::set_all_symbol_sizes`]; picking a symbol drives
7904    /// [`PlotWidget::set_all_symbols`]. The pending size persists in egui temp
7905    /// memory keyed by the plot id so it survives across frames, like the other
7906    /// toolbar popups here.
7907    ///
7908    /// Place it inside [`PlotWidget::show_toolbar_with`] to share the standard
7909    /// toolbar row.
7910    pub fn symbol_tool_button(&mut self, ui: &mut egui::Ui) {
7911        let plot_id = self.backend().plot().id;
7912        let size_id = egui::Id::new(plot_id).with("symbol_tool_size");
7913        let mut size = ui.data(|d| d.get_temp::<f32>(size_id)).unwrap_or(7.0);
7914
7915        ui.menu_button("Symbol", |ui| {
7916            ui.horizontal(|ui| {
7917                ui.label("Size:");
7918                if ui
7919                    .add(egui::DragValue::new(&mut size).range(1.0..=20.0).speed(0.5))
7920                    .on_hover_text("Marker size for every curve/scatter")
7921                    .changed()
7922                {
7923                    self.set_all_symbol_sizes(size);
7924                }
7925            });
7926            ui.separator();
7927            if ui.button("None").clicked() {
7928                self.set_all_symbols(None);
7929                ui.close();
7930            }
7931            for symbol in Symbol::ALL {
7932                if ui.button(symbol.name()).clicked() {
7933                    self.set_all_symbols(Some(symbol));
7934                    ui.close();
7935                }
7936            }
7937        });
7938
7939        ui.data_mut(|d| d.insert_temp(size_id, size));
7940    }
7941
7942    /// Draw the pixel-intensity histogram of the active image as bars + stats,
7943    /// with an editable bin-count control (silx `PixelIntensitiesHistoAction` /
7944    /// `HistogramWidget`).
7945    ///
7946    /// This is a UI shim: it computes the histogram on the CPU via
7947    /// [`PlotWidget::active_image_histogram`] and paints the bars with the egui
7948    /// painter (no second GPU `Plot1D`). `n_bins` holds the bin count chosen by
7949    /// the caller (e.g. egui temp-memory); `None` means "silx default", which is
7950    /// resolved to the actual count and written back so the control shows it.
7951    /// The bars / stats redraw whenever `*n_bins` changes (recompute on change).
7952    ///
7953    /// Returns the computed [`PixelHistogram`](crate::widget::actions::analysis::PixelHistogram),
7954    /// or `None` when there is no scalar image with finite pixels.
7955    pub fn show_pixel_histogram(
7956        &mut self,
7957        ui: &mut egui::Ui,
7958        n_bins: &mut Option<usize>,
7959    ) -> Option<crate::widget::actions::analysis::PixelHistogram> {
7960        let histogram = self.active_image_histogram(*n_bins);
7961        let Some(histo) = histogram else {
7962            ui.label("No image with finite pixels.");
7963            return None;
7964        };
7965
7966        // Bin-count control: seed from the resolved count so the field shows the
7967        // silx default, then recompute when the user changes it.
7968        let mut bins = n_bins.unwrap_or(histo.n_bins).max(2);
7969        ui.horizontal(|ui| {
7970            ui.label("Bins:");
7971            if ui
7972                .add(egui::DragValue::new(&mut bins).range(2..=1024))
7973                .changed()
7974            {
7975                *n_bins = Some(bins.max(2));
7976            }
7977        });
7978        // Recompute if the control changed the count this frame.
7979        let histo = if *n_bins == Some(histo.n_bins) || n_bins.is_none() {
7980            histo
7981        } else {
7982            match self.active_image_histogram(*n_bins) {
7983                Some(h) => h,
7984                None => return Some(histo),
7985            }
7986        };
7987
7988        // Stats line (silx HistogramWidget min/max/mean/std/sum).
7989        ui.label(format!(
7990            "min {:.4}  max {:.4}  mean {:.4}  std {:.4}  sum {:.4}",
7991            histo.min, histo.max, histo.mean, histo.std, histo.sum
7992        ));
7993
7994        // Bar chart drawn with the egui painter.
7995        let max_count = histo.counts.iter().copied().max().unwrap_or(0).max(1) as f32;
7996        let desired = egui::vec2(ui.available_width().max(120.0), 120.0);
7997        let (rect, _resp) = ui.allocate_exact_size(desired, egui::Sense::hover());
7998        if ui.is_rect_visible(rect) {
7999            let painter = ui.painter_at(rect);
8000            painter.rect_filled(rect, 0.0, ui.visuals().extreme_bg_color);
8001            let n = histo.counts.len().max(1);
8002            let bar_w = rect.width() / n as f32;
8003            let fill = Color32::from_rgb(0x66, 0xaa, 0xd7); // silx histogram color.
8004            for (i, &count) in histo.counts.iter().enumerate() {
8005                let h = (count as f32 / max_count) * rect.height();
8006                let x0 = rect.left() + i as f32 * bar_w;
8007                let bar = egui::Rect::from_min_max(
8008                    egui::pos2(x0, rect.bottom() - h),
8009                    egui::pos2(x0 + bar_w, rect.bottom()),
8010                );
8011                painter.rect_filled(bar.shrink(0.5), 0.0, fill);
8012            }
8013        }
8014
8015        Some(histo)
8016    }
8017
8018    /// Draw a pixel-intensity histogram toolbar button that toggles a popup
8019    /// window with the bars + stats + bin control (silx
8020    /// `PixelIntensitiesHistoAction`, a checkable action opening its
8021    /// `HistogramWidget`).
8022    ///
8023    /// Open-state and the chosen bin count are stored in egui temp-memory keyed
8024    /// by this plot's id. Returns `true` while the window is open this frame.
8025    pub fn show_pixel_histogram_toolbar(&mut self, ui: &mut egui::Ui) -> bool {
8026        let plot_id = self.backend().plot().id;
8027        let open_id = egui::Id::new(plot_id).with("pixel_histogram_open");
8028        let bins_id = egui::Id::new(plot_id).with("pixel_histogram_bins");
8029
8030        let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
8031        let has_image = self.get_image_pixels_raw().is_some();
8032
8033        let button = ui
8034            .add_enabled_ui(has_image, |ui| {
8035                toolbar_icon_button(ui, ToolbarIcon::PixelHistogram, open, "Pixel intensity")
8036            })
8037            .inner;
8038        if button.clicked() {
8039            open = !open;
8040        }
8041
8042        if open {
8043            let mut n_bins = ui.data(|d| d.get_temp::<Option<usize>>(bins_id)).flatten();
8044            let signals = crate::widget::detached::show_detached(
8045                ui.ctx(),
8046                open_id.with("window"),
8047                "Pixel intensity",
8048                egui::vec2(480.0, 360.0),
8049                None,
8050                |ui| {
8051                    self.show_pixel_histogram(ui, &mut n_bins);
8052                },
8053            );
8054            ui.data_mut(|d| d.insert_temp(bins_id, n_bins));
8055            if signals.close_requested {
8056                open = false;
8057            }
8058        }
8059
8060        ui.data_mut(|d| d.insert_temp(open_id, open));
8061        open
8062    }
8063
8064    /// Unwrap to the underlying [`PlotWidget`].
8065    pub fn into_inner(self) -> PlotWidget {
8066        self.inner
8067    }
8068}
8069
8070impl Deref for Plot2D {
8071    type Target = PlotWidget;
8072
8073    fn deref(&self) -> &Self::Target {
8074        &self.inner
8075    }
8076}
8077
8078impl DerefMut for Plot2D {
8079    fn deref_mut(&mut self) -> &mut Self::Target {
8080        &mut self.inner
8081    }
8082}
8083
8084// ─── CompareImages ────────────────────────────────────────────────────────────
8085
8086/// Visual mode for [`CompareImages`].
8087///
8088/// Mirrors the `VisualizationMode` options in silx `CompareImages.py`.
8089#[derive(Clone, Copy, Debug, Default, PartialEq)]
8090pub enum CompareMode {
8091    /// Show only image A.
8092    OnlyA,
8093    /// Show only image B.
8094    OnlyB,
8095    /// Left/right split with a vertical separator: the left `split` fraction of
8096    /// columns shows A, the rest shows B (silx `VisualizationMode.VERTICAL_LINE`,
8097    /// CompareImages.py:422-433).
8098    #[default]
8099    HalfHalf,
8100    /// Top/bottom split with a horizontal separator: the top `split` fraction of
8101    /// rows shows A, the rest shows B (silx
8102    /// `VisualizationMode.HORIZONTAL_LINE`, CompareImages.py:434-445).
8103    SplitHorizontal,
8104    /// Pixel-wise A − B, normalised to `[-1, 1]` for display.
8105    Subtract,
8106    /// RGB composite: A's normalised intensity in the red channel, B's in blue,
8107    /// their half-sum in green (silx `VisualizationMode.COMPOSITE_RED_BLUE_GRAY`,
8108    /// CompareImages.py:744-747).
8109    RedBlueGray,
8110    /// Negative RGB composite: each channel of [`Self::RedBlueGray`] inverted
8111    /// (silx `VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG`,
8112    /// CompareImages.py:748-751).
8113    RedBlueGrayNeg,
8114}
8115
8116/// How the two compared images are placed on a common grid when they differ in
8117/// shape, mirroring silx `AlignmentMode` (`tools/compare/core.py`).
8118///
8119/// The first three are resampling-free / bilinear placements. [`Auto`] is the
8120/// silx `AUTO` mode: SIFT keypoint registration + affine warp
8121/// ([`crate::sift_auto_align`]), the only mode for which silx's
8122/// `getTransformation` returns an affine (it is `None` for the others).
8123///
8124/// [`Auto`]: CompareAlignment::Auto
8125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
8126pub enum CompareAlignment {
8127    /// Both images anchored at the top-left origin on a common
8128    /// `max(w_a, w_b) × max(h_a, h_b)` grid, the smaller zero-padded (silx
8129    /// `ORIGIN`: `__createMarginImage` at position `(0, 0)`).
8130    #[default]
8131    Origin,
8132    /// Both images centered on the common `max × max` grid, zero-padded (silx
8133    /// `CENTER`: `__createMarginImage(center=True)`, offset `size // 2 -
8134    /// shape // 2`).
8135    Center,
8136    /// Image B bilinearly resampled to image A's shape; the common grid is A's
8137    /// shape (silx `STRETCH`: `data1 = raw1`, `data2 = __rescaleImage(raw2,
8138    /// raw1.shape)`).
8139    Stretch,
8140    /// SIFT keypoint registration: both images are padded to the common
8141    /// `max × max` grid, SIFT-matched, and image B is affine-warped onto image
8142    /// A's frame (silx `AUTO`: `__createSiftData` → `LinearAlign`). The estimated
8143    /// affine transform is read out via [`CompareImages::transformation`] (silx
8144    /// `getTransformation`). If fewer than three keypoints match, the widget
8145    /// falls back to [`Origin`](Self::Origin) (silx `__setDefaultAlignmentMode`).
8146    Auto,
8147}
8148
8149/// Colour of the matched-keypoint scatter overlay (silx renders the keypoints
8150/// with a dedicated colormap; siplot uses one conspicuous colour).
8151const KEYPOINT_COLOR: Color32 = Color32::from_rgb(255, 0, 255);
8152
8153/// A retained widget that displays two co-registered images with a draggable
8154/// split slider, mirroring silx `CompareImages`.
8155///
8156/// Create once, call [`Self::set_images`] to upload both images, then in the
8157/// frame loop call [`Self::show_toolbar`] and [`Self::show`].
8158///
8159/// ```ignore
8160/// let mut cmp = CompareImages::new(render_state, 0);
8161/// cmp.set_images((wa, ha), &data_a, (wb, hb), &data_b, Colormap::viridis(0.0, 1.0))?;
8162///
8163/// // frame loop
8164/// cmp.show_toolbar(ui);
8165/// cmp.show(ui);
8166/// ```
8167pub struct CompareImages {
8168    inner: PlotWidget,
8169    width_a: u32,
8170    height_a: u32,
8171    width_b: u32,
8172    height_b: u32,
8173    data_a: Vec<f32>,
8174    data_b: Vec<f32>,
8175    colormap: Colormap,
8176    composite_handle: Option<ItemHandle>,
8177    split: f32,
8178    mode: CompareMode,
8179    /// Alignment of A and B on the common display grid (silx `AlignmentMode`).
8180    alignment: CompareAlignment,
8181    dirty: bool,
8182    /// Latest pointer data position over the plot (silx status bar `self._pos`),
8183    /// updated each frame in [`Self::show`]; `None` before any pointer move.
8184    cursor: Option<[f64; 2]>,
8185    /// Handle of the on-plot draggable split separator (silx `__vline`/`__hline`),
8186    /// or `None` when the current mode shows no separator. silx keeps both markers
8187    /// and toggles `setVisible`; siplot markers carry no visibility flag, so the
8188    /// separator is recreated when its orientation must change and removed when no
8189    /// split is shown.
8190    separator: Option<ItemHandle>,
8191    /// Orientation of the live `separator`: `true` = horizontal line (slides
8192    /// vertically, [`CompareMode::SplitHorizontal`]); `false` = vertical line
8193    /// ([`CompareMode::HalfHalf`]).
8194    separator_horizontal: bool,
8195    /// Whether the separator is mid-drag. While dragging, [`Self::show`] reads the
8196    /// marker position back into `split` (silx `__separatorMoved`) and does not
8197    /// reposition the marker out from under the cursor.
8198    separator_dragging: bool,
8199    /// Common-grid `(width, height)` of the last-built composite, used to map the
8200    /// separator's data position to/from the `[0, 1]` `split` fraction without
8201    /// rebuilding the pixels.
8202    composite_w: u32,
8203    composite_h: u32,
8204    /// Cached result of the last [`CompareAlignment::Auto`] SIFT registration
8205    /// (silx `__createSiftData` output): image B warped onto A's grid plus the
8206    /// estimated affine and matched keypoints. Recomputed only when the images or
8207    /// alignment change (SIFT is expensive); `None` in every other alignment mode
8208    /// and when registration found too few keypoints.
8209    auto: Option<SiftAlignment>,
8210    /// Whether the matched SIFT keypoints are drawn as a scatter overlay (silx
8211    /// `setKeypointsVisible`/`getKeypointsVisible`). Off by default, matching silx
8212    /// (`__init__` calls `setKeypointsVisible(False)`).
8213    keypoints_visible: bool,
8214    /// Handle of the live keypoint scatter overlay (silx `__scatter`), or `None`
8215    /// when hidden / no registration is in effect. Rebuilt with the composite.
8216    keypoint_overlay: Option<ItemHandle>,
8217}
8218
8219impl CompareImages {
8220    /// Create a new compare-images widget backed by wgpu plot id `id`.
8221    pub fn new(render_state: &RenderState, id: PlotId) -> Self {
8222        let mut inner = PlotWidget::new(render_state, id);
8223        inner.set_keep_data_aspect_ratio(true);
8224        Self {
8225            inner,
8226            width_a: 0,
8227            height_a: 0,
8228            width_b: 0,
8229            height_b: 0,
8230            data_a: Vec::new(),
8231            data_b: Vec::new(),
8232            colormap: Colormap::viridis(0.0, 1.0),
8233            composite_handle: None,
8234            split: 0.5,
8235            mode: CompareMode::HalfHalf,
8236            alignment: CompareAlignment::default(),
8237            dirty: false,
8238            cursor: None,
8239            separator: None,
8240            separator_horizontal: false,
8241            separator_dragging: false,
8242            composite_w: 0,
8243            composite_h: 0,
8244            auto: None,
8245            keypoints_visible: false,
8246            keypoint_overlay: None,
8247        }
8248    }
8249
8250    /// Upload both images. Unlike the old single-shape API, A and B may have
8251    /// different shapes (silx `setData(image1, image2)`), each given as a
8252    /// `(width, height)` tuple; the [`alignment`] mode decides how they share a
8253    /// common display grid. Validates `data_a.len() == width_a * height_a` and
8254    /// `data_b.len() == width_b * height_b`.
8255    ///
8256    /// [`alignment`]: Self::alignment
8257    pub fn set_images(
8258        &mut self,
8259        shape_a: (u32, u32),
8260        data_a: &[f32],
8261        shape_b: (u32, u32),
8262        data_b: &[f32],
8263        colormap: Colormap,
8264    ) -> Result<(), PlotDataError> {
8265        let (width_a, height_a) = shape_a;
8266        let (width_b, height_b) = shape_b;
8267        let expected_a = (width_a as usize).saturating_mul(height_a as usize);
8268        if data_a.len() != expected_a {
8269            return Err(PlotDataError::ImageDataLength {
8270                expected: expected_a,
8271                actual: data_a.len(),
8272            });
8273        }
8274        let expected_b = (width_b as usize).saturating_mul(height_b as usize);
8275        if data_b.len() != expected_b {
8276            return Err(PlotDataError::ImageDataLength {
8277                expected: expected_b,
8278                actual: data_b.len(),
8279            });
8280        }
8281        self.width_a = width_a;
8282        self.height_a = height_a;
8283        self.width_b = width_b;
8284        self.height_b = height_b;
8285        self.data_a = data_a.to_vec();
8286        self.data_b = data_b.to_vec();
8287        self.colormap = colormap;
8288        self.dirty = true;
8289        Ok(())
8290    }
8291
8292    /// Current image-alignment mode (silx `getAlignmentMode`).
8293    pub fn alignment(&self) -> CompareAlignment {
8294        self.alignment
8295    }
8296
8297    /// Set the image-alignment mode (silx `setAlignmentMode`).
8298    pub fn set_alignment(&mut self, alignment: CompareAlignment) {
8299        if alignment != self.alignment {
8300            self.alignment = alignment;
8301            self.dirty = true;
8302        }
8303    }
8304
8305    /// The affine transform applied to image B to align it onto image A, or
8306    /// `None` when no SIFT registration is in effect — silx
8307    /// `CompareImages.getTransformation`.
8308    ///
8309    /// silx populates the transform *only* from the `AUTO`/SIFT path
8310    /// (`__createSiftData`); `getTransformation` is `None` for ORIGIN/CENTER/
8311    /// STRETCH. siplot matches this: the value is `Some` exactly while
8312    /// [`CompareAlignment::Auto`] registration has succeeded (it is cleared when
8313    /// the mode changes or registration fails), so it reads as `None` in every
8314    /// non-SIFT mode.
8315    pub fn transformation(&self) -> Option<AffineTransformation> {
8316        self.auto.as_ref().map(|a| a.transformation)
8317    }
8318
8319    /// Whether the matched SIFT keypoints are drawn over the images — silx
8320    /// `getKeypointsVisible`.
8321    pub fn keypoints_visible(&self) -> bool {
8322        self.keypoints_visible
8323    }
8324
8325    /// Show or hide the matched SIFT keypoints overlay — silx
8326    /// `setKeypointsVisible`. The overlay only ever has points to draw in
8327    /// [`CompareAlignment::Auto`] (it is fed by the registration's matched
8328    /// keypoints); in the other modes this flag has no visible effect.
8329    pub fn set_keypoints_visible(&mut self, visible: bool) {
8330        if visible != self.keypoints_visible {
8331            self.keypoints_visible = visible;
8332            self.dirty = true;
8333        }
8334    }
8335
8336    /// The matched SIFT keypoint pairs from the active registration (silx
8337    /// `__matching_keypoints`), or an empty slice when no registration is in
8338    /// effect.
8339    pub fn matched_keypoints(&self) -> &[MatchedKeypoint] {
8340        self.auto
8341            .as_ref()
8342            .map(|a| a.matches.as_slice())
8343            .unwrap_or(&[])
8344    }
8345
8346    /// Current split position in [0, 1] — fraction of the width shown as A.
8347    pub fn split(&self) -> f32 {
8348        self.split
8349    }
8350
8351    /// Set the split position.
8352    pub fn set_split(&mut self, split: f32) {
8353        let clamped = split.clamp(0.0, 1.0);
8354        if (clamped - self.split).abs() > 1e-6 {
8355            self.split = clamped;
8356            self.dirty = true;
8357        }
8358    }
8359
8360    /// Current visualization mode.
8361    pub fn mode(&self) -> CompareMode {
8362        self.mode
8363    }
8364
8365    /// Set the visualization mode.
8366    pub fn set_mode(&mut self, mode: CompareMode) {
8367        if mode != self.mode {
8368            self.mode = mode;
8369            self.dirty = true;
8370        }
8371    }
8372
8373    /// Show mode + split controls in a compact toolbar row.  Returns the current mode.
8374    ///
8375    /// Call this before [`Self::show`].
8376    pub fn show_toolbar(&mut self, ui: &mut egui::Ui) -> CompareMode {
8377        ui.horizontal_wrapped(|ui| {
8378            ui.spacing_mut().item_spacing.x = 2.0;
8379
8380            for (label, tooltip, m) in [
8381                ("A", "Show only image A", CompareMode::OnlyA),
8382                ("B", "Show only image B", CompareMode::OnlyB),
8383                (
8384                    "½",
8385                    "Vertical split: A left / B right (drag the separator or slider)",
8386                    CompareMode::HalfHalf,
8387                ),
8388                (
8389                    "═",
8390                    "Horizontal split: A top / B bottom (drag the separator or slider)",
8391                    CompareMode::SplitHorizontal,
8392                ),
8393                ("A-B", "Subtract: A minus B", CompareMode::Subtract),
8394                (
8395                    "R/B",
8396                    "Composite: A in red, B in blue, half-sum in green",
8397                    CompareMode::RedBlueGray,
8398                ),
8399                (
8400                    "R/B⁻",
8401                    "Negative composite: red-blue channels inverted",
8402                    CompareMode::RedBlueGrayNeg,
8403                ),
8404            ] {
8405                if ui
8406                    .selectable_label(self.mode == m, label)
8407                    .on_hover_text(tooltip)
8408                    .clicked()
8409                    && self.mode != m
8410                {
8411                    self.mode = m;
8412                    self.dirty = true;
8413                }
8414            }
8415
8416            let is_split = matches!(
8417                self.mode,
8418                CompareMode::HalfHalf | CompareMode::SplitHorizontal
8419            );
8420            if is_split && !self.data_a.is_empty() {
8421                ui.add_space(4.0);
8422                if ui
8423                    .add(egui::Slider::new(&mut self.split, 0.0..=1.0).text("split"))
8424                    .changed()
8425                {
8426                    self.dirty = true;
8427                }
8428            }
8429
8430            ui.add_space(8.0);
8431            ui.label("align:");
8432            for (label, tooltip, a) in [
8433                (
8434                    "orig",
8435                    "Align both images at the top-left origin",
8436                    CompareAlignment::Origin,
8437                ),
8438                (
8439                    "ctr",
8440                    "Center both images on the common grid",
8441                    CompareAlignment::Center,
8442                ),
8443                (
8444                    "fit",
8445                    "Stretch image B to image A's shape (bilinear)",
8446                    CompareAlignment::Stretch,
8447                ),
8448                (
8449                    "auto",
8450                    "Auto-align image B to A by SIFT keypoint registration",
8451                    CompareAlignment::Auto,
8452                ),
8453            ] {
8454                if ui
8455                    .selectable_label(self.alignment == a, label)
8456                    .on_hover_text(tooltip)
8457                    .clicked()
8458                    && self.alignment != a
8459                {
8460                    self.alignment = a;
8461                    self.dirty = true;
8462                }
8463            }
8464
8465            // Keypoint-overlay toggle (silx `setKeypointsVisible`); only has
8466            // matched keypoints to show in the AUTO alignment.
8467            ui.add_space(8.0);
8468            let mut visible = self.keypoints_visible;
8469            if ui
8470                .checkbox(&mut visible, "kp")
8471                .on_hover_text("Show the matched SIFT keypoints (AUTO alignment)")
8472                .changed()
8473            {
8474                self.set_keypoints_visible(visible);
8475            }
8476        });
8477
8478        self.mode
8479    }
8480
8481    /// Render the comparison image in `ui`.
8482    pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
8483        if self.dirty && !self.data_a.is_empty() {
8484            // AUTO alignment runs the (expensive) SIFT registration here, only
8485            // when something changed, and caches it for build_composite /
8486            // raw_pixel_data. On too few keypoints silx falls back to the default
8487            // alignment mode (`__setDefaultAlignmentMode`); siplot falls back to
8488            // ORIGIN so the images still display.
8489            if self.alignment == CompareAlignment::Auto {
8490                self.auto = self.compute_auto_alignment();
8491                if self.auto.is_none() {
8492                    self.alignment = CompareAlignment::Origin;
8493                }
8494            } else {
8495                self.auto = None;
8496            }
8497            let (composite, cw, ch) = self.build_composite();
8498            self.composite_w = cw;
8499            self.composite_h = ch;
8500            if let Some(handle) = self.composite_handle {
8501                self.inner
8502                    .try_update_rgba_image(handle, cw, ch, &composite)
8503                    .ok();
8504            } else {
8505                let handle = self.inner.add_rgba_image(cw, ch, &composite);
8506                self.composite_handle = Some(handle);
8507            }
8508            self.sync_keypoint_overlay();
8509            self.dirty = false;
8510        }
8511        // Place/reposition the on-plot draggable split separator before drawing
8512        // (silx `__updateSeparators`).
8513        self.sync_separator();
8514        let response = self.inner.show(ui);
8515        // A drag of the separator updates `split` (silx `__separatorMoved`).
8516        self.read_separator_drag(&response);
8517        // Track the cursor data position for the status bar (silx status bar's
8518        // `mouseMoved` -> `self._pos`); keep the last position on frames with no
8519        // pointer event, matching silx.
8520        if let Some(cursor) = cursor_from_pointer_event(response.pointer_event.as_ref()) {
8521            self.cursor = Some(cursor);
8522        }
8523        response
8524    }
8525
8526    /// Ensure the on-plot split separator matches the current mode and `split`
8527    /// (silx `__updateSeparators`): a draggable vertical line for
8528    /// [`CompareMode::HalfHalf`], a horizontal line for
8529    /// [`CompareMode::SplitHorizontal`], none for the other modes (or with no
8530    /// data). The marker is recreated when its orientation must change and
8531    /// repositioned to the split fraction every frame the user is *not* dragging
8532    /// it, so a programmatic [`Self::set_split`] or the toolbar slider move it too.
8533    fn sync_separator(&mut self) {
8534        let want = if self.data_a.is_empty() {
8535            None
8536        } else {
8537            match self.mode {
8538                CompareMode::HalfHalf => Some(false),
8539                CompareMode::SplitHorizontal => Some(true),
8540                _ => None,
8541            }
8542        };
8543
8544        let Some(horizontal) = want else {
8545            if let Some(handle) = self.separator.take() {
8546                self.inner.remove(handle);
8547            }
8548            self.separator_dragging = false;
8549            return;
8550        };
8551
8552        // Orientation changed (mode switched between the two split modes): the
8553        // marker kind is fixed at creation, so drop the old line and rebuild.
8554        if self.separator.is_some() && self.separator_horizontal != horizontal {
8555            if let Some(handle) = self.separator.take() {
8556                self.inner.remove(handle);
8557            }
8558            self.separator_dragging = false;
8559        }
8560
8561        let (x, y) = self.separator_position(horizontal);
8562        match self.separator {
8563            Some(handle) => {
8564                if !self.separator_dragging {
8565                    self.inner.set_marker_position(handle, x, y);
8566                }
8567            }
8568            None => {
8569                let marker = if horizontal {
8570                    Marker::hline(y)
8571                } else {
8572                    Marker::vline(x)
8573                }
8574                .with_color(Color32::BLUE)
8575                .with_draggable(true);
8576                self.separator = Some(self.inner.add_marker_data(&marker));
8577                self.separator_horizontal = horizontal;
8578            }
8579        }
8580    }
8581
8582    /// The separator's data position for the current `split`: a vertical line sits
8583    /// at data x `split * width`, a horizontal line at data y `split * height` —
8584    /// the composite occupies data `[0, width] × [0, height]` (identity image
8585    /// geometry), so the line lands on the composite's split column/row.
8586    fn separator_position(&self, horizontal: bool) -> (f64, f64) {
8587        if horizontal {
8588            (0.0, self.split as f64 * self.composite_h as f64)
8589        } else {
8590            (self.split as f64 * self.composite_w as f64, 0.0)
8591        }
8592    }
8593
8594    /// Fold a separator drag back into `split` (silx `__plotSlot` ->
8595    /// `__separatorMoved`): the dragged data position divided by the composite
8596    /// extent gives the new fraction. [`Self::set_split`] clamps it to `[0, 1]`,
8597    /// so a drag past the image edge collapses to a full-A / full-B view.
8598    fn read_separator_drag(&mut self, response: &PlotResponse) {
8599        let Some(sep) = self.separator else { return };
8600        if response.marker_drag_started == Some(sep) {
8601            self.separator_dragging = true;
8602        }
8603        if (response.marker_moved == Some(sep) || response.marker_drag_finished == Some(sep))
8604            && let Some((x, y)) = self.inner.marker_position(sep)
8605        {
8606            let (pos, extent) = if self.separator_horizontal {
8607                (y, self.composite_h)
8608            } else {
8609                (x, self.composite_w)
8610            };
8611            if extent > 0 {
8612                self.set_split((pos / extent as f64) as f32);
8613            }
8614        }
8615        if response.marker_drag_finished == Some(sep) {
8616            self.separator_dragging = false;
8617        }
8618    }
8619
8620    /// Map a data position to its on-screen pixel under the inner plot's cached
8621    /// transform (`None` before the first frame caches the data area). Forwards to
8622    /// the inner [`PlotWidget`]; useful for hit-testing the draggable separator.
8623    pub fn data_to_pixel(&self, x: f64, y: f64, axis: YAxis) -> Option<egui::Pos2> {
8624        self.inner.data_to_pixel(x, y, axis)
8625    }
8626
8627    /// The raw A and B pixel values under data position `(x, y)`, mirroring silx
8628    /// `CompareImages.getRawPixelData`. `(x, y)` is in the reference of the
8629    /// displayed (aligned) grid; it is mapped back to each raw image's own
8630    /// coordinates per the [`alignment`](Self::alignment) mode by
8631    /// `compare_aligned_coords`. Each value is `None` when that image has no
8632    /// data or the mapped position is outside it.
8633    pub fn raw_pixel_data(&self, x: f64, y: f64) -> (Option<f32>, Option<f32>) {
8634        // AUTO maps the display (A-grid) coordinate to B through the estimated
8635        // affine `(x_b, y_b) = matrix·(x, y) + offset`; A is identity (padded
8636        // top-left). Every other mode uses the analytic per-mode remap.
8637        let ((xa, ya), (xb, yb)) = match (self.alignment, self.auto.as_ref()) {
8638            (CompareAlignment::Auto, Some(al)) => {
8639                let [[a, b], [c, d]] = al.matrix;
8640                let (tx, ty) = al.offset;
8641                ((x, y), (a * x + b * y + tx, c * x + d * y + ty))
8642            }
8643            _ => compare_aligned_coords(
8644                self.alignment,
8645                x,
8646                y,
8647                self.width_a,
8648                self.height_a,
8649                self.width_b,
8650                self.height_b,
8651            ),
8652        };
8653        (
8654            compare_pixel_at(self.width_a, self.height_a, &self.data_a, xa, ya),
8655            compare_pixel_at(self.width_b, self.height_b, &self.data_b, xb, yb),
8656        )
8657    }
8658
8659    /// Show a status bar with the cursor's data coordinate and the raw A / B
8660    /// pixel values under it (silx `CompareImagesStatusBar`, the `ImageA:`/
8661    /// `ImageB:` labels), plus an `Align:` label summarising the SIFT affine when
8662    /// one is in effect. silx populates that transform only from its `AUTO`/SIFT
8663    /// alignment ([`Self::transformation`] is `None` for ORIGIN/CENTER/STRETCH),
8664    /// so the label appears only in `AUTO`, with the per-component breakdown
8665    /// (translation px / scale / rotation degrees) on hover. Call after
8666    /// [`Self::show`], which updates the tracked cursor. GPU/UI — not covered by
8667    /// the tests.
8668    pub fn show_status_bar(&self, ui: &mut egui::Ui) {
8669        ui.horizontal(|ui| {
8670            ui.spacing_mut().item_spacing.x = 12.0;
8671            let (a_text, b_text) = match self.cursor {
8672                Some([x, y]) => {
8673                    ui.label(format!("X: {x:.1}  Y: {y:.1}"));
8674                    let (a, b) = self.raw_pixel_data(x, y);
8675                    (
8676                        format_compare_value(self.data_a.is_empty(), a),
8677                        format_compare_value(self.data_b.is_empty(), b),
8678                    )
8679                }
8680                None => ("NA".to_string(), "NA".to_string()),
8681            };
8682            ui.label(format!("ImageA: {a_text}"));
8683            ui.label(format!("ImageB: {b_text}"));
8684            if let Some(t) = self.transformation() {
8685                ui.label(format!("Align: {}", align_summary(&t)))
8686                    .on_hover_text(align_tooltip(&t));
8687            }
8688        });
8689    }
8690
8691    /// Build the composite RGBA pixel array for the current mode and split,
8692    /// returning it with the common-grid `(width, height)`.
8693    ///
8694    /// The two raw images are first placed on a shared grid by
8695    /// [`align_compare_images`] per the alignment mode (silx
8696    /// `__updateData`); every visualization mode then operates on the aligned
8697    /// `data1`/`data2`, which always have identical shape.
8698    /// Run SIFT registration of B onto A for [`CompareAlignment::Auto`] (silx
8699    /// `__createSiftData`), returning the cached alignment or `None` when too few
8700    /// keypoints match. Pure data layer ([`sift_auto_align`]); only invoked from
8701    /// the dirty path in [`Self::show`].
8702    fn compute_auto_alignment(&self) -> Option<SiftAlignment> {
8703        sift_auto_align(
8704            &self.data_a,
8705            self.width_a as usize,
8706            self.height_a as usize,
8707            &self.data_b,
8708            self.width_b as usize,
8709            self.height_b as usize,
8710        )
8711    }
8712
8713    /// Rebuild the matched-keypoint scatter overlay (silx `__updateKeyPoints`):
8714    /// remove the previous overlay, then — when keypoints are visible and a SIFT
8715    /// registration is in effect — add a point scatter at the matched keypoints'
8716    /// image-A coordinates (the same common-grid data space the composite and the
8717    /// separator use). Hidden / non-AUTO modes leave no overlay.
8718    fn sync_keypoint_overlay(&mut self) {
8719        if let Some(handle) = self.keypoint_overlay.take() {
8720            self.inner.remove(handle);
8721        }
8722        if !self.keypoints_visible {
8723            return;
8724        }
8725        let Some(al) = self.auto.as_ref() else { return };
8726        if al.matches.is_empty() {
8727            return;
8728        }
8729        let xs: Vec<f64> = al.matches.iter().map(|m| m.ax as f64).collect();
8730        let ys: Vec<f64> = al.matches.iter().map(|m| m.ay as f64).collect();
8731        let handle =
8732            self.inner
8733                .add_scatter_with_symbol(&xs, &ys, KEYPOINT_COLOR, Symbol::Cross, 6.0);
8734        self.keypoint_overlay = Some(handle);
8735    }
8736
8737    fn build_composite(&self) -> (Vec<[u8; 4]>, u32, u32) {
8738        // AUTO uses the cached SIFT result (B already warped onto the common
8739        // grid); A is padded top-left onto the same grid (silx pads to max before
8740        // SIFT). Every other mode places both images via `align_compare_images`.
8741        let (data1, data2, cw, ch) = match (self.alignment, self.auto.as_ref()) {
8742            (CompareAlignment::Auto, Some(al)) => {
8743                let cw = al.width as u32;
8744                let ch = al.height as u32;
8745                let data1 = margin_image(
8746                    &self.data_a,
8747                    self.width_a as usize,
8748                    self.height_a as usize,
8749                    al.width,
8750                    al.height,
8751                    false,
8752                );
8753                (data1, al.aligned.clone(), cw, ch)
8754            }
8755            _ => align_compare_images(
8756                self.alignment,
8757                &self.data_a,
8758                self.width_a,
8759                self.height_a,
8760                &self.data_b,
8761                self.width_b,
8762                self.height_b,
8763            ),
8764        };
8765        let w = cw as usize;
8766        let h = ch as usize;
8767
8768        let pixels = match self.mode {
8769            CompareMode::OnlyA => colormap_to_rgba(cw, &data1, &self.colormap),
8770            CompareMode::OnlyB => colormap_to_rgba(cw, &data2, &self.colormap),
8771            CompareMode::HalfHalf => {
8772                let rgba_a = colormap_to_rgba(cw, &data1, &self.colormap);
8773                let rgba_b = colormap_to_rgba(cw, &data2, &self.colormap);
8774                let split_col = (self.split * cw as f32).round() as usize;
8775                split_composite(&rgba_a, &rgba_b, w, h, split_col, false)
8776            }
8777            CompareMode::SplitHorizontal => {
8778                let rgba_a = colormap_to_rgba(cw, &data1, &self.colormap);
8779                let rgba_b = colormap_to_rgba(cw, &data2, &self.colormap);
8780                let split_row = (self.split * ch as f32).round() as usize;
8781                split_composite(&rgba_a, &rgba_b, w, h, split_row, true)
8782            }
8783            CompareMode::Subtract => data1
8784                .iter()
8785                .zip(data2.iter())
8786                .map(|(&a, &b)| {
8787                    let diff = (a - b).clamp(-1.0, 1.0);
8788                    if diff > 0.0 {
8789                        [(diff * 255.0) as u8, 0, 0, 255]
8790                    } else if diff < 0.0 {
8791                        [0, 0, ((-diff) * 255.0) as u8, 255]
8792                    } else {
8793                        [128, 128, 128, 255]
8794                    }
8795                })
8796                .collect(),
8797            CompareMode::RedBlueGray => {
8798                red_blue_gray_composite(&data1, &data2, &self.colormap, false)
8799            }
8800            CompareMode::RedBlueGrayNeg => {
8801                red_blue_gray_composite(&data1, &data2, &self.colormap, true)
8802            }
8803        };
8804        (pixels, cw, ch)
8805    }
8806}
8807
8808/// The raw value of an image pixel at data position `(x, y)`, or `None` when
8809/// the position is outside the image (silx `CompareImages.getRawPixelData`,
8810/// ORIGIN alignment: `value = raw[int(y), int(x)]`, with out-of-range returning
8811/// no element). `data` is row-major `width × height`. A negative coordinate is
8812/// out of range (silx checks `< 0`); for the non-negative interior `x as usize`
8813/// matches Python's `int()` truncation toward zero. Pure and deterministic, so
8814/// the lookup is unit-testable without a GPU backend.
8815pub fn compare_pixel_at(width: u32, height: u32, data: &[f32], x: f64, y: f64) -> Option<f32> {
8816    if x < 0.0 || y < 0.0 {
8817        return None;
8818    }
8819    let col = x as usize;
8820    let row = y as usize;
8821    if col >= width as usize || row >= height as usize {
8822        return None;
8823    }
8824    data.get(row * width as usize + col).copied()
8825}
8826
8827/// Place a scalar image into a zero-padded `dst_w × dst_h` grid, mirroring silx
8828/// `CompareImages.__createMarginImage` (intensity branch:
8829/// `data = numpy.zeros(size); data[pos0:.., pos1:..] = image`). When `center`,
8830/// the source is offset by silx's `size // 2 - shape // 2` per axis; otherwise
8831/// it is anchored at the top-left `(0, 0)`. `src` is row-major `src_w × src_h`.
8832/// Requires `src_w <= dst_w` and `src_h <= dst_h` (silx asserts the same); the
8833/// destination is `dst_w * dst_h` zeros with the source copied in. Pure, so the
8834/// padding/centering is unit-testable.
8835fn margin_image(
8836    src: &[f32],
8837    src_w: usize,
8838    src_h: usize,
8839    dst_w: usize,
8840    dst_h: usize,
8841    center: bool,
8842) -> Vec<f32> {
8843    let mut out = vec![0.0f32; dst_w * dst_h];
8844    if src_w == 0 || src_h == 0 || src_w > dst_w || src_h > dst_h {
8845        return out;
8846    }
8847    // silx: pos0 = size[0]//2 - shape[0]//2, pos1 = size[1]//2 - shape[1]//2
8848    // (non-negative since dst >= src), or (0, 0) for the top-left anchor.
8849    let (pos_row, pos_col) = if center {
8850        (dst_h / 2 - src_h / 2, dst_w / 2 - src_w / 2)
8851    } else {
8852        (0, 0)
8853    };
8854    for r in 0..src_h {
8855        let dst_base = (pos_row + r) * dst_w + pos_col;
8856        let src_base = r * src_w;
8857        out[dst_base..dst_base + src_w].copy_from_slice(&src[src_base..src_base + src_w]);
8858    }
8859    out
8860}
8861
8862/// Bilinearly resample a scalar image to `dst_w × dst_h`, mirroring silx
8863/// `CompareImages.__rescaleArray` + `silx.image.bilinear.BilinearImage`. Output
8864/// pixel `(or, oc)` samples the source at corner-aligned coordinates
8865/// `row = or * (src_h - 1)/(dst_h - 1)`, `col = oc * (src_w - 1)/(dst_w - 1)`,
8866/// with the four-tap bilinear weights of silx's `c_funct` (indices clamped into
8867/// the image — silx clamps the coordinate to `[0, dim - 1]`). A destination
8868/// extent of 1 along an axis maps to source index 0 there (silx's `0/0` would be
8869/// NaN; siplot samples the first line instead). `src` is row-major
8870/// `src_w × src_h`. Pure, so the resampling is unit-testable.
8871fn rescale_array(src: &[f32], src_w: usize, src_h: usize, dst_w: usize, dst_h: usize) -> Vec<f32> {
8872    let mut out = vec![0.0f32; dst_w * dst_h];
8873    if src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0 {
8874        return out;
8875    }
8876    let row_scale = if dst_h > 1 {
8877        (src_h - 1) as f64 / (dst_h - 1) as f64
8878    } else {
8879        0.0
8880    };
8881    let col_scale = if dst_w > 1 {
8882        (src_w - 1) as f64 / (dst_w - 1) as f64
8883    } else {
8884        0.0
8885    };
8886    let sample = |row: f64, col: f64| -> f32 {
8887        // silx c_funct clamps the coordinate into [0, dim - 1] first.
8888        let row = row.clamp(0.0, (src_h - 1) as f64);
8889        let col = col.clamp(0.0, (src_w - 1) as f64);
8890        let r0 = row.floor() as usize;
8891        let c0 = col.floor() as usize;
8892        let r1 = (r0 + 1).min(src_h - 1);
8893        let c1 = (c0 + 1).min(src_w - 1);
8894        let fr = row - r0 as f64;
8895        let fc = col - c0 as f64;
8896        let at = |r: usize, c: usize| src[r * src_w + c] as f64;
8897        let top = at(r0, c0) * (1.0 - fc) + at(r0, c1) * fc;
8898        let bot = at(r1, c0) * (1.0 - fc) + at(r1, c1) * fc;
8899        (top * (1.0 - fr) + bot * fr) as f32
8900    };
8901    for or in 0..dst_h {
8902        for oc in 0..dst_w {
8903            out[or * dst_w + oc] = sample(or as f64 * row_scale, oc as f64 * col_scale);
8904        }
8905    }
8906    out
8907}
8908
8909/// Place the two raw images on a shared display grid for `mode`, mirroring silx
8910/// `CompareImages.__updateData` (intensity branch). Returns `(data1, data2,
8911/// common_w, common_h)` with both vectors row-major `common_w × common_h`:
8912/// - [`Origin`](CompareAlignment::Origin)/[`Center`](CompareAlignment::Center):
8913///   common grid is `(max(w_a, w_b), max(h_a, h_b))`, each image zero-padded
8914///   (top-left, or centered) via [`margin_image`].
8915/// - [`Stretch`](CompareAlignment::Stretch): common grid is A's shape; A is kept
8916///   verbatim and B is bilinearly resampled to it via [`rescale_array`].
8917///
8918/// Pure, so the alignment is unit-testable without a GPU backend.
8919fn align_compare_images(
8920    mode: CompareAlignment,
8921    a: &[f32],
8922    wa: u32,
8923    ha: u32,
8924    b: &[f32],
8925    wb: u32,
8926    hb: u32,
8927) -> (Vec<f32>, Vec<f32>, u32, u32) {
8928    match mode {
8929        CompareAlignment::Origin | CompareAlignment::Center => {
8930            let cw = wa.max(wb);
8931            let ch = ha.max(hb);
8932            let center = matches!(mode, CompareAlignment::Center);
8933            let (cwu, chu) = (cw as usize, ch as usize);
8934            let d1 = margin_image(a, wa as usize, ha as usize, cwu, chu, center);
8935            let d2 = margin_image(b, wb as usize, hb as usize, cwu, chu, center);
8936            (d1, d2, cw, ch)
8937        }
8938        CompareAlignment::Stretch => {
8939            let d2 = rescale_array(b, wb as usize, hb as usize, wa as usize, ha as usize);
8940            (a.to_vec(), d2, wa, ha)
8941        }
8942        // AUTO is served from the cached SIFT result in `build_composite`; this
8943        // arm is only reached if it is ever called without that cache, in which
8944        // case the ORIGIN top-left placement is the safe fallback (silx falls
8945        // back to the default alignment mode when SIFT fails).
8946        CompareAlignment::Auto => {
8947            let cw = wa.max(wb);
8948            let ch = ha.max(hb);
8949            let (cwu, chu) = (cw as usize, ch as usize);
8950            let d1 = margin_image(a, wa as usize, ha as usize, cwu, chu, false);
8951            let d2 = margin_image(b, wb as usize, hb as usize, cwu, chu, false);
8952            (d1, d2, cw, ch)
8953        }
8954    }
8955}
8956
8957/// Map a display-grid coordinate `(x, y)` back to each raw image's own
8958/// coordinates per the alignment mode, mirroring silx
8959/// `CompareImages.getRawPixelData`. Returns `((x_a, y_a), (x_b, y_b))`.
8960///
8961/// - [`Origin`](CompareAlignment::Origin): identity for both (silx ORIGIN).
8962/// - [`Center`](CompareAlignment::Center): subtract each image's centering
8963///   offset `(max_dim - dim) * 0.5` (silx CENTER).
8964/// - [`Stretch`](CompareAlignment::Stretch): A is identity (it is the grid); B
8965///   is scaled by the per-axis size ratio, `x_b = x * w_b / w_a`,
8966///   `y_b = y * h_b / h_a`. (silx's source writes `y2 = x * w2 / w1` here, a
8967///   transcription typo that uses the column coordinate and width ratio for the
8968///   row; siplot uses the row mapping so the readout matches the displayed
8969///   stretched pixel.)
8970///
8971/// Pure, so the per-mode remap is unit-testable.
8972fn compare_aligned_coords(
8973    mode: CompareAlignment,
8974    x: f64,
8975    y: f64,
8976    wa: u32,
8977    ha: u32,
8978    wb: u32,
8979    hb: u32,
8980) -> ((f64, f64), (f64, f64)) {
8981    match mode {
8982        CompareAlignment::Origin => ((x, y), (x, y)),
8983        CompareAlignment::Center => {
8984            let xx = wa.max(wb) as f64;
8985            let yy = ha.max(hb) as f64;
8986            let xa = x - (xx - wa as f64) * 0.5;
8987            let xb = x - (xx - wb as f64) * 0.5;
8988            let ya = y - (yy - ha as f64) * 0.5;
8989            let yb = y - (yy - hb as f64) * 0.5;
8990            ((xa, ya), (xb, yb))
8991        }
8992        CompareAlignment::Stretch => {
8993            let xb = x * wb as f64 / wa as f64;
8994            let yb = y * hb as f64 / ha as f64;
8995            ((x, y), (xb, yb))
8996        }
8997        // AUTO's B mapping is the estimated affine, which lives on the widget;
8998        // `raw_pixel_data` handles it directly. This identity arm is the fallback
8999        // for the no-registration case (then AUTO has degraded to ORIGIN).
9000        CompareAlignment::Auto => ((x, y), (x, y)),
9001    }
9002}
9003
9004/// Format one image's status-bar value (silx `CompareImagesStatusBar._formatData`
9005/// scalar branch + the empty/out-of-range fallbacks). `no_image` is `true` when
9006/// that image has no data at all (silx `raw is None` -> "No image"); otherwise a
9007/// value formats as silx `"%f"` (six decimals) and `None` (outside the image) is
9008/// "NA". Pure, so the formatting is unit-testable.
9009fn format_compare_value(no_image: bool, value: Option<f32>) -> String {
9010    if no_image {
9011        "no image".to_string()
9012    } else {
9013        match value {
9014            Some(v) => format!("{v:.6}"),
9015            None => "NA".to_string(),
9016        }
9017    }
9018}
9019
9020/// numpy `isclose(a, b)` with its default tolerances (`rtol=1e-5`, `atol=1e-8`).
9021fn isclose(a: f64, b: f64, atol: f64) -> bool {
9022    (a - b).abs() <= atol + 1e-5 * b.abs()
9023}
9024
9025/// The status-bar "Align:" summary of a SIFT affine, mirroring silx
9026/// `CompareImagesStatusBar._updateStatusBar`: the `+`-joined list of which of
9027/// `Translation`/`Scale`/`Rotation` deviate *notably* (`atol=0.01`) from
9028/// identity. With no notable component it is `"No big changes"` when a
9029/// sub-threshold change exists, else `"No changes"` (an exact identity).
9030fn align_summary(t: &AffineTransformation) -> String {
9031    let notable_translation = !isclose(t.tx, 0.0, 0.01) || !isclose(t.ty, 0.0, 0.01);
9032    let notable_scale = !isclose(t.sx, 1.0, 0.01) || !isclose(t.sy, 1.0, 0.01);
9033    let notable_rotation = !isclose(t.rotation, 0.0, 0.01);
9034
9035    let mut parts = Vec::new();
9036    if notable_translation {
9037        parts.push("Translation");
9038    }
9039    if notable_scale {
9040        parts.push("Scale");
9041    }
9042    if notable_rotation {
9043        parts.push("Rotation");
9044    }
9045    if !parts.is_empty() {
9046        return parts.join("+");
9047    }
9048    // No notable component: silx distinguishes a tiny change from exact identity
9049    // with numpy.isclose's default tolerances.
9050    let any_translation = !isclose(t.tx, 0.0, 1e-8) || !isclose(t.ty, 0.0, 1e-8);
9051    let any_scale = !isclose(t.sx, 1.0, 1e-8) || !isclose(t.sy, 1.0, 1e-8);
9052    let any_rotation = !isclose(t.rotation, 0.0, 1e-8);
9053    if any_translation || any_scale || any_rotation {
9054        "No big changes".to_string()
9055    } else {
9056        "No changes".to_string()
9057    }
9058}
9059
9060/// The detailed per-component tooltip for a SIFT affine, mirroring silx
9061/// `CompareImagesStatusBar` (`Translation x/y` in px, `Scale x/y`, `Rotation`
9062/// in degrees), one line per component that differs from identity, or
9063/// `"No transformation"` when none do. silx gates the `Translation x` line on
9064/// `ty` — a transcription slip; siplot gates it on `tx` so the x line tracks the
9065/// x translation.
9066fn align_tooltip(t: &AffineTransformation) -> String {
9067    let mut lines = Vec::new();
9068    if !isclose(t.tx, 0.0, 1e-8) {
9069        lines.push(format!("Translation x: {:.3}px", t.tx));
9070    }
9071    if !isclose(t.ty, 0.0, 1e-8) {
9072        lines.push(format!("Translation y: {:.3}px", t.ty));
9073    }
9074    if !isclose(t.sx, 1.0, 1e-8) {
9075        lines.push(format!("Scale x: {:.3}", t.sx));
9076    }
9077    if !isclose(t.sy, 1.0, 1e-8) {
9078        lines.push(format!("Scale y: {:.3}", t.sy));
9079    }
9080    if !isclose(t.rotation, 0.0, 1e-8) {
9081        lines.push(format!(
9082            "Rotation: {:.3}deg",
9083            t.rotation * 180.0 / std::f64::consts::PI
9084        ));
9085    }
9086    if lines.is_empty() {
9087        "No transformation".to_string()
9088    } else {
9089        lines.join("\n")
9090    }
9091}
9092
9093impl Deref for CompareImages {
9094    type Target = PlotWidget;
9095
9096    fn deref(&self) -> &Self::Target {
9097        &self.inner
9098    }
9099}
9100
9101impl DerefMut for CompareImages {
9102    fn deref_mut(&mut self) -> &mut Self::Target {
9103        &mut self.inner
9104    }
9105}
9106
9107/// Apply a colormap to scalar pixel data and return RGBA bytes.
9108fn colormap_to_rgba(_width: u32, data: &[f32], colormap: &Colormap) -> Vec<[u8; 4]> {
9109    data.iter()
9110        .map(|&v| {
9111            let t = colormap.normalize(v as f64);
9112            let idx = (t * 255.0).clamp(0.0, 255.0) as usize;
9113            colormap.lut[idx]
9114        })
9115        .collect()
9116}
9117
9118/// Compose two scalar images into an RGB composite, mirroring silx CompareImages
9119/// `__composeRgbImage` (CompareImages.py:744-751). Each image's value is
9120/// normalised through the shared `colormap` to a `0..=255` intensity (`a` for A,
9121/// `b` for B — the same `normalize`→byte step silx applies). The non-negative
9122/// mode puts A in red, B in blue, and their half-sum (`a/2 + b/2`) in green; the
9123/// negative mode inverts each channel (`255 - …`). `data_a` and `data_b` are
9124/// row-major and the same length (siplot uploads both together). Pure, so the
9125/// channel layout is unit-testable without a GPU.
9126fn red_blue_gray_composite(
9127    data_a: &[f32],
9128    data_b: &[f32],
9129    colormap: &Colormap,
9130    neg: bool,
9131) -> Vec<[u8; 4]> {
9132    let byte = |v: f32| (colormap.normalize(v as f64) * 255.0).clamp(0.0, 255.0) as u8;
9133    data_a
9134        .iter()
9135        .zip(data_b.iter())
9136        .map(|(&va, &vb)| {
9137            let a = byte(va);
9138            let b = byte(vb);
9139            let g = a / 2 + b / 2;
9140            if neg {
9141                [255 - b, 255 - g, 255 - a, 255]
9142            } else {
9143                [a, g, b, 255]
9144            }
9145        })
9146        .collect()
9147}
9148
9149/// Composite two colormapped RGBA images along a straight separator, mirroring
9150/// silx CompareImages VERTICAL_LINE / HORIZONTAL_LINE (CompareImages.py:422-445).
9151///
9152/// `a` and `b` are row-major `width × height` RGBA. For a vertical separator
9153/// (`horizontal == false`) columns with `col < split` show `a`, the rest show `b`
9154/// (silx `data[:, 0:pos]` / `data[:, pos:]`); for a horizontal separator rows
9155/// with `row < split` show `a`, the rest `b` (silx `data[0:pos, :]` /
9156/// `data[pos:, :]`). `split == 0` shows all `b`; `split >=` the split axis length
9157/// shows all `a` (silx clamps `pos` into `[0, shape]`).
9158fn split_composite(
9159    a: &[[u8; 4]],
9160    b: &[[u8; 4]],
9161    width: usize,
9162    height: usize,
9163    split: usize,
9164    horizontal: bool,
9165) -> Vec<[u8; 4]> {
9166    let mut out = vec![[0u8; 4]; width * height];
9167    for row in 0..height {
9168        let base = row * width;
9169        for col in 0..width {
9170            let i = base + col;
9171            let use_a = if horizontal { row < split } else { col < split };
9172            out[i] = if use_a { a[i] } else { b[i] };
9173        }
9174    }
9175    out
9176}
9177
9178// ─── ImageView ────────────────────────────────────────────────────────────────
9179
9180/// A 2D image viewer with side aggregate-profile panels, mirroring silx
9181/// `ImageView`.
9182///
9183/// Layout:
9184/// ```text
9185/// ┌──────────────────┬───────┐
9186/// │  histo_h (top)   │       │
9187/// ├──────────────────┤       │
9188/// │   image_plot     │ histo_v│
9189/// │   (centre)       │(right) │
9190/// └──────────────────┴───────┘
9191/// ```
9192///
9193/// The horizontal histogram (top) shows column sums; the vertical histogram
9194/// (right) shows row sums.  Both use `SyncAxes` to track the image-plot limits.
9195/// A colorbar column sits at the far right, synced to the active image's
9196/// colormap (silx `ImageView` grid column 2, ImageView.py:501).
9197///
9198/// Width in points reserved for the right-hand colorbar column. Wide enough for
9199/// the 25 pt gradient strip (silx `_ColorScale`) plus ticks and end labels.
9200const COLORBAR_WIDTH: f32 = 70.0;
9201
9202/// Width in points for the side colorbar column when the interactive
9203/// [`HistogramColorBar`](crate::widget::histogram_colorbar::HistogramColorBar) is
9204/// enabled: wider than [`COLORBAR_WIDTH`] to fit the value histogram beside the
9205/// gradient, handles, and level labels.
9206const INTERACTIVE_COLORBAR_WIDTH: f32 = 175.0;
9207
9208/// Build the side [`ColorBarWidget`](crate::widget::colorbar::ColorBarWidget)
9209/// for an [`ImageView`], synced to `colormap`'s value limits (silx
9210/// `ImageView.getColorBarWidget`, ImageView.py:501). Split out from
9211/// [`ImageView::colorbar`] so the colormap→colorbar sync is unit-testable
9212/// without a GPU backend.
9213fn image_view_colorbar(colormap: &Colormap) -> crate::widget::colorbar::ColorBarWidget {
9214    crate::widget::colorbar::ColorBarWidget::new(colormap.clone())
9215}
9216
9217/// Build the [`ScatterView`] side colorbar from its retained value colormap
9218/// (silx `ScatterView.getColorBarWidget`, ScatterView.py:83-88). Returns `None`
9219/// when no data has been uploaded yet (`colormap` is `None`). Split out from
9220/// [`ScatterView::colorbar`] so the colormap→colorbar mapping is unit-testable
9221/// without a GPU backend.
9222fn scatter_view_colorbar(
9223    colormap: Option<&Colormap>,
9224) -> Option<crate::widget::colorbar::ColorBarWidget> {
9225    colormap.map(image_view_colorbar)
9226}
9227
9228/// Project a [`crate::widget::scatter_mask::ScatterMaskWidget`]'s per-point
9229/// level buffer onto the boolean point selection applied to the scatter (silx
9230/// `ScatterView` mask: a point is selected when its level is non-zero). Split
9231/// out from [`ScatterView`] so the level→selection mapping is unit-testable
9232/// without a GPU backend.
9233fn scatter_masked_selection(mask: &[u8]) -> Vec<bool> {
9234    mask.iter().map(|&level| level != 0).collect()
9235}
9236
9237/// Extract cursor data coordinates `[x, y]` from a pointer event for the
9238/// PositionInfo readout (silx `PositionInfo._updateStatusBar`, fed by
9239/// `sigMouseMoved`). A move (hover), click, or double-click over the data area
9240/// all carry the data-space `(x, y)`; a `LimitsChanged` event carries no cursor
9241/// and yields `None`. `None` input (no pointer event this frame) yields `None`.
9242fn cursor_from_pointer_event(
9243    event: Option<&crate::widget::interaction::PlotPointerEvent>,
9244) -> Option<[f64; 2]> {
9245    use crate::widget::interaction::PlotPointerEvent;
9246    match event? {
9247        PlotPointerEvent::Moved { data, .. }
9248        | PlotPointerEvent::Clicked { data, .. }
9249        | PlotPointerEvent::DoubleClicked { data, .. } => Some([data.0, data.1]),
9250        PlotPointerEvent::LimitsChanged { .. } => None,
9251    }
9252}
9253
9254/// Extract a 1D profile for an [`ImageView`]'s profile tool from a drag between
9255/// data-space `(col, row)` endpoints `start` and `end` (silx
9256/// `ImageView._ProfileToolBar`, ImageView.py:692-697), dispatching to the
9257/// existing profile functions per `mode`:
9258///
9259/// - [`ProfileMode::Line`] → [`line_profile_values`] along `start`→`end`;
9260/// - [`ProfileMode::Horizontal`] → [`horizontal_profile_values`] at the row of
9261///   `end`, with the column index as the x axis;
9262/// - [`ProfileMode::Vertical`] → [`vertical_profile_values`] at the column of
9263///   `end`, with the row index as the x axis;
9264/// - [`ProfileMode::Rectangle`] → [`rect_profile_values`] over the `start`→`end`
9265///   bounding box, averaged along rows (a row profile);
9266/// - [`ProfileMode::None`] → `None`.
9267///
9268/// Returns `None` when the mode is disabled or the index is out of range.
9269/// Split out so the drag→profile mapping is unit-testable without a GPU backend.
9270fn image_view_profile_values(
9271    mode: ProfileMode,
9272    width: u32,
9273    height: u32,
9274    pixels: &[f32],
9275    start: (f64, f64),
9276    end: (f64, f64),
9277) -> Option<(Vec<f64>, Vec<f64>)> {
9278    match mode {
9279        ProfileMode::None => None,
9280        ProfileMode::Line => line_profile_values(width, height, pixels, start, end).ok(),
9281        ProfileMode::Horizontal => {
9282            let row = end.1.floor();
9283            if row < 0.0 || row >= height as f64 {
9284                return None;
9285            }
9286            horizontal_profile_values(width, height, pixels, row as u32)
9287                .ok()
9288                .map(|y| {
9289                    let x: Vec<f64> = (0..width as usize).map(|i| i as f64).collect();
9290                    (x, y)
9291                })
9292        }
9293        ProfileMode::Vertical => {
9294            let col = end.0.floor();
9295            if col < 0.0 || col >= width as f64 {
9296                return None;
9297            }
9298            vertical_profile_values(width, height, pixels, col as u32)
9299                .ok()
9300                .map(|y| {
9301                    let x: Vec<f64> = (0..height as usize).map(|i| i as f64).collect();
9302                    (x, y)
9303                })
9304        }
9305        ProfileMode::Rectangle => {
9306            let rect = (
9307                start.0.min(end.0),
9308                start.0.max(end.0),
9309                start.1.min(end.1),
9310                start.1.max(end.1),
9311            );
9312            rect_profile_values(width, height, pixels, rect, true, ProfileMethod::Mean).ok()
9313        }
9314    }
9315}
9316
9317/// Build the profile-tool [`Roi`] from a drag between data-space `(col, row)`
9318/// endpoints for `mode` (silx `_ProfileToolBar` ROI shape per mode):
9319///
9320/// - [`ProfileMode::Line`] → [`Roi::Line`] `start`→`end`;
9321/// - [`ProfileMode::Horizontal`] → [`Roi::HRange`] at the row of `end`
9322///   (a degenerate range `(row, row)`, so the window's midpoint is that row);
9323/// - [`ProfileMode::Vertical`] → [`Roi::VRange`] at the column of `end`;
9324/// - [`ProfileMode::Rectangle`] → [`Roi::Rect`] over the `start`→`end` box;
9325/// - [`ProfileMode::None`] → `None`.
9326///
9327/// The returned ROI is handed to [`ProfileWindow::update_profile`], which
9328/// re-derives the samples with the same profile helpers as
9329/// [`image_view_profile_values`].
9330///
9331/// [`ProfileWindow::update_profile`]: crate::widget::profile_window::ProfileWindow::update_profile
9332fn profile_roi_from_drag(mode: ProfileMode, start: (f64, f64), end: (f64, f64)) -> Option<Roi> {
9333    match mode {
9334        ProfileMode::None => None,
9335        ProfileMode::Line => Some(Roi::Line { start, end }),
9336        ProfileMode::Horizontal => {
9337            let row = end.1.floor();
9338            Some(Roi::HRange { y: (row, row) })
9339        }
9340        ProfileMode::Vertical => {
9341            let col = end.0.floor();
9342            Some(Roi::VRange { x: (col, col) })
9343        }
9344        ProfileMode::Rectangle => Some(Roi::Rect {
9345            x: (start.0.min(end.0), start.0.max(end.0)),
9346            y: (start.1.min(end.1), start.1.max(end.1)),
9347        }),
9348    }
9349}
9350
9351/// Whether [`ImageView::show`] should route the captured pointer to the mask
9352/// tool and paint this frame: only when the plot is in
9353/// [`PlotInteractionMode::MaskDraw`] *and* the mask panel is enabled for the
9354/// active image. Gating strictly on `MaskDraw` keeps pan / zoom / select from
9355/// ever painting (silx's pencil draw interaction is its own mode). Pure, so the
9356/// gate is unit-testable without a `Ui`/GPU.
9357fn image_view_should_paint_mask(mode: PlotInteractionMode, mask_enabled: bool) -> bool {
9358    mask_enabled && mode == PlotInteractionMode::MaskDraw
9359}
9360
9361/// Build a [`ScalarMask`] from a [`MaskToolsWidget`](crate::widget::mask_tools::MaskToolsWidget)
9362/// level buffer (`levels`, row-major, `width * height`, `0` unmasked / non-zero
9363/// masked). The resulting mask is the representation re-uploaded through
9364/// [`Plot2D::try_add_masked_image`] / [`apply_image_mask`]: every non-zero level
9365/// becomes a masked (→ `NaN`) pixel, matching silx `getValueData`
9366/// (`items/image.py`). A `levels` length not equal to `width * height` is
9367/// clip/zero-extended by [`ScalarMask::set_mask_data`] (silx's lazy clip/extend),
9368/// so the returned mask always has the image shape. Pure, so the conversion is
9369/// unit-testable without a GPU backend.
9370fn scalar_mask_from_level_buffer(width: u32, height: u32, levels: &[u8]) -> ScalarMask {
9371    let mut mask = ScalarMask::new(width as usize, height as usize);
9372    mask.set_mask_data(levels, width as usize);
9373    mask
9374}
9375
9376/// Build the [`ImageSpec`] for an [`ImageView`]'s active image from its retained
9377/// colormap and `alpha` (silx `ActiveImageAlphaSlider` propagation,
9378/// ImageView.py:513-517). Split out from [`ImageView::upload_image`] so the
9379/// alpha→spec propagation is unit-testable without a GPU backend.
9380#[allow(clippy::too_many_arguments)]
9381fn image_view_image_spec<'a>(
9382    width: u32,
9383    height: u32,
9384    pixels: &'a [f32],
9385    colormap: &Colormap,
9386    alpha: f32,
9387    interpolation: InterpolationMode,
9388    aggregation: AggregationMode,
9389    aggregation_block: (u32, u32),
9390) -> ImageSpec<'a> {
9391    let mut spec = ImageSpec::scalar(width, height, pixels, colormap.clone());
9392    spec.alpha = alpha;
9393    spec.interpolation = interpolation;
9394    spec.aggregation = aggregation;
9395    spec.aggregation_block = aggregation_block;
9396    spec
9397}
9398
9399pub struct ImageView {
9400    image_plot: Plot2D,
9401    histo_h: Plot1D,
9402    histo_v: Plot1D,
9403    sync_x: crate::widget::sync::SyncAxes,
9404    sync_y: crate::widget::sync::SyncAxes,
9405    image_handle: Option<ItemHandle>,
9406    histo_h_curve: Option<ItemHandle>,
9407    histo_v_curve: Option<ItemHandle>,
9408    width: u32,
9409    height: u32,
9410    pixels: Vec<f32>,
9411    /// Colormap of the active image, retained so the side colorbar
9412    /// (silx `ImageView` `getColorBarWidget`, ImageView.py:501) reflects the
9413    /// current value limits.
9414    colormap: Colormap,
9415    /// Active-image opacity slider (silx `ImageView` `ActiveImageAlphaSlider`,
9416    /// ImageView.py:513-517). Its value propagates to the displayed image.
9417    alpha: crate::widget::alpha_slider::AlphaSlider,
9418    /// Data-to-screen interpolation of the active image (silx image
9419    /// `interpolation`, items/image.py: nearest / linear).
9420    interpolation: InterpolationMode,
9421    /// Block aggregation of the active image (silx `ImageDataAggregated`,
9422    /// items/image.py: max / mean / min).
9423    aggregation: AggregationMode,
9424    /// Per-axis block factors `(block_x, block_y)` for [`aggregation`]
9425    /// (silx level-of-detail `(lodx, lody)`).
9426    ///
9427    /// [`aggregation`]: ImageView::aggregation
9428    aggregation_block: (u32, u32),
9429    /// Cursor-coordinate readout fed by the live pointer (silx
9430    /// `tools/PositionInfo.PositionInfo`, bound to the plot `sigMouseMoved`).
9431    position_info: crate::widget::position_info::PositionInfo,
9432    /// Last cursor data coordinates `(x, y)` from a pointer move/click over the
9433    /// image plot, or `None` when no pointer event landed on the data area.
9434    cursor: Option<[f64; 2]>,
9435    /// Corner overview of the full image extent with a draggable viewport
9436    /// rectangle (silx `ImageView._radarView`, ImageView.py:486-490). Dragging
9437    /// it pans the image plot.
9438    radar: crate::widget::radar_view::RadarView,
9439    /// Active profile-extraction mode of the profile tool (silx
9440    /// `ImageView._ProfileToolBar`, ImageView.py:692-697). [`ProfileMode::None`]
9441    /// disables the tool.
9442    profile_mode: ProfileMode,
9443    /// Popup window showing the extracted 1D profile (silx profile window).
9444    profile_window: crate::widget::profile_window::ProfileWindow,
9445    /// Data-space `(col, row)` where the current profile drag began, or `None`
9446    /// when no drag is in progress.
9447    profile_drag_start: Option<(f64, f64)>,
9448    /// Whether the side colorbar column is shown (silx `ColorBarAction`,
9449    /// ImageView's `ColorBarWidget` visibility). Defaults to `true`; when `false`
9450    /// the colorbar column is not reserved and the image fills its width.
9451    show_colorbar: bool,
9452    /// Whether the side histograms (and the radar overview) are shown (silx
9453    /// `ImageView.setSideHistogramDisplayed`, ImageView.py:552-566). Defaults to
9454    /// `true`; when `false` the top/right strips and the radar are not drawn and
9455    /// the image reclaims that space.
9456    show_side_histograms: bool,
9457    /// Whether the side colorbar is the interactive pyqtgraph-style
9458    /// [`HistogramColorBar`](crate::widget::histogram_colorbar::HistogramColorBar)
9459    /// (value histogram + draggable `vmin`/`vmax` handles) instead of the static
9460    /// [`ColorBarWidget`](crate::widget::colorbar::ColorBarWidget). Defaults to
9461    /// `false` (silx-faithful: silx adjusts levels through a separate
9462    /// `ColormapDialog`). When `true`, dragging a handle re-renders the image with
9463    /// the new colormap levels.
9464    interactive_colorbar: bool,
9465    /// Cached value-distribution histogram `(counts, edges)` for the interactive
9466    /// colorbar, recomputed only when the image data or normalization changes —
9467    /// not per frame, nor on a level drag (drags do not change the histogram).
9468    value_histogram: Option<(Vec<u64>, Vec<f64>)>,
9469    /// The active image's finite value range `(min, max)`, the axis basis for the
9470    /// interactive colorbar; recomputed alongside [`value_histogram`].
9471    value_range: (f64, f64),
9472    /// Normalization the cached [`value_histogram`] was binned under; a change
9473    /// triggers a recompute (log vs linear binning differ). `None` invalidates
9474    /// the cache (set when the image data changes).
9475    histogram_norm: Option<Normalization>,
9476    /// Per-pixel mask editor for the active image (silx `ImageView`'s mask
9477    /// `MaskToolsWidget`). Resized to the active image on [`Self::set_image`].
9478    /// Painting is gated strictly on [`PlotInteractionMode::MaskDraw`]
9479    /// ([`image_view_should_paint_mask`]); the painted level buffer is converted
9480    /// to a [`ScalarMask`] and re-uploaded (masked pixels → `NaN`) via the same
9481    /// pre-upload path as [`Plot2D::try_add_masked_image`].
9482    mask: crate::widget::mask_tools::MaskToolsWidget,
9483}
9484
9485/// Width in points to reserve for the side colorbar column given the show flag
9486/// and whether a colorbar is available, mirroring silx `ColorBarAction` toggling
9487/// the `ColorBarWidget`'s visibility. Returns [`COLORBAR_WIDTH`] only when the
9488/// bar is both shown and available, else `0.0` (no column reserved). Split out
9489/// so the show/hide reservation is unit-testable without a GPU backend.
9490fn colorbar_column_width(show: bool, has_colorbar: bool) -> f32 {
9491    if show && has_colorbar {
9492        COLORBAR_WIDTH
9493    } else {
9494        0.0
9495    }
9496}
9497
9498/// Width left for the flexible (plot/image) child of a `ui.horizontal` row
9499/// after the fixed-width side columns: subtracts the columns AND the
9500/// `item_spacing.x` gap egui inserts before each of the `gaps` trailing
9501/// children. Sizing the flexible child to `avail.x - columns` without the
9502/// gaps overflowed the row past the window's right edge, visually clipping
9503/// the last column (e.g. the colorbar's value labels).
9504fn row_content_width(avail_x: f32, side_columns: f32, gaps: u32, spacing: f32) -> f32 {
9505    (avail_x - side_columns - gaps as f32 * spacing).max(0.0)
9506}
9507
9508/// Finite `(min, max)` of a slice, or `None` when no value is finite. Used to
9509/// derive the interactive colorbar's axis from the active image's pixels.
9510fn finite_minmax(data: &[f64]) -> Option<(f64, f64)> {
9511    let mut lo = f64::INFINITY;
9512    let mut hi = f64::NEG_INFINITY;
9513    for &v in data {
9514        if v.is_finite() {
9515            lo = lo.min(v);
9516            hi = hi.max(v);
9517        }
9518    }
9519    (lo.is_finite() && hi.is_finite()).then_some((lo, hi))
9520}
9521
9522/// Extent (points) to reserve for a side-histogram strip given the show flag
9523/// (silx `ImageView.setSideHistogramDisplayed`): the `requested` size when
9524/// shown, else `0.0` — the strip is not drawn and the image reclaims the space.
9525/// Split out so the show/hide reservation is unit-testable without a GPU backend.
9526fn side_histogram_extent(show: bool, requested: f32) -> f32 {
9527    if show { requested } else { 0.0 }
9528}
9529
9530/// Which side-histogram profile [`ImageView::histogram`] returns, mirroring the
9531/// `axis` argument of silx `ImageView.getHistogram(axis)`.
9532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9533pub enum ImageHistogramAxis {
9534    /// Horizontal histogram: per-column sums over the image rows (silx `'x'`).
9535    X,
9536    /// Vertical histogram: per-row sums over the image columns (silx `'y'`).
9537    Y,
9538}
9539
9540/// A side-histogram profile and its index extent, mirroring the silx
9541/// `ImageView.getHistogram` dict `{data, extent}`: `data` is the profile sum per
9542/// column ([`ImageHistogramAxis::X`]) or per row ([`ImageHistogramAxis::Y`]), and
9543/// `extent` is the `(start, end)` index range with `end` exclusive
9544/// (`data.len() == end - start`).
9545#[derive(Debug, Clone, PartialEq)]
9546pub struct ImageProfileHistogram {
9547    /// Per-column (X) or per-row (Y) sum of the image pixels.
9548    pub data: Vec<f64>,
9549    /// `(start, end)` index extent; `end` is exclusive.
9550    pub extent: (f64, f64),
9551}
9552
9553/// Per-column sums of a row-major `w×h` image (silx `histoH`):
9554/// `out[col] = Σ_row pixels[row*w + col]`. Empty when `w == 0`.
9555fn image_column_sums(pixels: &[f32], w: usize, h: usize) -> Vec<f64> {
9556    (0..w)
9557        .map(|col| (0..h).map(|row| pixels[row * w + col] as f64).sum())
9558        .collect()
9559}
9560
9561/// Per-row sums of a row-major `w×h` image (silx `histoV`):
9562/// `out[row] = Σ_col pixels[row*w + col]`. Empty when `h == 0`.
9563fn image_row_sums(pixels: &[f32], w: usize, h: usize) -> Vec<f64> {
9564    (0..h)
9565        .map(|row| (0..w).map(|col| pixels[row * w + col] as f64).sum())
9566        .collect()
9567}
9568
9569/// The `(col, row, value)` triple silx `ImageView.valueChanged` emits for a
9570/// cursor at data coordinates `(x, y)` over the active image
9571/// (`ImageView._imagePlotCB`, ImageView.py:585-601). siplot's ImageView uses
9572/// identity image geometry (origin `(0, 0)`, scale `(1, 1)`), so a pixel index
9573/// is the truncated coordinate. Returns `None` — silx emits nothing — when the
9574/// cursor is left of / below the origin or outside the pixel grid, or when no
9575/// image is loaded.
9576fn image_value_at(
9577    x: f64,
9578    y: f64,
9579    pixels: &[f32],
9580    width: usize,
9581    height: usize,
9582) -> Option<(f64, f64, f64)> {
9583    if width == 0 || height == 0 || pixels.len() < width * height {
9584        return None;
9585    }
9586    // silx guard: cursor must be at or beyond the image origin (here (0, 0)).
9587    if x < 0.0 || y < 0.0 {
9588        return None;
9589    }
9590    // silx `int((x - origin) / scale)`: truncation toward zero. The non-negative
9591    // guard above makes `as usize` (also truncating) equal to a floor here.
9592    let col = x as usize;
9593    let row = y as usize;
9594    if col >= width || row >= height {
9595        return None;
9596    }
9597    Some((col as f64, row as f64, pixels[row * width + col] as f64))
9598}
9599
9600impl ImageView {
9601    /// Create a new `ImageView`.
9602    ///
9603    /// Plots use ids `image_id`, `image_id + 1` (histo_h), and `image_id + 2`
9604    /// (histo_v) — choose a base id that does not collide with other plots in
9605    /// the same egui frame.
9606    pub fn new(render_state: &RenderState, image_id: PlotId) -> Self {
9607        let mut image_plot = Plot2D::new(render_state, image_id);
9608        image_plot.set_graph_cursor(true);
9609        image_plot.set_keep_data_aspect_ratio(true);
9610        // ImageView renders its own dedicated colorbar column (silx
9611        // `getColorBarWidget` at grid (0,2), ImageView.py:499). The image plot
9612        // still carries the active image's colormap, which would otherwise draw
9613        // a second, built-in colorbar — suppress it so only the dedicated one
9614        // shows.
9615        image_plot.set_show_colorbar(false);
9616
9617        // Side profile plots mirror silx `_SideHistogram` (ImageView.py:168): a
9618        // bare plot with no graph title, no axis labels, and no grid so the full
9619        // strip is the profile curve — `Plot1D::new` defaults a title-less plot
9620        // with "X"/"Y" labels and a major grid, so those are cleared here. A 10%
9621        // data margin is set on the independent "sum" axis (silx `setDataMargins`,
9622        // ImageView.py:464/477) so the profile peak does not touch the frame
9623        // edge. The "sum" axis is histo_h's Y and histo_v's X; the other axis is
9624        // synced to the image and overrides any margin there.
9625        let mut histo_h = Plot1D::new(render_state, image_id + 1);
9626        histo_h.clear_graph_x_label();
9627        histo_h.clear_graph_y_label(YAxis::Left);
9628        histo_h.set_graph_grid(false);
9629        // Reserve the (blank) y-label gutter the image carries ("Rows") so
9630        // histo_h's data area starts at the same x as the image's — the column
9631        // profile then lines up over the image columns (silx aligned grid).
9632        histo_h.plot_mut().reserve_y_label_gutter = true;
9633        histo_h.plot_mut().set_data_margins(DataMargins {
9634            x_min: 0.0,
9635            x_max: 0.0,
9636            y_min: 0.1,
9637            y_max: 0.1,
9638        });
9639
9640        let mut histo_v = Plot1D::new(render_state, image_id + 2);
9641        histo_v.clear_graph_x_label();
9642        histo_v.clear_graph_y_label(YAxis::Left);
9643        histo_v.set_graph_grid(false);
9644        // Reserve the (blank) x-label gutter the image carries ("Columns") so
9645        // histo_v's data-area bottom matches the image's; the top title gutter is
9646        // mirrored per-frame in `show` since the image title is caller-set.
9647        histo_v.plot_mut().reserve_x_label_gutter = true;
9648        histo_v.plot_mut().set_data_margins(DataMargins {
9649            x_min: 0.1,
9650            x_max: 0.1,
9651            y_min: 0.0,
9652            y_max: 0.0,
9653        });
9654
9655        Self {
9656            image_plot,
9657            histo_h,
9658            histo_v,
9659            sync_x: crate::widget::sync::SyncAxes::new().with_sync_y(false),
9660            sync_y: crate::widget::sync::SyncAxes::new().with_sync_x(false),
9661            image_handle: None,
9662            histo_h_curve: None,
9663            histo_v_curve: None,
9664            width: 0,
9665            height: 0,
9666            pixels: Vec::new(),
9667            colormap: Colormap::viridis(0.0, 1.0),
9668            alpha: crate::widget::alpha_slider::AlphaSlider::default(),
9669            interpolation: InterpolationMode::default(),
9670            aggregation: AggregationMode::default(),
9671            aggregation_block: (1, 1),
9672            position_info: crate::widget::position_info::PositionInfo::with_xy(),
9673            cursor: None,
9674            radar: crate::widget::radar_view::RadarView::default(),
9675            profile_mode: ProfileMode::None,
9676            profile_window: crate::widget::profile_window::ProfileWindow::new(
9677                render_state,
9678                image_id + 3,
9679            ),
9680            profile_drag_start: None,
9681            show_colorbar: true,
9682            show_side_histograms: true,
9683            interactive_colorbar: false,
9684            value_histogram: None,
9685            value_range: (0.0, 1.0),
9686            histogram_norm: None,
9687            mask: crate::widget::mask_tools::MaskToolsWidget::new(0, 0),
9688        }
9689    }
9690
9691    /// Whether the side colorbar column is shown (silx `ColorBarAction`).
9692    pub fn show_colorbar(&self) -> bool {
9693        self.show_colorbar
9694    }
9695
9696    /// Show or hide the side colorbar column (silx `ColorBarAction`). When
9697    /// hidden, [`Self::show`] does not reserve the colorbar column and the image
9698    /// fills the freed width.
9699    pub fn set_show_colorbar(&mut self, show: bool) {
9700        self.show_colorbar = show;
9701    }
9702
9703    /// Whether the side colorbar is the interactive pyqtgraph-style histogram
9704    /// colorbar (value histogram + draggable `vmin`/`vmax` handles).
9705    pub fn interactive_colorbar(&self) -> bool {
9706        self.interactive_colorbar
9707    }
9708
9709    /// Enable or disable the interactive pyqtgraph-style histogram colorbar. When
9710    /// enabled the side colorbar column shows the active image's value-distribution
9711    /// histogram with two draggable handles; dragging a handle adjusts the
9712    /// colormap's `vmin`/`vmax` and re-renders the image live. Off by default
9713    /// (the static silx [`ColorBarWidget`](crate::widget::colorbar::ColorBarWidget);
9714    /// silx itself adjusts levels through a separate `ColormapDialog`).
9715    pub fn set_interactive_colorbar(&mut self, interactive: bool) {
9716        self.interactive_colorbar = interactive;
9717    }
9718
9719    /// Whether the side histograms (and the radar overview) are displayed (silx
9720    /// `ImageView.isSideHistogramDisplayed`).
9721    pub fn is_side_histogram_displayed(&self) -> bool {
9722        self.show_side_histograms
9723    }
9724
9725    /// Show or hide the side histograms and the radar overview (silx
9726    /// `ImageView.setSideHistogramDisplayed`). When hidden, [`Self::show`] does
9727    /// not reserve the top/right strips or the radar, and the image reclaims
9728    /// that space.
9729    pub fn set_side_histogram_displayed(&mut self, show: bool) {
9730        self.show_side_histograms = show;
9731    }
9732
9733    /// Upload and display a new image.
9734    pub fn set_image(
9735        &mut self,
9736        width: u32,
9737        height: u32,
9738        pixels: &[f32],
9739        colormap: Colormap,
9740    ) -> Result<(), PlotDataError> {
9741        let expected = (width as usize).saturating_mul(height as usize);
9742        if pixels.len() != expected {
9743            return Err(PlotDataError::ImageDataLength {
9744                expected,
9745                actual: pixels.len(),
9746            });
9747        }
9748        self.width = width;
9749        self.height = height;
9750        self.pixels = pixels.to_vec();
9751        // Invalidate the interactive-colorbar histogram cache; it is recomputed
9752        // lazily in `show` from the new pixels.
9753        self.histogram_norm = None;
9754        self.value_histogram = None;
9755        self.colormap = colormap.clone();
9756        self.image_plot.set_default_colormap(colormap);
9757
9758        // Resize the mask editor to the new active image (silx `MaskToolsWidget`
9759        // resets to the image shape; a shape change clears the undo history).
9760        if self.mask.width != width || self.mask.height != height {
9761            self.mask.reset_geometry(width, height);
9762        }
9763
9764        // The image uses default geometry (origin (0,0), unit scale), so its
9765        // data extent is [0, width] × [0, height]. Feed it to the radar overview
9766        // (silx `_updateDataContent` from `getDataRange`).
9767        self.radar
9768            .set_data_bounds(0.0, width as f64, 0.0, height as f64);
9769
9770        self.upload_image();
9771        self.rebuild_histograms();
9772        Ok(())
9773    }
9774
9775    /// Build the active image's [`ImageSpec`] from the retained colormap and
9776    /// alpha, then add or update it on the image plot. Routing every upload
9777    /// through one spec keeps the alpha (silx `ActiveImageAlphaSlider`) in sync
9778    /// with the displayed image.
9779    fn upload_image(&mut self) {
9780        if self.width == 0 || self.pixels.is_empty() {
9781            return;
9782        }
9783        // Mask path (silx `getValueData`): when the mask editor has any masked
9784        // pixel for this image, NaN those pixels before upload so the scalar
9785        // pipeline's `nan_color` renders them as holes (the 6B-1 pre-upload mask
9786        // representation, identical to `Plot2D::try_add_masked_image`). When no
9787        // pixel is masked, upload the pixels verbatim (no extra allocation).
9788        let masked: Option<Vec<f32>> = if self.mask.width == self.width
9789            && self.mask.height == self.height
9790            && self.mask.mask.iter().any(|&level| level != 0)
9791        {
9792            let scalar_mask =
9793                scalar_mask_from_level_buffer(self.width, self.height, &self.mask.mask);
9794            Some(scalar_mask.apply(&self.pixels))
9795        } else {
9796            None
9797        };
9798        let pixels: &[f32] = masked.as_deref().unwrap_or(&self.pixels);
9799
9800        let spec = image_view_image_spec(
9801            self.width,
9802            self.height,
9803            pixels,
9804            &self.colormap,
9805            self.alpha.alpha(),
9806            self.interpolation,
9807            self.aggregation,
9808            self.aggregation_block,
9809        );
9810        if let Some(handle) = self.image_handle {
9811            self.image_plot.update_image_spec(handle, spec);
9812        } else {
9813            let h = self.image_plot.add_image_spec(spec);
9814            self.image_handle = Some(h);
9815        }
9816    }
9817
9818    /// Recompute the cached value-distribution histogram and data range used by
9819    /// the interactive colorbar, but only when stale: the image data changed
9820    /// (cache invalidated in [`Self::set_image`]) or the normalization changed
9821    /// (log vs linear binning differ). Keyed on the normalization, so a `vmin`/
9822    /// `vmax` drag does not trigger a recompute.
9823    fn ensure_value_histogram(&mut self) {
9824        let norm = self.colormap.normalization;
9825        if self.histogram_norm == Some(norm) {
9826            return;
9827        }
9828        let data: Vec<f64> = self.pixels.iter().map(|&p| p as f64).collect();
9829        let log = norm == Normalization::Log;
9830        self.value_histogram = crate::core::histogram::compute_histogram(&data, None, log);
9831        self.value_range = finite_minmax(&data).unwrap_or((self.colormap.vmin, self.colormap.vmax));
9832        self.histogram_norm = Some(norm);
9833    }
9834
9835    /// The current active-image opacity in `[0.0, 1.0]` (silx
9836    /// `ActiveImageAlphaSlider`, ImageView.py:513-517).
9837    pub fn alpha(&self) -> f32 {
9838        self.alpha.alpha()
9839    }
9840
9841    /// Set the active-image opacity in `[0.0, 1.0]` and re-upload the image so
9842    /// the change takes effect (silx `ActiveImageAlphaSlider.valueChanged`
9843    /// → `image.setAlpha`).
9844    pub fn set_alpha(&mut self, alpha: f32) {
9845        self.alpha.set_alpha(alpha);
9846        self.upload_image();
9847    }
9848
9849    /// The active image's data-to-screen interpolation (silx image
9850    /// `interpolation`).
9851    pub fn interpolation(&self) -> InterpolationMode {
9852        self.interpolation
9853    }
9854
9855    /// Set the active image's interpolation and re-upload it (silx
9856    /// `image.setInterpolation`, items/image.py).
9857    pub fn set_interpolation(&mut self, interpolation: InterpolationMode) {
9858        if interpolation != self.interpolation {
9859            self.interpolation = interpolation;
9860            self.upload_image();
9861        }
9862    }
9863
9864    /// The active image's block aggregation (silx `ImageDataAggregated`).
9865    pub fn aggregation(&self) -> AggregationMode {
9866        self.aggregation
9867    }
9868
9869    /// The active image's per-axis aggregation block factors `(block_x,
9870    /// block_y)` (silx level-of-detail `(lodx, lody)`).
9871    pub fn aggregation_block(&self) -> (u32, u32) {
9872        self.aggregation_block
9873    }
9874
9875    /// Set the active image's block aggregation `mode` and per-axis block
9876    /// factors, then re-upload it (silx `ImageDataAggregated.setAggregationMode`,
9877    /// items/image_aggregated.py). Each block factor is clamped to `>= 1`.
9878    pub fn set_aggregation(&mut self, mode: AggregationMode, block: (u32, u32)) {
9879        let block = (block.0.max(1), block.1.max(1));
9880        if mode != self.aggregation || block != self.aggregation_block {
9881            self.aggregation = mode;
9882            self.aggregation_block = block;
9883            self.upload_image();
9884        }
9885    }
9886
9887    /// Show the ImageView toolbar: interpolation / aggregation selectors on the
9888    /// active image (silx image `interpolation` nearest/linear and
9889    /// `ImageDataAggregated` max/mean/min, items/image.py) plus the active-image
9890    /// alpha slider (silx `ActiveImageAlphaSlider`, ImageView.py:513-517). Each
9891    /// change is propagated to the displayed image.
9892    pub fn show_toolbar(&mut self, ui: &mut egui::Ui) {
9893        ui.horizontal_wrapped(|ui| {
9894            // Interpolation selector (silx image interpolation).
9895            let mut interpolation = self.interpolation;
9896            egui::ComboBox::from_label("interp")
9897                .selected_text(match interpolation {
9898                    InterpolationMode::Nearest => "nearest",
9899                    InterpolationMode::Linear => "linear",
9900                })
9901                .show_ui(ui, |ui| {
9902                    ui.selectable_value(&mut interpolation, InterpolationMode::Nearest, "nearest");
9903                    ui.selectable_value(&mut interpolation, InterpolationMode::Linear, "linear");
9904                });
9905            if interpolation != self.interpolation {
9906                self.set_interpolation(interpolation);
9907            }
9908
9909            // Aggregation selector (silx ImageDataAggregated).
9910            let mut aggregation = self.aggregation;
9911            egui::ComboBox::from_label("agg")
9912                .selected_text(match aggregation {
9913                    AggregationMode::None => "none",
9914                    AggregationMode::Max => "max",
9915                    AggregationMode::Mean => "mean",
9916                    AggregationMode::Min => "min",
9917                })
9918                .show_ui(ui, |ui| {
9919                    ui.selectable_value(&mut aggregation, AggregationMode::None, "none");
9920                    ui.selectable_value(&mut aggregation, AggregationMode::Max, "max");
9921                    ui.selectable_value(&mut aggregation, AggregationMode::Mean, "mean");
9922                    ui.selectable_value(&mut aggregation, AggregationMode::Min, "min");
9923                });
9924
9925            // Block factors (silx level-of-detail (lodx, lody)).
9926            let mut block = self.aggregation_block;
9927            let bx = ui.add(
9928                egui::DragValue::new(&mut block.0)
9929                    .range(1..=64)
9930                    .prefix("bx "),
9931            );
9932            let by = ui.add(
9933                egui::DragValue::new(&mut block.1)
9934                    .range(1..=64)
9935                    .prefix("by "),
9936            );
9937            if aggregation != self.aggregation || bx.changed() || by.changed() {
9938                self.set_aggregation(aggregation, block);
9939            }
9940
9941            // Colorbar show/hide toggle (silx `ColorBarAction`).
9942            if ui
9943                .selectable_label(self.show_colorbar, "colorbar")
9944                .on_hover_text("Show/hide the colorbar")
9945                .clicked()
9946            {
9947                crate::widget::actions::control::image_colorbar_toggle(self);
9948            }
9949        });
9950
9951        let response = self.alpha.ui(ui);
9952        if response.changed() {
9953            self.upload_image();
9954        }
9955
9956        // Profile-tool toggles (silx _ProfileToolBar action group,
9957        // ImageView.py:692-697). Clicking the active mode again disables it.
9958        ui.horizontal(|ui| {
9959            ui.spacing_mut().item_spacing.x = 2.0;
9960            ui.label("profile:");
9961            for (label, tooltip, mode) in [
9962                ("—", "Row profile (horizontal)", ProfileMode::Horizontal),
9963                ("|", "Column profile (vertical)", ProfileMode::Vertical),
9964                ("/", "Line profile (drag)", ProfileMode::Line),
9965                ("□", "Rectangle profile (drag)", ProfileMode::Rectangle),
9966            ] {
9967                if ui
9968                    .selectable_label(self.profile_mode == mode, label)
9969                    .on_hover_text(tooltip)
9970                    .clicked()
9971                {
9972                    let next = if self.profile_mode == mode {
9973                        ProfileMode::None
9974                    } else {
9975                        mode
9976                    };
9977                    self.set_profile_mode(next);
9978                }
9979            }
9980        });
9981
9982        // Mask-draw tool: toggle MaskDraw mode (silx `MaskToolsWidget`
9983        // activating the plot's pencil draw interaction). Entering it sets the
9984        // mask tool to Pencil so the primary drag paints; exiting restores Zoom
9985        // and disables the tool. While active, a brush-size slider and the
9986        // pencil/eraser/clear controls are exposed.
9987        let in_mask_draw = self.image_plot.interaction_mode() == PlotInteractionMode::MaskDraw;
9988        ui.horizontal(|ui| {
9989            ui.spacing_mut().item_spacing.x = 2.0;
9990            ui.label("mask:");
9991            if ui
9992                .selectable_label(in_mask_draw, "✏")
9993                .on_hover_text("Draw mask (pencil): primary drag paints the mask")
9994                .clicked()
9995            {
9996                self.set_mask_draw(!in_mask_draw);
9997            }
9998            if in_mask_draw {
9999                ui.selectable_value(
10000                    &mut self.mask.active_tool,
10001                    crate::widget::mask_tools::MaskTool::Pencil,
10002                    "pencil",
10003                )
10004                .on_hover_text("Paint mask");
10005                ui.selectable_value(
10006                    &mut self.mask.active_tool,
10007                    crate::widget::mask_tools::MaskTool::Eraser,
10008                    "eraser",
10009                )
10010                .on_hover_text("Erase mask");
10011                // silx pencil width: 1-50 slider + 1-1024 spin box, kept in sync
10012                // (_BaseMaskToolsWidget.py:822-846 / _pencilWidthChanged). Both
10013                // bind the same `brush_size`.
10014                ui.add(egui::Slider::new(&mut self.mask.brush_size, 1..=50).text("brush"));
10015                ui.add(
10016                    egui::DragValue::new(&mut self.mask.brush_size)
10017                        .range(1..=1024)
10018                        .speed(1.0),
10019                )
10020                .on_hover_text("Brush width in pixels (1-1024)");
10021                if ui.button("clear mask").clicked() {
10022                    self.mask.clear_all();
10023                    self.mask.commit();
10024                    self.upload_image();
10025                }
10026                // Invert the current mask level (silx invert action,
10027                // _BaseMaskToolsWidget.py:207-218 / BaseMask.invert): unmasked
10028                // pixels become the current level and current-level pixels clear;
10029                // other levels are untouched. Operates on the mask buffer only.
10030                if ui
10031                    .button("invert")
10032                    .on_hover_text("Invert the current mask level")
10033                    .clicked()
10034                {
10035                    self.mask.invert();
10036                    self.mask.commit();
10037                    self.upload_image();
10038                }
10039                // Mask non-finite pixels (silx "Mask not finite values" button,
10040                // _BaseMaskToolsWidget.py:296-304). Only meaningful when the mask
10041                // geometry matches the active image; the guard also keeps
10042                // `mask_not_finite` from indexing past the mask buffer.
10043                let mask_matches_image = self.mask.width == self.width
10044                    && self.mask.height == self.height
10045                    && !self.pixels.is_empty();
10046                if ui
10047                    .add_enabled(mask_matches_image, egui::Button::new("mask non-finite"))
10048                    .on_hover_text("Mask all NaN / infinite pixels at the current level")
10049                    .clicked()
10050                {
10051                    self.mask.mask_not_finite(&self.pixels);
10052                    self.mask.commit();
10053                    self.upload_image();
10054                }
10055            }
10056        });
10057        // Threshold-masking row (silx threshold group box,
10058        // `_BaseMaskToolsWidget._initThresholdGroupBox` / `_maskBtnClicked`):
10059        // pick below/between/above, enter the bound(s), and Apply masks the
10060        // matching pixels at the current level then commits. Per silx, the min
10061        // edit shows for below/between and the max edit for between/above. Only
10062        // meaningful when the mask geometry matches the active image.
10063        if in_mask_draw {
10064            use crate::widget::mask_tools::ThresholdMode;
10065            ui.horizontal(|ui| {
10066                ui.spacing_mut().item_spacing.x = 2.0;
10067                ui.label("threshold:");
10068                egui::ComboBox::from_id_salt("mask_threshold_mode")
10069                    .selected_text(match self.mask.threshold_mode {
10070                        ThresholdMode::Below => "below",
10071                        ThresholdMode::Between => "between",
10072                        ThresholdMode::Above => "above",
10073                    })
10074                    .show_ui(ui, |ui| {
10075                        ui.selectable_value(
10076                            &mut self.mask.threshold_mode,
10077                            ThresholdMode::Below,
10078                            "below",
10079                        );
10080                        ui.selectable_value(
10081                            &mut self.mask.threshold_mode,
10082                            ThresholdMode::Between,
10083                            "between",
10084                        );
10085                        ui.selectable_value(
10086                            &mut self.mask.threshold_mode,
10087                            ThresholdMode::Above,
10088                            "above",
10089                        );
10090                    });
10091                let mode = self.mask.threshold_mode;
10092                let show_min = matches!(mode, ThresholdMode::Below | ThresholdMode::Between);
10093                let show_max = matches!(mode, ThresholdMode::Between | ThresholdMode::Above);
10094                if show_min {
10095                    ui.label("min");
10096                    ui.add(egui::DragValue::new(&mut self.mask.threshold_min).speed(0.1));
10097                }
10098                if show_max {
10099                    ui.label("max");
10100                    ui.add(egui::DragValue::new(&mut self.mask.threshold_max).speed(0.1));
10101                }
10102                let apply_label = match mode {
10103                    ThresholdMode::Below => "Mask below",
10104                    ThresholdMode::Between => "Mask between",
10105                    ThresholdMode::Above => "Mask above",
10106                };
10107                let mask_matches_image = self.mask.width == self.width
10108                    && self.mask.height == self.height
10109                    && !self.pixels.is_empty();
10110                let (min, max) = (self.mask.threshold_min, self.mask.threshold_max);
10111                if ui
10112                    .add_enabled(mask_matches_image, egui::Button::new(apply_label))
10113                    .on_hover_text("Mask pixels matching the threshold at the current level")
10114                    .clicked()
10115                {
10116                    self.mask.update_threshold(&self.pixels, mode, min, max);
10117                    self.mask.commit();
10118                    self.upload_image();
10119                }
10120                // silx `_BaseMaskToolsWidget` "Set min-max from colormap"
10121                // (MaskToolsWidget override :883-892): copy the colormap's value
10122                // range into the threshold fields. siplot's `colormap.vmin/vmax`
10123                // already hold the effective (post-autoscale) range after upload,
10124                // so this is the faithful equivalent of silx's vmin/vmax-or-auto.
10125                if ui
10126                    .button("Min-max from colormap")
10127                    .on_hover_text("Copy the colormap's value range into the threshold fields")
10128                    .clicked()
10129                {
10130                    self.mask.threshold_min = self.colormap.vmin as f32;
10131                    self.mask.threshold_max = self.colormap.vmax as f32;
10132                }
10133            });
10134        }
10135    }
10136
10137    /// Enter or leave pencil / mask-draw mode (silx `MaskToolsWidget` activating
10138    /// the plot's pencil draw interaction). Entering sets the image plot to
10139    /// [`PlotInteractionMode::MaskDraw`] and the mask tool to
10140    /// [`crate::widget::mask_tools::MaskTool::Pencil`] so the primary drag
10141    /// paints; leaving restores [`PlotInteractionMode::Zoom`] and disables the
10142    /// tool ([`crate::widget::mask_tools::MaskTool::None`]).
10143    pub fn set_mask_draw(&mut self, on: bool) {
10144        if on {
10145            crate::widget::actions::mode::mask_draw_mode(&mut self.image_plot);
10146            if !matches!(
10147                self.mask.active_tool,
10148                crate::widget::mask_tools::MaskTool::Pencil
10149                    | crate::widget::mask_tools::MaskTool::Eraser
10150            ) {
10151                self.mask.active_tool = crate::widget::mask_tools::MaskTool::Pencil;
10152            }
10153        } else {
10154            crate::widget::actions::mode::zoom_mode(&mut self.image_plot);
10155            self.mask.active_tool = crate::widget::mask_tools::MaskTool::None;
10156        }
10157    }
10158
10159    /// Whether the image plot is in pencil / mask-draw mode
10160    /// ([`PlotInteractionMode::MaskDraw`]).
10161    pub fn is_mask_draw(&self) -> bool {
10162        self.image_plot.interaction_mode() == PlotInteractionMode::MaskDraw
10163    }
10164
10165    /// The mask editor for the active image (silx `ImageView` mask
10166    /// `MaskToolsWidget`), exposing whole-mask operations and the painted level
10167    /// buffer.
10168    pub fn mask(&self) -> &crate::widget::mask_tools::MaskToolsWidget {
10169        &self.mask
10170    }
10171
10172    /// Mutable access to the mask editor for the active image, for programmatic
10173    /// mask operations (silx `ImageView` mask `MaskToolsWidget`). After mutating
10174    /// the mask, call [`Self::set_image`] or re-show to re-upload the masked
10175    /// image.
10176    pub fn mask_mut(&mut self) -> &mut crate::widget::mask_tools::MaskToolsWidget {
10177        &mut self.mask
10178    }
10179
10180    /// The active image's colormap (silx `ImageView.getColormap`).
10181    pub fn colormap(&self) -> &Colormap {
10182        &self.colormap
10183    }
10184
10185    /// A [`ColorBarWidget`](crate::ColorBarWidget) for the active image's colormap, used by
10186    /// [`Self::show`] to render the side colorbar (silx
10187    /// `ImageView.getColorBarWidget`, ImageView.py:501). The bar's value limits
10188    /// track the colormap's `vmin`/`vmax`.
10189    pub fn colorbar(&self) -> crate::widget::colorbar::ColorBarWidget {
10190        image_view_colorbar(&self.colormap)
10191    }
10192
10193    /// Render the image + side histogram panels.
10194    ///
10195    /// Call this once per frame.  The top histogram occupies `histo_height`
10196    /// points of vertical space; the right histogram occupies `histo_width`
10197    /// points of horizontal space.  Pass `None` to use the defaults (80 pt).
10198    pub fn show(&mut self, ui: &mut egui::Ui, histo_height: Option<f32>, histo_width: Option<f32>) {
10199        // Default strip size = silx `HISTOGRAMS_HEIGHT` (ImageView.py:374): the
10200        // side-profile strips are 200px on their short dimension, enough room for
10201        // the profile curve plus the sum-axis tick labels. When the side
10202        // histograms are hidden (silx `setSideHistogramDisplayed(False)`), the
10203        // strips reserve no space and the image reclaims the freed width/height.
10204        let show_histos = self.show_side_histograms;
10205        let histo_h_h = side_histogram_extent(show_histos, histo_height.unwrap_or(200.0));
10206        let histo_v_w = side_histogram_extent(show_histos, histo_width.unwrap_or(200.0));
10207
10208        // Synchronise axes before rendering.
10209        self.sync_x
10210            .sync(&mut [self.image_plot.plot_mut(), self.histo_h.plot_mut()]);
10211        self.sync_y
10212            .sync(&mut [self.image_plot.plot_mut(), self.histo_v.plot_mut()]);
10213
10214        // Mirror the image's top title gutter onto histo_v so its data-area top
10215        // tracks the image's. The image title is caller-set and may change at
10216        // runtime, so this is re-evaluated each frame (unlike the static
10217        // x/y-label gutters reserved once in `new`).
10218        self.histo_v.plot_mut().reserve_title_gutter = self.image_plot.graph_title().is_some();
10219
10220        let avail = ui.available_size();
10221
10222        // Reserve the far-right colorbar column (silx grid column 2,
10223        // ImageView.py:501), unless the colorbar is hidden (silx
10224        // `ColorBarAction`). The interactive histogram colorbar needs a wider
10225        // column for the value histogram beside the gradient.
10226        let colorbar_w = colorbar_column_width(self.show_colorbar, true);
10227        let colorbar_w = if self.interactive_colorbar && colorbar_w > 0.0 {
10228            INTERACTIVE_COLORBAR_WIDTH
10229        } else {
10230            colorbar_w
10231        };
10232        if self.interactive_colorbar && colorbar_w > 0.0 {
10233            self.ensure_value_histogram();
10234        }
10235
10236        // The image (and histo_h, which must share its width so the x-synced
10237        // pair stays aligned) gets what is left after the side columns AND the
10238        // inter-child gaps `ui.horizontal` inserts — one gap per trailing
10239        // bottom-row child (histo_v, colorbar). Without the gap term the row
10240        // overflowed the window and the colorbar's value labels clipped at the
10241        // window's right edge.
10242        let spacing = ui.spacing().item_spacing.x;
10243        let row_gaps = u32::from(show_histos) + u32::from(colorbar_w > 0.0);
10244        let img_w = row_content_width(avail.x, histo_v_w + colorbar_w, row_gaps, spacing);
10245
10246        // Top row: horizontal histogram on the left, radar overview filling the
10247        // top-right corner (the histoV + colorbar column band above the image).
10248        // Both are skipped when side histograms are hidden.
10249        if show_histos {
10250            ui.horizontal(|ui| {
10251                // The histogram keeps the image's width (aligned above the
10252                // image, x-axes synced); the radar takes the rest minus this
10253                // row's single inter-child gap.
10254                ui.allocate_ui(egui::vec2(img_w, histo_h_h), |ui| {
10255                    self.histo_h.show(ui);
10256                });
10257
10258                // Sync the radar viewport to the image plot's current limits
10259                // before rendering (silx `__setVisibleRectFromPlot`), then draw
10260                // it in the previously empty top-right corner. A drag pans/zooms
10261                // the image plot (silx `plot.setLimits`, RadarView.py:326).
10262                let (xmin, xmax) = self.image_plot.x_limits();
10263                if let Some((ymin, ymax)) = self.image_plot.y_limits(YAxis::Left) {
10264                    self.radar.set_viewport_limits(xmin, xmax, ymin, ymax);
10265                }
10266                let radar = self.radar.ui(
10267                    ui,
10268                    egui::vec2((avail.x - img_w - spacing).max(0.0), histo_h_h),
10269                );
10270                if let Some((rx0, rx1, ry0, ry1)) = radar.dragged_limits {
10271                    self.image_plot.set_limits(rx0, rx1, ry0, ry1, None);
10272                }
10273            });
10274        }
10275
10276        // Bottom row: image + vertical histogram + colorbar side by side.
10277        let img_h = avail.y - histo_h_h;
10278        // New colormap levels from an interactive-colorbar handle drag this frame,
10279        // applied to the image after the row is laid out (single-owner pattern,
10280        // mirroring the radar drag above).
10281        let mut dragged_levels: Option<(f64, f64)> = None;
10282        let response = ui.horizontal(|ui| {
10283            let response = ui
10284                .allocate_ui(egui::vec2(img_w, img_h), |ui| self.image_plot.show(ui))
10285                .inner;
10286            // Vertical histogram (skipped when side histograms hidden).
10287            if show_histos {
10288                ui.allocate_ui(egui::vec2(histo_v_w, img_h), |ui| {
10289                    self.histo_v.show(ui);
10290                });
10291            }
10292            // Colorbar column, synced to the active image's colormap limits.
10293            if colorbar_w > 0.0 {
10294                if self.interactive_colorbar {
10295                    // pyqtgraph-style histogram colorbar with draggable levels.
10296                    // Pin the strip to the image's data-area guides (top/bottom of
10297                    // `transform.area`) so it lines up with the image rather than
10298                    // overshooting into the image's title / axis-label gutters.
10299                    let guides = response.transform.area;
10300                    let bar = crate::widget::histogram_colorbar::HistogramColorBar::new(
10301                        self.colormap.clone(),
10302                    )
10303                    .with_data_range(self.value_range)
10304                    .with_histogram(self.value_histogram.clone())
10305                    .with_levels(self.colormap.vmin, self.colormap.vmax)
10306                    .with_bar_bounds(guides.top(), guides.bottom());
10307                    dragged_levels = bar.ui(ui, egui::vec2(colorbar_w, img_h)).dragged_levels;
10308                } else {
10309                    self.colorbar().ui(ui, egui::vec2(colorbar_w, img_h));
10310                }
10311            }
10312            response
10313        });
10314
10315        // Apply a level drag: update the colormap and re-render the image (silx
10316        // `Colormap.setVRange`). vmin/vmax are GPU uniforms, so this is a colormap
10317        // re-upload, not a recompute of the pixel data.
10318        if let Some((vmin, vmax)) = dragged_levels {
10319            self.colormap.vmin = vmin;
10320            self.colormap.vmax = vmax;
10321            self.image_plot.set_default_colormap(self.colormap.clone());
10322            self.upload_image();
10323        }
10324
10325        let plot_response = response.inner;
10326
10327        // Feed the live cursor (silx sigMouseMoved) into the PositionInfo
10328        // readout, then render it below the image.
10329        if let Some(cursor) = cursor_from_pointer_event(plot_response.pointer_event.as_ref()) {
10330            self.cursor = Some(cursor);
10331        }
10332        self.position_info.ui(ui, self.cursor);
10333
10334        // Mask draw: in MaskDraw mode, route the captured pointer to the mask
10335        // tool (brush paint / erase) and re-upload the masked image. Gated
10336        // strictly on MaskDraw so pan/zoom/select never paint.
10337        self.handle_mask_paint(&plot_response);
10338
10339        // Shape mask tools (rectangle / ellipse / polygon): drive the on-plot
10340        // shape draw and paint its rubber-band preview (silx mask draw modes).
10341        self.handle_mask_shape_draw(ui, &plot_response);
10342
10343        // Brush footprint preview at the cursor (silx pencil shape circle),
10344        // drawn on top of the just-painted mask.
10345        self.draw_brush_preview(ui, &plot_response);
10346
10347        // Profile tool: a drag on the image plot extracts a profile via the
10348        // existing helpers and shows it in the profile window (silx
10349        // _ProfileToolBar, ImageView.py:692-697).
10350        self.handle_profile_drag(&plot_response);
10351        self.profile_window.show(ui.ctx());
10352    }
10353
10354    /// In [`PlotInteractionMode::MaskDraw`], route the captured pointer to the
10355    /// mask tool (its existing brush paint / erase in
10356    /// [`crate::widget::mask_tools::MaskToolsWidget::handle_interaction`]) and
10357    /// re-upload the masked image when the mask changed. Gated strictly on
10358    /// [`image_view_should_paint_mask`] so pan / zoom / select never paint
10359    /// (silx's pencil draw interaction is its own mode).
10360    fn handle_mask_paint(&mut self, plot_response: &PlotResponse) {
10361        let mode = self.image_plot.interaction_mode();
10362        let mask_enabled =
10363            self.mask.width == self.width && self.mask.height == self.height && self.width != 0;
10364        if !image_view_should_paint_mask(mode, mask_enabled) {
10365            return;
10366        }
10367        let before = self.mask.mask.clone();
10368        self.mask.handle_interaction(plot_response);
10369        if self.mask.mask != before {
10370            // Painted this frame: re-upload the active image with the new mask
10371            // applied (masked pixels → NaN).
10372            self.upload_image();
10373        }
10374    }
10375
10376    /// In [`PlotInteractionMode::MaskDraw`] with a shape tool (rectangle /
10377    /// ellipse / polygon) active, drive the on-plot shape draw and paint its
10378    /// rubber-band preview, mirroring silx `MaskToolsWidget._plotDrawEvent` for
10379    /// the shape draw modes. The mask widget owns the draw state machine and the
10380    /// fill; a finished shape masks the current level, after which the active
10381    /// image is re-uploaded with the new mask. The in-progress preview is drawn
10382    /// in the mask overlay color on a foreground layer clipped to the image
10383    /// area (silx draws the mask shape in the overlay color). Gated strictly on
10384    /// [`image_view_should_paint_mask`] so pan / zoom / select never draw.
10385    fn handle_mask_shape_draw(&mut self, ui: &egui::Ui, plot_response: &PlotResponse) {
10386        let mode = self.image_plot.interaction_mode();
10387        let mask_enabled =
10388            self.mask.width == self.width && self.mask.height == self.height && self.width != 0;
10389        if !image_view_should_paint_mask(mode, mask_enabled)
10390            || self.mask.active_tool.draw_mode().is_none()
10391        {
10392            // Not a shape draw: drop any in-progress shape so re-entering a
10393            // shape tool starts fresh.
10394            self.mask.cancel_shape_draw();
10395            return;
10396        }
10397        let before = self.mask.mask.clone();
10398        let event = self.mask.handle_shape_draw(plot_response);
10399        if self.mask.mask != before {
10400            // Shape finished and masked this frame: re-upload the active image
10401            // with the new mask applied (masked pixels → NaN).
10402            self.upload_image();
10403        }
10404        // Paint the in-progress preview (rubber band) on top of the image. The
10405        // render lives on the mask widget (the single owner, shared with the
10406        // standalone `MaskToolsWidget::handle_draw`).
10407        self.mask
10408            .paint_shape_preview(ui, plot_response, event.as_ref());
10409    }
10410
10411    /// In [`PlotInteractionMode::MaskDraw`] with a brush tool active, draw the
10412    /// pencil footprint at the cursor: an unfilled circle of radius
10413    /// `brush_size / 2` (data coords). Mirrors silx
10414    /// `DrawFreeHand.updatePencilShape` (`PlotInteraction.py:1011-1017`,
10415    /// `fill="none"`), shown both while hovering and while painting (silx draws
10416    /// it from `Idle.onMove` and `Select.onMove`). The mask brush paints a disk
10417    /// of `brush_size / 2` cells (siplot masks in data==cell space), so the
10418    /// circle marks the exact footprint. Painted on a foreground layer clipped
10419    /// to the image area.
10420    fn draw_brush_preview(&self, ui: &egui::Ui, plot_response: &PlotResponse) {
10421        let mode = self.image_plot.interaction_mode();
10422        let mask_enabled =
10423            self.mask.width == self.width && self.mask.height == self.height && self.width != 0;
10424        if !image_view_should_paint_mask(mode, mask_enabled) {
10425            return;
10426        }
10427        // The brush footprint render lives on the mask widget (the single owner,
10428        // shared with the standalone `MaskToolsWidget::handle_draw`); it gates on
10429        // the active tool / cursor itself.
10430        self.mask.paint_brush_preview(ui, plot_response);
10431    }
10432
10433    /// Track a profile drag on the image plot and extract the profile on
10434    /// release. The drag start/current pixels are mapped to data-space
10435    /// `(col, row)` via the plot transform, then routed through
10436    /// [`image_view_profile_values`]; the result feeds the profile window.
10437    fn handle_profile_drag(&mut self, plot_response: &PlotResponse) {
10438        if self.profile_mode == ProfileMode::None || self.pixels.is_empty() {
10439            self.profile_drag_start = None;
10440            return;
10441        }
10442        let response = &plot_response.response;
10443        let transform = &plot_response.transform;
10444
10445        if response.drag_started()
10446            && let Some(p) = response.interact_pointer_pos()
10447        {
10448            self.profile_drag_start = Some(transform.pixel_to_data(p));
10449        }
10450
10451        if response.dragged()
10452            && let (Some(start), Some(p)) =
10453                (self.profile_drag_start, response.interact_pointer_pos())
10454        {
10455            let end = transform.pixel_to_data(p);
10456            if let Some(roi) = profile_roi_from_drag(self.profile_mode, start, end) {
10457                // ProfileWindow re-derives the profile from the ROI using the
10458                // same line/row/column helpers (single source of truth).
10459                self.profile_window
10460                    .update_profile(self.width, self.height, &self.pixels, &roi);
10461                self.profile_window.set_open(true);
10462            }
10463        }
10464
10465        if response.drag_stopped() {
10466            self.profile_drag_start = None;
10467        }
10468    }
10469
10470    /// The active profile-extraction mode of the profile tool (silx
10471    /// `_ProfileToolBar`, ImageView.py:692-697).
10472    pub fn profile_mode(&self) -> ProfileMode {
10473        self.profile_mode
10474    }
10475
10476    /// Set the active profile-extraction mode (silx `_ProfileToolBar` action
10477    /// toggle). [`ProfileMode::None`] disables the tool and closes the window.
10478    pub fn set_profile_mode(&mut self, mode: ProfileMode) {
10479        self.profile_mode = mode;
10480        if mode == ProfileMode::None {
10481            self.profile_drag_start = None;
10482            self.profile_window.set_open(false);
10483        }
10484    }
10485
10486    /// Extract the profile for `mode` directly from a drag between data-space
10487    /// `(col, row)` endpoints, without UI (silx `_ProfileToolBar` profile
10488    /// extraction). Returns `(x_axis, y_values)` or `None`.
10489    pub fn profile_values(
10490        &self,
10491        mode: ProfileMode,
10492        start: (f64, f64),
10493        end: (f64, f64),
10494    ) -> Option<(Vec<f64>, Vec<f64>)> {
10495        image_view_profile_values(mode, self.width, self.height, &self.pixels, start, end)
10496    }
10497
10498    /// The radar overview of the full image extent with its draggable viewport
10499    /// (silx `ImageView._radarView`, ImageView.py:486-490).
10500    pub fn radar(&self) -> &crate::widget::radar_view::RadarView {
10501        &self.radar
10502    }
10503
10504    /// The last cursor data coordinates `(x, y)` fed into the PositionInfo
10505    /// readout from a pointer move/click, or `None`.
10506    pub fn cursor(&self) -> Option<[f64; 2]> {
10507        self.cursor
10508    }
10509
10510    /// The position-info readout bound to the live cursor (silx
10511    /// `tools/PositionInfo.PositionInfo`).
10512    pub fn position_info(&self) -> &crate::widget::position_info::PositionInfo {
10513        &self.position_info
10514    }
10515
10516    /// Mutable access to the position-info readout, to add converter columns
10517    /// (silx `PositionInfo(converters=...)`).
10518    pub fn position_info_mut(&mut self) -> &mut crate::widget::position_info::PositionInfo {
10519        &mut self.position_info
10520    }
10521
10522    /// The PositionInfo readout strings at the current live cursor (silx
10523    /// `PositionInfo` value fields). One string per converter column;
10524    /// `"------"` when no cursor has been seen.
10525    pub fn position_info_values(&self) -> Vec<String> {
10526        self.position_info.values(self.cursor)
10527    }
10528
10529    /// Access the main image plot for toolbar/ROI/limit configuration.
10530    pub fn image_plot(&self) -> &Plot2D {
10531        &self.image_plot
10532    }
10533
10534    /// Mutable access to the main image plot.
10535    pub fn image_plot_mut(&mut self) -> &mut Plot2D {
10536        &mut self.image_plot
10537    }
10538
10539    /// The side-histogram profile sum and its index extent for `axis`, mirroring
10540    /// silx `ImageView.getHistogram(axis)` (the `{data, extent}` dict).
10541    /// [`ImageHistogramAxis::X`] returns per-column sums over `[0, width)`,
10542    /// [`ImageHistogramAxis::Y`] per-row sums over `[0, height)` (extent `end`
10543    /// exclusive). Returns `None` before an image is set — silx returns `None`
10544    /// when no histogram has been computed.
10545    pub fn histogram(&self, axis: ImageHistogramAxis) -> Option<ImageProfileHistogram> {
10546        if self.width == 0 || self.height == 0 || self.pixels.is_empty() {
10547            return None;
10548        }
10549        let w = self.width as usize;
10550        let h = self.height as usize;
10551        let (data, extent) = match axis {
10552            ImageHistogramAxis::X => (image_column_sums(&self.pixels, w, h), (0.0, w as f64)),
10553            ImageHistogramAxis::Y => (image_row_sums(&self.pixels, w, h), (0.0, h as f64)),
10554        };
10555        Some(ImageProfileHistogram { data, extent })
10556    }
10557
10558    /// The `(col, row, value)` under the live cursor, as silx
10559    /// `ImageView.valueChanged` emits it (ImageView.py:381, emitted at :601):
10560    /// integer pixel indices returned as floats, with the pixel value at that
10561    /// index. Returns `None` when the cursor is off the image, before an image
10562    /// is set, or before any pointer move — silx emits nothing in those cases.
10563    /// The cursor is updated each frame by [`Self::show`] from the live pointer.
10564    pub fn value_changed(&self) -> Option<(f64, f64, f64)> {
10565        let [x, y] = self.cursor?;
10566        image_value_at(
10567            x,
10568            y,
10569            &self.pixels,
10570            self.width as usize,
10571            self.height as usize,
10572        )
10573    }
10574
10575    fn rebuild_histograms(&mut self) {
10576        if self.width == 0 || self.pixels.is_empty() {
10577            return;
10578        }
10579        let w = self.width as usize;
10580        let h = self.height as usize;
10581
10582        // Column sums: histo_h — x = column index, y = sum of that column.
10583        // Row sums: histo_v — x = sum of that row, y = row index. Computed via
10584        // the shared pure helpers (also serving `ImageView::histogram`); both
10585        // sums are taken before any mutable `self.histo_*` borrow below.
10586        let col_sums = image_column_sums(&self.pixels, w, h);
10587        let row_sums = image_row_sums(&self.pixels, w, h);
10588        let col_x: Vec<f64> = (0..w).map(|i| i as f64).collect();
10589        let row_y: Vec<f64> = (0..h).map(|i| i as f64).collect();
10590
10591        if let Some(h) = self.histo_h_curve {
10592            self.histo_h
10593                .update_curve_data(h, &CurveData::new(col_x, col_sums, Color32::YELLOW));
10594        } else {
10595            let h =
10596                self.histo_h
10597                    .add_curve_with_legend(&col_x, &col_sums, Color32::YELLOW, "col sums");
10598            self.histo_h_curve = Some(h);
10599        }
10600
10601        if let Some(h) = self.histo_v_curve {
10602            self.histo_v
10603                .update_curve_data(h, &CurveData::new(row_sums, row_y, Color32::LIGHT_BLUE));
10604        } else {
10605            let h = self.histo_v.add_curve_with_legend(
10606                &row_sums,
10607                &row_y,
10608                Color32::LIGHT_BLUE,
10609                "row sums",
10610            );
10611            self.histo_v_curve = Some(h);
10612        }
10613    }
10614}
10615
10616// ─── ScatterView ──────────────────────────────────────────────────────────────
10617
10618/// How a [`ScatterView`]'s `(x, y, value)` points are rendered, mirroring silx
10619/// `ScatterVisualizationMixIn.Visualization` (core.py:1252-1295).
10620///
10621/// [`Points`](Self::Points) draws the marker cloud (the default); the three
10622/// grid modes convert the unstructured points into a value image (via the
10623/// matching [`crate::core::scatter_viz`] primitive) rendered through the image
10624/// path; [`Solid`](Self::Solid) renders the per-vertex-colored Delaunay
10625/// triangle surface through the existing CPU triangle (egui `epaint::Mesh`)
10626/// path.
10627#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10628pub enum ScatterVisualization {
10629    /// `Visualization.POINTS`: a marker per point (the existing default).
10630    #[default]
10631    Points,
10632    /// `Visualization.SOLID`: the filled Delaunay triangle surface, each vertex
10633    /// carrying its point's colormap color so the GPU interpolates the fill
10634    /// (silx `scatter.py:610-625` `backend.addTriangles`, GL Gouraud shading).
10635    /// Built via [`crate::core::scatter_viz::solid_triangles`] over the same
10636    /// per-point colormap+alpha colors as [`Points`](Self::Points). When the
10637    /// points cannot be triangulated (fewer than 3 finite points or all
10638    /// collinear) nothing is drawn, matching silx's "Cannot display as solid
10639    /// surface" early-out.
10640    Solid,
10641    /// `Visualization.IRREGULAR_GRID`: the points arranged onto the
10642    /// auto-detected grid and rendered as a flat-shaded quadrilateral triangle
10643    /// mesh — each point owns one cell carrying its colormap color
10644    /// ([`crate::core::scatter_viz::irregular_grid_triangles`], silx
10645    /// `_quadrilateral_grid_as_triangles`). A picked cell maps back to its
10646    /// source point ([`crate::core::scatter_viz::irregular_grid_pick`]).
10647    IrregularGrid,
10648    /// `Visualization.REGULAR_GRID`: the points reshaped onto the auto-detected
10649    /// grid ([`crate::core::scatter_viz::detect_regular_grid`]). Trailing cells
10650    /// not covered by a point are `NaN`.
10651    RegularGrid,
10652    /// `Visualization.BINNED_STATISTIC`: the per-bin mean over a 2D binning
10653    /// ([`crate::core::scatter_viz::binned_statistic`]). Empty bins are `NaN`.
10654    BinnedStatistic,
10655}
10656
10657impl ScatterVisualization {
10658    /// All visualization modes in silx menu order (`items/core.py:1262-1295`),
10659    /// for building a picker.
10660    pub const ALL: [ScatterVisualization; 5] = [
10661        ScatterVisualization::Points,
10662        ScatterVisualization::Solid,
10663        ScatterVisualization::RegularGrid,
10664        ScatterVisualization::IrregularGrid,
10665        ScatterVisualization::BinnedStatistic,
10666    ];
10667
10668    /// Human-readable label matching the silx visualization menu text.
10669    pub fn label(self) -> &'static str {
10670        match self {
10671            ScatterVisualization::Points => "Points",
10672            ScatterVisualization::Solid => "Solid",
10673            ScatterVisualization::RegularGrid => "Regular Grid",
10674            ScatterVisualization::IrregularGrid => "Irregular Grid",
10675            ScatterVisualization::BinnedStatistic => "Binned Statistic",
10676        }
10677    }
10678}
10679
10680/// Reshape `values` onto the auto-detected regular grid for
10681/// [`ScatterVisualization::RegularGrid`], faithful to silx
10682/// `__getRegularGridInfo` + the REGULAR_GRID render branch
10683/// (scatter.py:402-467, 631-680).
10684///
10685/// The grid shape and major order come from
10686/// [`crate::core::scatter_viz::detect_regular_grid`]. Row-major points fill the
10687/// returned grid directly; column-major points are written down columns then
10688/// the grid is logically transposed so the result is always row-major
10689/// `(rows, cols)`. When there are fewer points than cells the trailing cells are
10690/// `NaN` (silx "transparent pixels", scatter.py:648-651). `origin`/`scale` use
10691/// silx's regular-grid placement: `scale = span / max(1, n - 1)` per axis and
10692/// `origin = begin - 0.5 * scale`, with silx's zero-scale fallbacks.
10693///
10694/// Returns `None` when no grid can be guessed (silx logs and skips the image).
10695fn regular_grid_image(x: &[f64], y: &[f64], values: &[f64]) -> Option<GridImage> {
10696    let grid = crate::core::scatter_viz::detect_regular_grid(x, y)?;
10697    let (mut rows, mut cols) = grid.shape;
10698
10699    // silx enlarges the grid when there are more points than cells
10700    // (scatter.py:426-436), keeping the slow dimension and growing the other.
10701    let n = values.len();
10702    if n > rows * cols {
10703        match grid.order {
10704            crate::core::scatter_viz::GridMajorOrder::Row => {
10705                rows = n.div_ceil(cols.max(1));
10706            }
10707            crate::core::scatter_viz::GridMajorOrder::Column => {
10708                cols = n.div_ceil(rows.max(1));
10709            }
10710        }
10711    }
10712    if rows == 0 || cols == 0 {
10713        return None;
10714    }
10715
10716    // silx bounds: per axis, the (min, max) ordered so the first point is
10717    // nearest `begin` (scatter.py:441-447).
10718    let (xb, xe) = grid_axis_bounds(x);
10719    let (yb, ye) = grid_axis_bounds(y);
10720    let mut sx = if cols > 1 {
10721        (xe - xb) / (cols - 1) as f64
10722    } else {
10723        0.0
10724    };
10725    let mut sy = if rows > 1 {
10726        (ye - yb) / (rows - 1) as f64
10727    } else {
10728        0.0
10729    };
10730    // silx zero-scale fallbacks (scatter.py:454-459).
10731    match (sx == 0.0, sy == 0.0) {
10732        (true, true) => {
10733            sx = 1.0;
10734            sy = 1.0;
10735        }
10736        (true, false) => sx = sy,
10737        (false, true) => sy = sx,
10738        (false, false) => {}
10739    }
10740    let origin = (xb - 0.5 * sx, yb - 0.5 * sy);
10741
10742    // Reshape the values into the row-major (rows, cols) grid. Row-major order
10743    // fills rows directly; column-major fills down columns (silx transpose,
10744    // scatter.py:637-663). Trailing cells beyond the point count stay NaN.
10745    let mut data = vec![f64::NAN; rows * cols];
10746    match grid.order {
10747        crate::core::scatter_viz::GridMajorOrder::Row => {
10748            for (i, &v) in values.iter().enumerate().take(rows * cols) {
10749                data[i] = v;
10750            }
10751        }
10752        crate::core::scatter_viz::GridMajorOrder::Column => {
10753            // Points fill column-major: point i goes to (row i % rows, col i / rows).
10754            for (i, &v) in values.iter().enumerate().take(rows * cols) {
10755                let r = i % rows;
10756                let c = i / rows;
10757                data[r * cols + c] = v;
10758            }
10759        }
10760    }
10761
10762    Some(GridImage {
10763        data,
10764        shape: (rows, cols),
10765        origin,
10766        scale: (sx, sy),
10767    })
10768}
10769
10770/// Per-axis grid bounds `(begin, end)` for [`regular_grid_image`], ordered so the
10771/// first sample is nearest `begin` (silx scatter.py:443-446). Falls back to
10772/// `(0, 0)` for an empty/all-non-finite axis.
10773fn grid_axis_bounds(coord: &[f64]) -> (f64, f64) {
10774    let mut min = f64::INFINITY;
10775    let mut max = f64::NEG_INFINITY;
10776    for &v in coord {
10777        if v.is_finite() {
10778            min = min.min(v);
10779            max = max.max(v);
10780        }
10781    }
10782    if min > max {
10783        return (0.0, 0.0);
10784    }
10785    let first = coord.first().copied().unwrap_or(min);
10786    if (first - min) <= (max - first) {
10787        (min, max)
10788    } else {
10789        (max, min)
10790    }
10791}
10792
10793/// Convert a [`BinnedStatistic`]'s per-bin mean into a [`GridImage`] for
10794/// [`ScatterVisualization::BinnedStatistic`] (silx renders the BINNED_STATISTIC
10795/// reduction as a colormapped image; empty bins are `NaN`).
10796fn binned_statistic_image(bs: &crate::core::scatter_viz::BinnedStatistic) -> GridImage {
10797    GridImage {
10798        data: bs.select(crate::core::scatter_viz::BinnedStatisticFunction::Mean),
10799        shape: bs.shape,
10800        origin: bs.origin,
10801        scale: bs.scale,
10802    }
10803}
10804
10805/// Convert a [`ScatterView`]'s `(x, y, value)` points into the [`GridImage`] for
10806/// a grid visualization `mode`, dispatching to the matching
10807/// [`crate::core::scatter_viz`] primitive (silx scatter.py render branches).
10808///
10809/// `resolution` is the target `(rows, cols)` for the resolution-driven
10810/// [`ScatterVisualization::BinnedStatistic`] mode; it is ignored by
10811/// [`ScatterVisualization::RegularGrid`], whose shape is auto-detected.
10812/// [`ScatterVisualization::IrregularGrid`] no longer renders as an image — it
10813/// goes through the triangle-mesh path ([`crate::core::scatter_viz::irregular_grid_triangles`]).
10814///
10815/// Returns `None` for [`ScatterVisualization::Points`] (no image) and when the
10816/// chosen primitive cannot produce a grid (e.g. un-triangulable points, no
10817/// guessable grid, or empty data). Split out so the per-mode conversion is
10818/// unit-testable without a GPU backend.
10819fn scatter_grid_image(
10820    mode: ScatterVisualization,
10821    x: &[f64],
10822    y: &[f64],
10823    values: &[f64],
10824    resolution: (usize, usize),
10825) -> Option<GridImage> {
10826    let (rows, cols) = resolution;
10827    match mode {
10828        // Points, SOLID, and IRREGULAR_GRID render through the CPU triangle path
10829        // (marker cloud / `addTriangles`), not the image path — only the two
10830        // genuinely image-like grid modes produce a grid image here.
10831        ScatterVisualization::Points
10832        | ScatterVisualization::Solid
10833        | ScatterVisualization::IrregularGrid => None,
10834        ScatterVisualization::RegularGrid => regular_grid_image(x, y, values),
10835        ScatterVisualization::BinnedStatistic => {
10836            crate::core::scatter_viz::binned_statistic(x, y, values, rows, cols)
10837                .as_ref()
10838                .map(binned_statistic_image)
10839        }
10840    }
10841}
10842
10843/// A scatter point picked under the cursor — the result of silx
10844/// `ScatterView._pickScatterData`: the data `index` and its `(x, y)`
10845/// coordinates and `value`.
10846#[derive(Debug, Clone, Copy, PartialEq)]
10847pub struct ScatterPick {
10848    /// Index of the point in the scatter's data arrays.
10849    pub index: usize,
10850    /// The point's X coordinate.
10851    pub x: f64,
10852    /// The point's Y coordinate.
10853    pub y: f64,
10854    /// The point's value (the colormapped scalar).
10855    pub value: f64,
10856}
10857
10858/// Pixel snap radius for the scatter position-info pick (silx picks points
10859/// whose symbol overlaps the cursor); sized to the default marker symbol.
10860const SCATTER_PICK_RADIUS_PX: f32 = crate::core::marker::DEFAULT_MARKER_SIZE;
10861
10862/// Index of the scatter point nearest `cursor` in pixel space within `radius`
10863/// pixels, or `None` if none is close enough — the pure core of silx
10864/// `ScatterView._pickScatterData`. `points` are the per-point pixel positions
10865/// `(px, py)` (project the data points through the display transform first).
10866/// Distance ties resolve to the highest index (silx top-most = last-drawn
10867/// point).
10868pub fn scatter_pick_pixels(
10869    cursor: (f32, f32),
10870    points: &[(f32, f32)],
10871    radius: f32,
10872) -> Option<usize> {
10873    let r2 = radius * radius;
10874    let mut best: Option<(usize, f32)> = None;
10875    for (i, &(px, py)) in points.iter().enumerate() {
10876        let d2 = (px - cursor.0).powi(2) + (py - cursor.1).powi(2);
10877        if d2 > r2 {
10878            continue;
10879        }
10880        // `<=` so a later (higher) index at an equal distance wins the tie.
10881        let take = match best {
10882            None => true,
10883            Some((_, best_d2)) => d2 <= best_d2,
10884        };
10885        if take {
10886            best = Some((i, d2));
10887        }
10888    }
10889    best.map(|(i, _)| i)
10890}
10891
10892/// Among `candidates` (scatter indices in a picked
10893/// [`ScatterVisualization::BinnedStatistic`] bin), the index whose data point is
10894/// nearest `(cx, cy)` in data space, ties resolving to the highest index — the
10895/// pure core of silx `ScatterView._pickScatterData`'s BINNED_STATISTIC branch
10896/// (`selected = indices[::-1]; argmin(...)`, ScatterView.py:197-204). Returns
10897/// `None` for an empty candidate set.
10898fn nearest_candidate_in_data(
10899    candidates: &[usize],
10900    xs: &[f64],
10901    ys: &[f64],
10902    cx: f64,
10903    cy: f64,
10904) -> Option<usize> {
10905    let mut best: Option<(usize, f64)> = None;
10906    for &i in candidates {
10907        let d2 = (xs[i] - cx).powi(2) + (ys[i] - cy).powi(2);
10908        // `<=` so a later (higher) candidate index at an equal distance wins the
10909        // tie, matching silx's reversed-order `argmin` (highest index first).
10910        let take = match best {
10911            None => true,
10912            Some((_, best_d2)) => d2 <= best_d2,
10913        };
10914        if take {
10915            best = Some((i, d2));
10916        }
10917    }
10918    best.map(|(i, _)| i)
10919}
10920
10921/// Build the silx `ScatterView` position-info bar — the `X`, `Y`, `Data`,
10922/// `Index` columns (ScatterView.py:90-101). When a scatter point is picked,
10923/// `X`/`Y` snap to it and `Data`/`Index` show its value/index; otherwise `X`/`Y`
10924/// show the bare cursor coordinates (`%.7g`) and `Data`/`Index` show `"-"`
10925/// (silx `_getPickedValue`/`_getPickedIndex` fallback).
10926pub fn scatter_position_info(
10927    pick: Option<ScatterPick>,
10928) -> crate::widget::position_info::PositionInfo {
10929    use crate::widget::position_info::{Converter, PositionInfo, format_value};
10930    let columns: Vec<(String, Converter)> = vec![
10931        (
10932            "X".to_owned(),
10933            Box::new(move |x, _| match pick {
10934                Some(p) => format_value(p.x),
10935                None => format_value(x),
10936            }),
10937        ),
10938        (
10939            "Y".to_owned(),
10940            Box::new(move |_, y| match pick {
10941                Some(p) => format_value(p.y),
10942                None => format_value(y),
10943            }),
10944        ),
10945        (
10946            "Data".to_owned(),
10947            Box::new(move |_, _| match pick {
10948                Some(p) => format_value(p.value),
10949                None => "-".to_owned(),
10950            }),
10951        ),
10952        (
10953            "Index".to_owned(),
10954            Box::new(move |_, _| match pick {
10955                Some(p) => p.index.to_string(),
10956                None => "-".to_owned(),
10957            }),
10958        ),
10959    ];
10960    PositionInfo::new(columns)
10961}
10962
10963/// A scatter plot where marker colours are driven by a per-point value array
10964/// mapped through a [`Colormap`], mirroring silx `ScatterView`.
10965///
10966/// ```ignore
10967/// let mut sv = ScatterView::new(render_state, 0);
10968/// sv.set_data(&x, &y, &values, Colormap::viridis(0.0, 10.0))?;
10969/// // frame loop
10970/// sv.show_toolbar(ui);
10971/// sv.show(ui);
10972/// ```
10973/// Samples per scatter line profile (silx `_DefaultScatterProfileRoiMixIn`'s
10974/// default `__nPoints = 1024`, `tools/profile/rois.py`).
10975const SCATTER_PROFILE_NPOINTS: usize = 1024;
10976
10977pub struct ScatterView {
10978    inner: PlotWidget,
10979    scatter_handle: Option<ItemHandle>,
10980    /// Handle of the grid image rendered for a non-[`Points`] visualization
10981    /// (silx scatter grid render branches). `None` in `Points` mode.
10982    ///
10983    /// [`Points`]: ScatterVisualization::Points
10984    grid_handle: Option<ItemHandle>,
10985    /// Handle of the per-vertex-colored triangle mesh rendered for
10986    /// [`ScatterVisualization::Solid`] or [`ScatterVisualization::IrregularGrid`]
10987    /// (silx `backend.addTriangles`). `None` outside those modes and when the
10988    /// points cannot be triangulated / arranged onto a grid.
10989    triangles_handle: Option<ItemHandle>,
10990    /// The IRREGULAR_GRID triangle mesh retained for picking (silx
10991    /// `Scatter.pick` IRREGULAR_GRID branch maps a picked triangle to its source
10992    /// point). `Some` only while [`ScatterVisualization::IrregularGrid`] is
10993    /// displayed with a buildable grid; cleared on every other rebuild.
10994    irregular_grid_mesh: Option<Triangles>,
10995    /// Colormap that maps the per-point `values` to marker colors, retained so
10996    /// the side colorbar (silx `ScatterView` `getColorBarWidget`,
10997    /// ScatterView.py:83-88) reflects the current value limits. `None` until
10998    /// [`Self::set_data`] has been called.
10999    colormap: Option<Colormap>,
11000    /// Retained `(x, y, values)` of the last [`Self::set_data`], so a
11001    /// visualization-mode change can rebuild the grid image without re-supplying
11002    /// the data (silx caches the scatter data on the item).
11003    points: Option<(Vec<f64>, Vec<f64>, Vec<f64>)>,
11004    /// Active visualization mode (silx `Scatter.getVisualization`).
11005    visualization: ScatterVisualization,
11006    /// Target grid resolution `(rows, cols)` for the resolution-driven grid
11007    /// modes (silx `GRID_SHAPE` / `BINNED_STATISTIC_SHAPE`, default 100×100,
11008    /// scatter.py:476).
11009    grid_resolution: (usize, usize),
11010    /// Per-point scatter mask, sized to the point count on [`Self::set_data`]
11011    /// (silx `ScatterView` `ScatterMaskToolsWidget`, ScatterView.py:116-122).
11012    /// Its non-zero levels flag the masked-point selection.
11013    mask: crate::widget::scatter_mask::ScatterMaskWidget,
11014    /// Whether the side colorbar column is shown (silx `ColorBarAction`,
11015    /// ScatterView's `ColorBarWidget` visibility). Defaults to `true`; even when
11016    /// `true` the column is only reserved once data with a colormap exists.
11017    show_colorbar: bool,
11018    /// Optional per-point alpha in `[0, 1]` (silx `Scatter` `alpha` array,
11019    /// scatter.py:1051-1060). When set, it scales each point's colormap RGBA
11020    /// alpha in the `Points` visualization (silx
11021    /// `__applyColormapToData`); `None` leaves the colormap alpha untouched.
11022    alpha: Option<Vec<f64>>,
11023    /// Last cursor data coordinates fed into the position-info readout (silx
11024    /// `ScatterView._positionInfo`, updated on `sigMouseMoved`); `None` until the
11025    /// pointer has moved over the data area.
11026    cursor: Option<[f64; 2]>,
11027    /// Side window showing the 1D line profile sampled across the scatter (silx
11028    /// `ScatterProfileToolBar`'s profile window). Fed by [`Self::show_line_profile`]
11029    /// and drawn by [`Self::show`] when open.
11030    profile_window: crate::widget::profile_window::ProfileWindow,
11031    /// Whether the interactive line-profile tool is armed (silx
11032    /// `ScatterProfileToolBar` line-ROI tool). While armed, a primary drag on the
11033    /// scatter samples a line profile between its endpoints into
11034    /// [`Self::profile_window`].
11035    profile_mode: bool,
11036    /// Data-space start of the in-progress profile drag, or `None` (silx profile
11037    /// ROI first point). Set on `drag_started`, cleared on `drag_stopped`.
11038    profile_drag_start: Option<(f64, f64)>,
11039}
11040
11041impl ScatterView {
11042    /// Create a new scatter-view widget.
11043    ///
11044    /// Reserves two plot ids: `id` for the scatter plot and `id + 1` for the
11045    /// line-profile side window (mirroring `Plot2D`, which reserves a small id
11046    /// range for its profile window).
11047    pub fn new(render_state: &RenderState, id: PlotId) -> Self {
11048        let mut inner = PlotWidget::new(render_state, id);
11049        inner.set_graph_cursor(true);
11050        Self {
11051            inner,
11052            scatter_handle: None,
11053            grid_handle: None,
11054            triangles_handle: None,
11055            irregular_grid_mesh: None,
11056            colormap: None,
11057            points: None,
11058            visualization: ScatterVisualization::Points,
11059            grid_resolution: (100, 100),
11060            mask: crate::widget::scatter_mask::ScatterMaskWidget::new(0),
11061            show_colorbar: true,
11062            alpha: None,
11063            cursor: None,
11064            profile_window: crate::widget::profile_window::ProfileWindow::new(render_state, id + 1),
11065            profile_mode: false,
11066            profile_drag_start: None,
11067        }
11068    }
11069
11070    /// Whether the side colorbar column is shown (silx `ColorBarAction`).
11071    pub fn show_colorbar(&self) -> bool {
11072        self.show_colorbar
11073    }
11074
11075    /// Show or hide the side colorbar column (silx `ColorBarAction`). When
11076    /// hidden, [`Self::show`] does not reserve the colorbar column and the
11077    /// scatter plot fills the freed width.
11078    pub fn set_show_colorbar(&mut self, show: bool) {
11079        self.show_colorbar = show;
11080    }
11081
11082    /// The retained per-point alpha array in `[0, 1]` (silx `Scatter` `alpha`),
11083    /// or `None` when no per-point alpha is set.
11084    pub fn alpha(&self) -> Option<&[f64]> {
11085        self.alpha.as_deref()
11086    }
11087
11088    /// Set the per-point alpha array (silx `Scatter.setData(alpha=...)`,
11089    /// scatter.py:1051-1060), consumed at construction. Each entry is clamped to
11090    /// `[0, 1]`; the array should have one entry per point (the silx contract),
11091    /// but a length mismatch does not panic — see `compose_per_point_alpha`.
11092    /// In [`ScatterVisualization::Points`] mode each point's colormap RGBA alpha
11093    /// is multiplied by its per-point alpha, with the curve's global alpha
11094    /// multiplying on top in-shader (silx three-stage `colormap.alpha *
11095    /// per_point.alpha * global.alpha`).
11096    #[must_use]
11097    pub fn with_alpha(mut self, alpha: Vec<f64>) -> Self {
11098        self.alpha = Some(clamp_alpha(alpha));
11099        self
11100    }
11101
11102    /// Set the per-point alpha array and re-render the current visualization
11103    /// (silx `Scatter.setData(alpha=...)`). Each entry is clamped to `[0, 1]`.
11104    /// See [`Self::with_alpha`] for the three-stage alpha composition.
11105    pub fn set_alpha(&mut self, alpha: Vec<f64>) {
11106        self.alpha = Some(clamp_alpha(alpha));
11107        self.rebuild_visualization();
11108    }
11109
11110    /// Clear any per-point alpha (silx `Scatter.setData(alpha=None)`) and
11111    /// re-render, restoring the unscaled colormap RGBA alpha.
11112    pub fn clear_alpha(&mut self) {
11113        self.alpha = None;
11114        self.rebuild_visualization();
11115    }
11116
11117    /// Upload data.  `values` drives point colours through `colormap`.
11118    ///
11119    /// All three slices must have equal length; returns [`PlotDataError`] otherwise.
11120    pub fn set_data(
11121        &mut self,
11122        x: &[f64],
11123        y: &[f64],
11124        values: &[f64],
11125        colormap: Colormap,
11126    ) -> Result<(), PlotDataError> {
11127        if x.len() != y.len() || x.len() != values.len() {
11128            return Err(PlotDataError::ImageDataLength {
11129                expected: x.len(),
11130                actual: if y.len() != x.len() {
11131                    y.len()
11132                } else {
11133                    values.len()
11134                },
11135            });
11136        }
11137
11138        // Resize the scatter mask to the new point count, resetting the mask
11139        // and its undo history (silx `ScatterMask.reset(shape)`). A length
11140        // change clears any prior selection.
11141        if self.mask.len() != x.len() {
11142            self.mask.reset_len(x.len());
11143        }
11144        self.points = Some((x.to_vec(), y.to_vec(), values.to_vec()));
11145        self.colormap = Some(colormap);
11146        self.rebuild_visualization();
11147        Ok(())
11148    }
11149
11150    /// The active scatter visualization mode (silx `Scatter.getVisualization`).
11151    pub fn visualization(&self) -> ScatterVisualization {
11152        self.visualization
11153    }
11154
11155    /// Extract a line profile across the scatter data (silx
11156    /// `ScatterProfileToolBar` / `_computeProfile`, `tools/profile/rois.py:737`).
11157    ///
11158    /// Samples `n_points` evenly along `start`..`end` and interpolates each
11159    /// through the scatter's Delaunay mesh (silx `LinearNDInterpolator`, via
11160    /// [`crate::core::scatter_viz::scatter_line_profile`]). Returns the sampled
11161    /// [`ScatterLineProfile`] (positions + interpolated values), or `None` when
11162    /// no data has been set or every sample lies outside the convex hull (silx
11163    /// `_computeProfile` returns `None` when nothing is finite). The interactive
11164    /// line-ROI tool and the side profile plot are not wired (GPU/UI).
11165    pub fn line_profile(
11166        &self,
11167        start: (f64, f64),
11168        end: (f64, f64),
11169        n_points: usize,
11170    ) -> Option<ScatterLineProfile> {
11171        let (x, y, values) = self.points.as_ref()?;
11172        let profile =
11173            crate::core::scatter_viz::scatter_line_profile(x, y, values, start, end, n_points);
11174        if profile.values.iter().all(Option::is_none) {
11175            return None;
11176        }
11177        Some(profile)
11178    }
11179
11180    /// Sample the line profile `start`..`end` (with `n_points` samples) across the
11181    /// scatter and show it in the side profile window as a value-vs-distance curve
11182    /// (silx `ScatterProfileToolBar`: draw a profile line ROI → the profile window
11183    /// plots the interpolated profile). Opens the window and returns `true` when a
11184    /// profile was produced; returns `false` (leaving the window untouched) when
11185    /// there is no data, fewer than 3 non-collinear points, or the whole segment
11186    /// falls outside the scatter's convex hull (see [`Self::line_profile`]).
11187    pub fn show_line_profile(
11188        &mut self,
11189        start: (f64, f64),
11190        end: (f64, f64),
11191        n_points: usize,
11192    ) -> bool {
11193        let Some(profile) = self.line_profile(start, end, n_points) else {
11194            return false;
11195        };
11196        let (distance, value) = profile.distance_value_curve();
11197        // matplotlib C0 blue, silx's default first-curve color.
11198        self.profile_window.set_profile_curve(
11199            "Profile",
11200            Color32::from_rgb(31, 119, 180),
11201            distance,
11202            value,
11203        );
11204        self.profile_window.set_open(true);
11205        true
11206    }
11207
11208    /// The line-profile side window (silx `ScatterProfileToolBar` profile window),
11209    /// fed by [`Self::show_line_profile`].
11210    pub fn profile_window(&self) -> &crate::widget::profile_window::ProfileWindow {
11211        &self.profile_window
11212    }
11213
11214    /// Mutable access to the line-profile side window (e.g. to set its band width
11215    /// / reduction method or to close it).
11216    pub fn profile_window_mut(&mut self) -> &mut crate::widget::profile_window::ProfileWindow {
11217        &mut self.profile_window
11218    }
11219
11220    /// Whether the interactive line-profile tool is armed (silx
11221    /// `ScatterProfileToolBar`).
11222    pub fn profile_mode(&self) -> bool {
11223        self.profile_mode
11224    }
11225
11226    /// Arm or disarm the interactive line-profile tool (silx
11227    /// `ScatterProfileToolBar`'s profile ROI). While armed, a primary drag across
11228    /// the scatter samples a line profile between its data-space endpoints and
11229    /// shows it in the side window (see [`Self::show_line_profile`]).
11230    ///
11231    /// Arming switches the plot to [`PlotInteractionMode::Select`] so the drag
11232    /// neither pans nor box-zooms (and, with no ROI under the cursor, nothing else
11233    /// consumes it); disarming restores the default [`PlotInteractionMode::Zoom`],
11234    /// drops any in-progress drag, and closes the profile window (silx clears the
11235    /// profile when its tool is deselected).
11236    pub fn set_profile_mode(&mut self, enabled: bool) {
11237        if self.profile_mode == enabled {
11238            return;
11239        }
11240        self.profile_mode = enabled;
11241        if enabled {
11242            self.inner.set_interaction_mode(PlotInteractionMode::Select);
11243        } else {
11244            self.inner.set_interaction_mode(PlotInteractionMode::Zoom);
11245            self.profile_drag_start = None;
11246            self.profile_window.set_open(false);
11247        }
11248    }
11249
11250    /// Track a profile drag on the scatter plot and sample the line profile on
11251    /// each dragged frame, mirroring `Plot2D::handle_profile_drag`. The drag
11252    /// start/current pixels are mapped to data space via the plot transform and
11253    /// fed to [`Self::show_line_profile`]; a no-op when the tool is disarmed or
11254    /// there is no data.
11255    fn handle_profile_drag(&mut self, plot_response: &PlotResponse) {
11256        if !self.profile_mode || self.points.is_none() {
11257            self.profile_drag_start = None;
11258            return;
11259        }
11260        let response = &plot_response.response;
11261        let transform = &plot_response.transform;
11262
11263        if response.drag_started()
11264            && let Some(p) = response.interact_pointer_pos()
11265        {
11266            self.profile_drag_start = Some(transform.pixel_to_data(p));
11267        }
11268
11269        if response.dragged()
11270            && let (Some(start), Some(p)) =
11271                (self.profile_drag_start, response.interact_pointer_pos())
11272        {
11273            let end = transform.pixel_to_data(p);
11274            self.show_line_profile(start, end, SCATTER_PROFILE_NPOINTS);
11275        }
11276
11277        if response.drag_stopped() {
11278            self.profile_drag_start = None;
11279        }
11280    }
11281
11282    /// Set the scatter visualization mode (silx `Scatter.setVisualization`).
11283    ///
11284    /// [`ScatterVisualization::Points`] shows the marker cloud;
11285    /// [`ScatterVisualization::Solid`] renders the per-vertex-colored Delaunay
11286    /// triangle surface (built via
11287    /// [`crate::core::scatter_viz::solid_triangles`]); the grid modes render the
11288    /// retained `(x, y, value)` points as a colormapped image (built via
11289    /// `scatter_grid_image`). Re-renders immediately against the data from the
11290    /// last [`Self::set_data`].
11291    pub fn set_visualization(&mut self, mode: ScatterVisualization) {
11292        if self.visualization == mode {
11293            return;
11294        }
11295        self.visualization = mode;
11296        self.rebuild_visualization();
11297    }
11298
11299    /// The target grid resolution `(rows, cols)` used by the resolution-driven
11300    /// grid modes (silx `GRID_SHAPE` / `BINNED_STATISTIC_SHAPE`).
11301    pub fn grid_resolution(&self) -> (usize, usize) {
11302        self.grid_resolution
11303    }
11304
11305    /// Set the target grid resolution `(rows, cols)` for the resolution-driven
11306    /// [`ScatterVisualization::BinnedStatistic`] mode; ignored by
11307    /// [`ScatterVisualization::RegularGrid`] (auto-detected) and
11308    /// [`ScatterVisualization::IrregularGrid`] (triangle mesh). Re-renders the
11309    /// current visualization.
11310    pub fn set_grid_resolution(&mut self, rows: usize, cols: usize) {
11311        self.grid_resolution = (rows, cols);
11312        self.rebuild_visualization();
11313    }
11314
11315    /// The grid image produced for the current visualization mode from the
11316    /// retained points, or `None` for the non-image modes
11317    /// ([`ScatterVisualization::Points`], [`ScatterVisualization::Solid`], and
11318    /// [`ScatterVisualization::IrregularGrid`], which render through the marker
11319    /// cloud / triangle-mesh paths) / before any data is uploaded / when the
11320    /// points cannot form a grid.
11321    ///
11322    /// Exposed so callers (and tests) can inspect the converted grid that
11323    /// [`Self::show`] renders through the image path.
11324    pub fn grid_image(&self) -> Option<GridImage> {
11325        let (x, y, values) = self.points.as_ref()?;
11326        scatter_grid_image(self.visualization, x, y, values, self.grid_resolution)
11327    }
11328
11329    /// Render the retained points under the current visualization mode through
11330    /// the appropriate backend path: the marker cloud for
11331    /// [`ScatterVisualization::Points`], a per-vertex-colored triangle mesh for
11332    /// [`ScatterVisualization::Solid`] (Delaunay) and
11333    /// [`ScatterVisualization::IrregularGrid`] (quadrilateral grid), otherwise
11334    /// the converted grid image.
11335    ///
11336    /// Single owner of the scatter/grid/triangles item handles (and the
11337    /// IRREGULAR_GRID pick mesh) so the displayed item always matches
11338    /// `self.visualization`. The non-active paths' items are removed so they
11339    /// never overlap.
11340    fn rebuild_visualization(&mut self) {
11341        let Some((x, y, values)) = self.points.clone() else {
11342            return;
11343        };
11344        let Some(colormap) = self.colormap.clone() else {
11345            return;
11346        };
11347
11348        // Single owner of the IRREGULAR_GRID pick mesh: cleared on every
11349        // rebuild, set only by the IrregularGrid arm below.
11350        self.irregular_grid_mesh = None;
11351
11352        match self.visualization {
11353            ScatterVisualization::Points => {
11354                // Drop any grid image / triangle surface so neither shadows the
11355                // markers (single owner: only the active arm keeps its handle).
11356                if let Some(h) = self.grid_handle.take() {
11357                    self.inner.remove(h);
11358                }
11359                if let Some(h) = self.triangles_handle.take() {
11360                    self.inner.remove(h);
11361                }
11362                // Stage 1+2 of the silx three-stage alpha: per-point colormap
11363                // RGBA scaled by the per-point alpha (silx shares
11364                // `__applyColormapToData` between POINTS and SOLID — see the
11365                // Solid arm below). The curve's global alpha (CurveSpec.alpha)
11366                // multiplies on top in-shader for stage 3.
11367                let colors = point_colors(&values, &colormap, self.alpha.as_deref());
11368
11369                let mut spec = CurveSpec::new(&x, &y, Color32::WHITE);
11370                spec.color = crate::core::backend::CurveColor::PerVertex(&colors);
11371                spec.line_style = LineStyle::None;
11372                spec.symbol = Some(crate::core::items::Symbol::Circle);
11373                spec.symbol_size = 6.0;
11374
11375                if let Some(h) = self.scatter_handle {
11376                    self.inner.update_curve_spec(h, spec);
11377                } else {
11378                    let h = self.inner.add_curve_spec(spec);
11379                    self.scatter_handle = Some(h);
11380                    self.inner.set_item_legend(h, "scatter");
11381                }
11382            }
11383            ScatterVisualization::Solid => {
11384                // Drop the marker cloud / grid image so neither shadows the
11385                // triangle surface (single owner: only the active arm keeps its
11386                // handle).
11387                if let Some(h) = self.scatter_handle.take() {
11388                    self.inner.remove(h);
11389                }
11390                if let Some(h) = self.grid_handle.take() {
11391                    self.inner.remove(h);
11392                }
11393                // Same per-point colormap+alpha colors as the Points arm: silx
11394                // shares `__applyColormapToData` between POINTS and SOLID
11395                // (scatter.py:526-535, 612-629), then hands the per-vertex RGBA
11396                // to `backend.addTriangles` for GL Gouraud interpolation.
11397                let colors = point_colors(&values, &colormap, self.alpha.as_deref());
11398
11399                // Build the Delaunay triangle surface (silx
11400                // `scatter_viz::solid_triangles`). `None` for degenerate input
11401                // (fewer than 3 finite points or all collinear) matches silx's
11402                // "Cannot display as solid surface" early-out: nothing is drawn.
11403                let Some(tri) = crate::core::scatter_viz::solid_triangles(&x, &y, &colors) else {
11404                    if let Some(h) = self.triangles_handle.take() {
11405                        self.inner.remove(h);
11406                    }
11407                    return;
11408                };
11409
11410                // No backend update_triangles primitive exists, so re-add the
11411                // mesh from scratch on each rebuild (remove the prior handle
11412                // first). `triangles_handle` is Some iff a mesh is displayed.
11413                if let Some(h) = self.triangles_handle.take() {
11414                    self.inner.remove(h);
11415                }
11416                let h = self.inner.add_triangles_data(&tri);
11417                self.triangles_handle = Some(h);
11418                self.inner.set_item_legend(h, "scatter solid");
11419            }
11420            ScatterVisualization::IrregularGrid => {
11421                // Drop the marker cloud / grid image so neither shadows the
11422                // triangle mesh (single owner: only the active arm keeps its
11423                // handle).
11424                if let Some(h) = self.scatter_handle.take() {
11425                    self.inner.remove(h);
11426                }
11427                if let Some(h) = self.grid_handle.take() {
11428                    self.inner.remove(h);
11429                }
11430                // Same per-point colormap+alpha colors as Points/Solid; silx
11431                // flat-shades each grid cell with its point's color
11432                // (scatter.py:788-797, `gridcolors[first::4]`).
11433                let colors = point_colors(&values, &colormap, self.alpha.as_deref());
11434
11435                // Build the quadrilateral-grid triangle mesh (silx
11436                // `_quadrilateral_grid_as_triangles`). `None` when the points do
11437                // not form a guessable grid (or fewer than two): nothing is
11438                // drawn, matching silx returning no item.
11439                let Some(tri) = crate::core::scatter_viz::irregular_grid_triangles(&x, &y, &colors)
11440                else {
11441                    if let Some(h) = self.triangles_handle.take() {
11442                        self.inner.remove(h);
11443                    }
11444                    return;
11445                };
11446
11447                // No backend update_triangles primitive, so re-add the mesh from
11448                // scratch each rebuild (remove the prior handle first).
11449                // `triangles_handle` is Some iff a mesh is displayed;
11450                // `irregular_grid_mesh` retains the geometry for `//4` picking.
11451                if let Some(h) = self.triangles_handle.take() {
11452                    self.inner.remove(h);
11453                }
11454                let h = self.inner.add_triangles_data(&tri);
11455                self.triangles_handle = Some(h);
11456                self.inner.set_item_legend(h, "scatter irregular grid");
11457                self.irregular_grid_mesh = Some(tri);
11458            }
11459            mode => {
11460                // Drop the marker cloud / triangle surface so neither shadows
11461                // the grid image (single owner: only the active arm keeps its
11462                // handle).
11463                if let Some(h) = self.scatter_handle.take() {
11464                    self.inner.remove(h);
11465                }
11466                if let Some(h) = self.triangles_handle.take() {
11467                    self.inner.remove(h);
11468                }
11469                let Some(grid) = scatter_grid_image(mode, &x, &y, &values, self.grid_resolution)
11470                else {
11471                    // No grid (un-triangulable / no guess / empty): clear any
11472                    // stale image so nothing is shown for this mode.
11473                    if let Some(h) = self.grid_handle.take() {
11474                        self.inner.remove(h);
11475                    }
11476                    return;
11477                };
11478                let pixels: Vec<f32> = grid.data.iter().map(|&v| v as f32).collect();
11479                let geometry = ImageGeometry {
11480                    origin: grid.origin,
11481                    scale: grid.scale,
11482                    alpha: 1.0,
11483                };
11484                let mut spec =
11485                    ImageSpec::scalar(grid.shape.1 as u32, grid.shape.0 as u32, &pixels, colormap);
11486                spec.origin = geometry.origin;
11487                spec.scale = geometry.scale;
11488                spec.alpha = geometry.alpha;
11489
11490                if let Some(h) = self.grid_handle {
11491                    self.inner.update_image_spec(h, spec);
11492                } else {
11493                    let h = self.inner.add_image_spec(spec);
11494                    self.grid_handle = Some(h);
11495                    self.inner.set_item_legend(h, "scatter grid");
11496                }
11497            }
11498        }
11499    }
11500
11501    /// The value colormap that drives the marker colors, retained from the last
11502    /// [`Self::set_data`] (silx `ScatterView.getColormap`). `None` before any
11503    /// data has been uploaded.
11504    pub fn colormap(&self) -> Option<&Colormap> {
11505        self.colormap.as_ref()
11506    }
11507
11508    /// A [`ColorBarWidget`] for the scatter's value colormap, used by
11509    /// [`Self::show`] to render the side colorbar (silx
11510    /// `ScatterView.getColorBarWidget`, ScatterView.py:83-88). The bar's value
11511    /// limits track the colormap's `vmin`/`vmax`. Returns `None` before any data
11512    /// has been uploaded.
11513    ///
11514    /// [`ColorBarWidget`]: crate::widget::colorbar::ColorBarWidget
11515    pub fn colorbar(&self) -> Option<crate::widget::colorbar::ColorBarWidget> {
11516        scatter_view_colorbar(self.colormap.as_ref())
11517    }
11518
11519    /// The scatter mask, sized to the point count of the last [`Self::set_data`]
11520    /// (silx `ScatterView.getMaskToolsDockWidget().getSelectionMask()`,
11521    /// ScatterView.py:116-122). Drive selections via its geometric / threshold
11522    /// operations (e.g. [`ScatterMaskWidget::update_rectangle`]); the resulting
11523    /// non-zero levels flag the masked points, queryable via
11524    /// [`Self::masked_selection`].
11525    ///
11526    /// [`ScatterMaskWidget::update_rectangle`]: crate::widget::scatter_mask::ScatterMaskWidget::update_rectangle
11527    pub fn scatter_mask(&self) -> &crate::widget::scatter_mask::ScatterMaskWidget {
11528        &self.mask
11529    }
11530
11531    /// Mutable access to the scatter mask, to apply selection operations against
11532    /// the scatter's point arrays (silx `ScatterMaskToolsWidget`).
11533    pub fn scatter_mask_mut(&mut self) -> &mut crate::widget::scatter_mask::ScatterMaskWidget {
11534        &mut self.mask
11535    }
11536
11537    /// The boolean point selection applied to the scatter: one entry per point,
11538    /// `true` where the mask level is non-zero (silx scatter mask selection).
11539    pub fn masked_selection(&self) -> Vec<bool> {
11540        scatter_masked_selection(&self.mask.mask)
11541    }
11542
11543    /// The per-point selection mask as raw levels (silx
11544    /// `ScatterView.getSelectionMask`): one `u8` per scatter point, `0`
11545    /// unmasked and `1..=255` a mask level. Empty until [`Self::set_data`] has
11546    /// sized it to the point count — silx returns an empty array when there is
11547    /// no active scatter.
11548    pub fn selection_mask(&self) -> &[u8] {
11549        &self.mask.mask
11550    }
11551
11552    /// Replace the per-point selection mask (silx
11553    /// `ScatterView.setSelectionMask`).
11554    ///
11555    /// `mask` must have exactly one entry per scatter point — the current
11556    /// [`selection_mask`](Self::selection_mask) length (the last
11557    /// [`Self::set_data`] point count). On success the new mask is committed to
11558    /// the undo history and its point count returned; a length mismatch returns
11559    /// [`PlotDataError`] and leaves the mask unchanged (silx raises `ValueError`
11560    /// on a shape mismatch).
11561    pub fn set_selection_mask(&mut self, mask: &[u8]) -> Result<usize, PlotDataError> {
11562        if mask.len() != self.mask.mask.len() {
11563            return Err(PlotDataError::ImageDataLength {
11564                expected: self.mask.mask.len(),
11565                actual: mask.len(),
11566            });
11567        }
11568        self.mask.mask.copy_from_slice(mask);
11569        self.mask.commit();
11570        Ok(mask.len())
11571    }
11572
11573    /// The last cursor data coordinates `(x, y)` fed into the position-info
11574    /// readout (silx `ScatterView` `sigMouseMoved`), or `None` before the
11575    /// pointer has moved over the data area.
11576    pub fn cursor(&self) -> Option<[f64; 2]> {
11577        self.cursor
11578    }
11579
11580    /// Show the silx `ScatterView` position-info bar below the plot: the `X`,
11581    /// `Y`, `Data`, `Index` columns, snapping to the scatter point under the
11582    /// cursor (silx ScatterView.py:90-101 + `_pickScatterData`).
11583    ///
11584    /// Pass the [`PlotResponse`] returned by [`Self::show`] this frame: the
11585    /// cursor is updated from its pointer event and the pick is done in pixel
11586    /// space through its display [`Transform`](crate::Transform) (so the snap radius is constant on
11587    /// screen regardless of zoom). When a point is within
11588    /// `SCATTER_PICK_RADIUS_PX` of the cursor, `X`/`Y` snap to it and
11589    /// `Data`/`Index` show its value/index; otherwise `X`/`Y` show the cursor
11590    /// coordinates and `Data`/`Index` show `"-"`.
11591    pub fn show_position_info(&mut self, ui: &mut egui::Ui, response: &PlotResponse) {
11592        if let Some(cursor) = cursor_from_pointer_event(response.pointer_event.as_ref()) {
11593            self.cursor = Some(cursor);
11594        }
11595        let pick = self.cursor.and_then(|[cx, cy]| {
11596            use crate::core::scatter_viz;
11597            let (xs, ys, vs) = self.points.as_ref()?;
11598            // Mode-specific picking, mirroring silx `Scatter.pick`
11599            // (scatter.py:804-861) followed by `ScatterView._pickScatterData`'s
11600            // per-mode index reduction (ScatterView.py:191-214).
11601            let i = match self.visualization {
11602                // REGULAR_GRID: the cursor's data cell in the rendered grid image
11603                // maps straight to a source index by the grid major order
11604                // (scatter.py:815-835). No pixel radius — like an image pick.
11605                ScatterVisualization::RegularGrid => {
11606                    let image = regular_grid_image(xs, ys, vs)?;
11607                    let order = scatter_viz::detect_regular_grid(xs, ys)?.order;
11608                    scatter_viz::regular_grid_pick(&image, order, xs.len(), cx, cy)?
11609                }
11610                // BINNED_STATISTIC: every point in the cursor's bin is a
11611                // candidate (scatter.py:837-859); reduce to the one nearest the
11612                // cursor in data space, highest index on ties (ScatterView.py:197).
11613                ScatterVisualization::BinnedStatistic => {
11614                    let (rows, cols) = self.grid_resolution;
11615                    let bs = scatter_viz::binned_statistic(xs, ys, vs, rows, cols)?;
11616                    let candidates = bs.pick(xs, ys, cx, cy)?;
11617                    nearest_candidate_in_data(&candidates, xs, ys, cx, cy)?
11618                }
11619                // IRREGULAR_GRID: the cell (triangle pair) under the cursor maps
11620                // back to its source point (silx scatter.py:810-813, picked
11621                // vertex `// 4`). Pick against the retained quadrilateral mesh in
11622                // data space — no pixel radius, the cell tiles the plane.
11623                ScatterVisualization::IrregularGrid => {
11624                    let mesh = self.irregular_grid_mesh.as_ref()?;
11625                    scatter_viz::irregular_grid_pick(mesh, cx, cy)?
11626                }
11627                // POINTS/SOLID: top-most point under the cursor (scatter.py base
11628                // pick → `indices[-1]`).
11629                ScatterVisualization::Points | ScatterVisualization::Solid => {
11630                    let cursor_px = response.transform.data_to_pixel(cx, cy);
11631                    let points_px: Vec<(f32, f32)> = xs
11632                        .iter()
11633                        .zip(ys)
11634                        .map(|(&x, &y)| {
11635                            let p = response.transform.data_to_pixel(x, y);
11636                            (p.x, p.y)
11637                        })
11638                        .collect();
11639                    scatter_pick_pixels(
11640                        (cursor_px.x, cursor_px.y),
11641                        &points_px,
11642                        SCATTER_PICK_RADIUS_PX,
11643                    )?
11644                }
11645            };
11646            Some(ScatterPick {
11647                index: i,
11648                x: xs[i],
11649                y: ys[i],
11650                value: vs[i],
11651            })
11652        });
11653        scatter_position_info(pick).ui(ui, self.cursor);
11654    }
11655
11656    /// Mask (or unmask) the scatter points inside the data-space rectangle with
11657    /// bottom-left `anchor = (x, y)` and `size = (width, height)`, at the
11658    /// current mask level, then commit it to the undo history (silx
11659    /// `ScatterMask.updateRectangle` over the scatter points). Uses the
11660    /// retained point coordinates from the last [`Self::set_data`].
11661    pub fn mask_rectangle(&mut self, anchor: (f64, f64), size: (f64, f64), mask: bool) {
11662        let (px, py) = self.mask_point_coords();
11663        let level = self.mask.level;
11664        self.mask.update_rectangle(
11665            level,
11666            (anchor.1 as f32, anchor.0 as f32),
11667            (size.1 as f32, size.0 as f32),
11668            &px,
11669            &py,
11670            mask,
11671        );
11672        self.mask.commit();
11673    }
11674
11675    /// Mask (or unmask) the scatter points inside the data-space polygon
11676    /// `vertices` (`(x, y)` corners), at the current mask level, then commit it
11677    /// to the undo history (silx `ScatterMask.updatePolygon`). Uses the retained
11678    /// point coordinates from the last [`Self::set_data`].
11679    pub fn mask_polygon(&mut self, vertices: &[(f64, f64)], mask: bool) {
11680        let (px, py) = self.mask_point_coords();
11681        // scatter_mask vertices are (y, x) corners (silx Polygon order).
11682        let verts: Vec<(f32, f32)> = vertices
11683            .iter()
11684            .map(|&(x, y)| (y as f32, x as f32))
11685            .collect();
11686        let level = self.mask.level;
11687        self.mask.update_polygon(level, &verts, &px, &py, mask);
11688        self.mask.commit();
11689    }
11690
11691    /// The retained scatter point coordinate arrays as `f32` `(x, y)` for the
11692    /// geometric mask operations, or empty vectors before any data is uploaded.
11693    fn mask_point_coords(&self) -> (Vec<f32>, Vec<f32>) {
11694        match &self.points {
11695            Some((x, y, _)) => (
11696                x.iter().map(|&v| v as f32).collect(),
11697                y.iter().map(|&v| v as f32).collect(),
11698            ),
11699            None => (Vec::new(), Vec::new()),
11700        }
11701    }
11702
11703    /// The retained scatter values as `f32` for the threshold mask operations,
11704    /// or an empty vector before any data is uploaded.
11705    fn mask_values(&self) -> Vec<f32> {
11706        match &self.points {
11707            Some((_, _, v)) => v.iter().map(|&val| val as f32).collect(),
11708            None => Vec::new(),
11709        }
11710    }
11711
11712    /// Render the scatter mask-tools panel beside the plot (silx
11713    /// `ScatterView` mask dock, ScatterView.py:116-122).
11714    ///
11715    /// Exposes the whole-mask operations (clear-level / clear-all / invert /
11716    /// undo / redo) plus value-threshold selection over the scatter's value
11717    /// array; geometric selections (rectangle / polygon / disk) are driven
11718    /// programmatically through [`Self::scatter_mask_mut`]. The resulting
11719    /// boolean selection ([`Self::masked_selection`]) is applied to the scatter
11720    /// — masked points are flagged. Returns `true` when the selection changed
11721    /// this frame.
11722    pub fn show_mask_tools(&mut self, ui: &mut egui::Ui) -> bool {
11723        let before = self.mask.mask.clone();
11724        ui.horizontal(|ui| {
11725            ui.label("Mask level:");
11726            let mut level = self.mask.level;
11727            if ui
11728                .add(egui::DragValue::new(&mut level).range(1..=255))
11729                .changed()
11730            {
11731                self.mask.level = level;
11732            }
11733        });
11734        ui.horizontal(|ui| {
11735            if ui.button("Clear level").clicked() {
11736                self.mask.clear();
11737                self.mask.commit();
11738            }
11739            if ui.button("Clear all").clicked() {
11740                self.mask.clear_all();
11741                self.mask.commit();
11742            }
11743            if ui.button("Invert").clicked() {
11744                self.mask.invert();
11745                self.mask.commit();
11746            }
11747        });
11748        ui.horizontal(|ui| {
11749            if ui
11750                .add_enabled(self.mask.can_undo(), egui::Button::new("Undo"))
11751                .clicked()
11752            {
11753                self.mask.undo();
11754            }
11755            if ui
11756                .add_enabled(self.mask.can_redo(), egui::Button::new("Redo"))
11757                .clicked()
11758            {
11759                self.mask.redo();
11760            }
11761            if ui.button("Mask non-finite").clicked() {
11762                let values = self.mask_values();
11763                self.mask.mask_not_finite(&values);
11764                self.mask.commit();
11765            }
11766        });
11767
11768        let changed = self.mask.mask != before;
11769        ui.label(format!(
11770            "{} / {} points masked",
11771            self.masked_selection().iter().filter(|&&m| m).count(),
11772            self.mask.len()
11773        ));
11774        changed
11775    }
11776
11777    /// Show the standard toolbar plus a colorbar show/hide toggle.
11778    ///
11779    /// The colorbar toggle mirrors silx `ColorBarAction`; clicking it flips
11780    /// whether [`Self::show`] reserves the side colorbar column.
11781    pub fn show_toolbar(&mut self, ui: &mut egui::Ui) -> ToolbarResponse {
11782        let show_colorbar = self.show_colorbar;
11783        let current_viz = self.visualization;
11784        let mut toggle = false;
11785        let mut picked_viz = current_viz;
11786        let (out, ()) = self.inner.show_toolbar_with(ui, |ui, _| {
11787            ui.separator();
11788            if ui
11789                .selectable_label(show_colorbar, "colorbar")
11790                .on_hover_text("Show/hide the colorbar")
11791                .clicked()
11792            {
11793                toggle = true;
11794            }
11795            // Visualization-mode selector (silx `ScatterVisualizationToolButton`,
11796            // PlotToolButtons.py:550+): pick the scatter rendering mode on the
11797            // toolbar rather than only via `set_visualization`.
11798            ui.separator();
11799            egui::ComboBox::from_id_salt("scatter_visualization")
11800                .selected_text(current_viz.label())
11801                .show_ui(ui, |ui| {
11802                    for mode in ScatterVisualization::ALL {
11803                        ui.selectable_value(&mut picked_viz, mode, mode.label());
11804                    }
11805                });
11806        });
11807        if toggle {
11808            crate::widget::actions::control::scatter_colorbar_toggle(self);
11809        }
11810        // `set_visualization` is a no-op when the mode is unchanged, so calling
11811        // it unconditionally only rebuilds on an actual selection change.
11812        self.set_visualization(picked_viz);
11813        out
11814    }
11815
11816    /// Render the scatter plot with the value colorbar beside it.
11817    ///
11818    /// The colorbar occupies a fixed-width column on the right, synced to the
11819    /// value colormap's limits (silx `ScatterView` grid colorbar,
11820    /// ScatterView.py:83-88). Before any data is uploaded no colorbar is drawn.
11821    pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
11822        let avail = ui.available_size();
11823        let colorbar = self.colorbar();
11824        let colorbar_w = colorbar_column_width(self.show_colorbar, colorbar.is_some());
11825        // Subtract the inter-child gap `ui.horizontal` inserts before the
11826        // colorbar, or the row overflows the window and the colorbar's value
11827        // labels clip at the window's right edge (see `row_content_width`).
11828        let spacing = ui.spacing().item_spacing.x;
11829        let plot_w = row_content_width(avail.x, colorbar_w, u32::from(colorbar_w > 0.0), spacing);
11830        let response = ui
11831            .horizontal(|ui| {
11832                let response = ui
11833                    .allocate_ui(egui::vec2(plot_w, avail.y), |ui| self.inner.show(ui))
11834                    .inner;
11835                if colorbar_w > 0.0
11836                    && let Some(bar) = colorbar
11837                {
11838                    bar.ui(ui, egui::vec2(colorbar_w, avail.y));
11839                }
11840                response
11841            })
11842            .inner;
11843        // Sample the line profile when the profile tool is armed and the user is
11844        // dragging across the scatter (silx `ScatterProfileToolBar`).
11845        self.handle_profile_drag(&response);
11846        // Draw the line-profile side window (silx `ScatterProfileToolBar`'s
11847        // profile window) when open; it lives in its own viewport beside the plot.
11848        self.profile_window.show(ui.ctx());
11849        response
11850    }
11851}
11852
11853impl Deref for ScatterView {
11854    type Target = PlotWidget;
11855
11856    fn deref(&self) -> &Self::Target {
11857        &self.inner
11858    }
11859}
11860
11861impl DerefMut for ScatterView {
11862    fn deref_mut(&mut self) -> &mut Self::Target {
11863        &mut self.inner
11864    }
11865}
11866
11867// ─── StackView ────────────────────────────────────────────────────────────────
11868
11869/// Which volume dimension a [`StackView`] browses through — silx `StackView`
11870/// "perspective": the orthogonal axis whose index selects the displayed frame.
11871///
11872/// For a row-major volume of shape `[d0, d1, d2]` (element `(i, j, k)` at flat
11873/// offset `(i * d1 + j) * d2 + k`):
11874///
11875/// - [`Axis0`](Self::Axis0): browse dimension 0 (`d0` frames); each frame is
11876///   `(d1, d2)` = (height, width). silx perspective 0, no transpose.
11877/// - [`Axis1`](Self::Axis1): browse dimension 1 (`d1` frames); each frame is
11878///   `(d0, d2)`. silx perspective 1, transpose `(1, 0, 2)`.
11879/// - [`Axis2`](Self::Axis2): browse dimension 2 (`d2` frames); each frame is
11880///   `(d0, d1)`. silx perspective 2, transpose `(2, 0, 1)`.
11881#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11882pub enum StackPerspective {
11883    /// Browse dimension 0; frames are `(d1, d2)`.
11884    #[default]
11885    Axis0,
11886    /// Browse dimension 1; frames are `(d0, d2)`.
11887    Axis1,
11888    /// Browse dimension 2; frames are `(d0, d1)`.
11889    Axis2,
11890}
11891
11892impl StackPerspective {
11893    /// The volume dimension index browsed by this perspective (0, 1, or 2).
11894    pub fn axis(self) -> usize {
11895        match self {
11896            StackPerspective::Axis0 => 0,
11897            StackPerspective::Axis1 => 1,
11898            StackPerspective::Axis2 => 2,
11899        }
11900    }
11901
11902    /// The two non-browsed dimensions as `(height_axis, width_axis)`, ascending
11903    /// — matching silx `__updatePlotLabels` `(y, x)`: `Axis0`→`(1, 2)`,
11904    /// `Axis1`→`(0, 2)`, `Axis2`→`(0, 1)`.
11905    pub fn display_axes(self) -> (usize, usize) {
11906        match self {
11907            StackPerspective::Axis0 => (1, 2),
11908            StackPerspective::Axis1 => (0, 2),
11909            StackPerspective::Axis2 => (0, 1),
11910        }
11911    }
11912}
11913
11914/// Number of frames when browsing a volume of `shape` (`[d0, d1, d2]`) along
11915/// `perspective` — silx `__transposed_view.shape[0]`.
11916pub fn stack_frame_count(shape: [usize; 3], perspective: StackPerspective) -> usize {
11917    shape[perspective.axis()]
11918}
11919
11920/// Slice one 2D frame out of a row-major 3D volume for the given perspective —
11921/// the pure core of silx `StackView.__createTransposedView` +
11922/// `setStackPosition`.
11923///
11924/// `data` is the flat volume of shape `shape` (`[d0, d1, d2]`), element
11925/// `(i, j, k)` at offset `(i * d1 + j) * d2 + k`. `index` selects the frame
11926/// along the browsed dimension. Returns `(width, height, pixels)` with `pixels`
11927/// in row-major (height, width) order, or `None` if `data.len() != d0*d1*d2` or
11928/// `index` is out of range for the browsed dimension.
11929pub fn stack_frame(
11930    data: &[f32],
11931    shape: [usize; 3],
11932    perspective: StackPerspective,
11933    index: usize,
11934) -> Option<(u32, u32, Vec<f32>)> {
11935    let [d0, d1, d2] = shape;
11936    if data.len() != d0.checked_mul(d1)?.checked_mul(d2)? {
11937        return None;
11938    }
11939    if index >= shape[perspective.axis()] {
11940        return None;
11941    }
11942    let (height_axis, width_axis) = perspective.display_axes();
11943    let height = shape[height_axis];
11944    let width = shape[width_axis];
11945    let at = |i: usize, j: usize, k: usize| data[(i * d1 + j) * d2 + k];
11946    let mut pixels = Vec::with_capacity(width.saturating_mul(height));
11947    for row in 0..height {
11948        for col in 0..width {
11949            // (i, j, k) places `index` on the browsed axis and (row, col) on
11950            // the two display axes, matching each perspective's transpose.
11951            let value = match perspective {
11952                StackPerspective::Axis0 => at(index, row, col),
11953                StackPerspective::Axis1 => at(row, index, col),
11954                StackPerspective::Axis2 => at(row, col, index),
11955            };
11956            pixels.push(value);
11957        }
11958    }
11959    Some((width as u32, height as u32, pixels))
11960}
11961
11962/// A profile extracted from every frame of a 3D stack — the 2D "profile over
11963/// stack" of silx `ProfileImageStack*` ROIs (`tools/profile/rois.py:1058-1165`).
11964///
11965/// One 1D profile is taken from each frame along the browsed dimension and the
11966/// rows are stacked, giving a 2D image of shape `(frame_count, profile_len)` in
11967/// row-major order: `values[frame * profile_len + position]`.
11968#[derive(Debug, Clone, PartialEq)]
11969pub struct StackProfile {
11970    /// Number of frames profiled (the browsed-dimension length).
11971    pub frame_count: usize,
11972    /// Samples per single-frame profile.
11973    pub profile_len: usize,
11974    /// Stacked profiles, row-major `[frame, position]`.
11975    pub values: Vec<f64>,
11976}
11977
11978/// Which profile a [`StackView`]'s Profile3D tool extracts — silx
11979/// `_DefaultImageStackProfileRoiMixIn.profileType` (`"1D"` / `"2D"`,
11980/// `tools/profile/rois.py:1063-1075`).
11981#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11982pub enum StackProfileDimension {
11983    /// A 1D profile of the currently displayed frame (silx `"1D"`, the default),
11984    /// shown in the [`ProfileWindow`](crate::widget::profile_window::ProfileWindow)
11985    /// curve window.
11986    #[default]
11987    OneD,
11988    /// A 2D profile stacked over every frame (silx `"2D"`), shown in the
11989    /// [`StackProfileWindow`](crate::widget::stack_profile_window::StackProfileWindow)
11990    /// image window.
11991    TwoD,
11992}
11993
11994/// Apply a single-frame profile extractor to every frame of a stack and stack
11995/// the results — the pure core behind [`stack_aligned_profile`] /
11996/// [`stack_line_profile`], mirroring silx `ProfileImageStack*`.
11997///
11998/// Returns `None` if `data` does not match `shape`, the stack is empty, any
11999/// frame fails to slice/extract, or the per-frame profiles are not all the same
12000/// length.
12001fn stack_profile_with<F>(
12002    data: &[f32],
12003    shape: [usize; 3],
12004    perspective: StackPerspective,
12005    mut extract: F,
12006) -> Option<StackProfile>
12007where
12008    F: FnMut(u32, u32, &[f32]) -> Option<Vec<f64>>,
12009{
12010    let frame_count = stack_frame_count(shape, perspective);
12011    if frame_count == 0 {
12012        return None;
12013    }
12014    let mut values: Vec<f64> = Vec::new();
12015    let mut profile_len: Option<usize> = None;
12016    for index in 0..frame_count {
12017        let (w, h, pixels) = stack_frame(data, shape, perspective, index)?;
12018        let profile = extract(w, h, &pixels)?;
12019        match profile_len {
12020            None => profile_len = Some(profile.len()),
12021            Some(len) if len != profile.len() => return None,
12022            _ => {}
12023        }
12024        values.extend(profile);
12025    }
12026    Some(StackProfile {
12027        frame_count,
12028        profile_len: profile_len.unwrap_or(0),
12029        values,
12030    })
12031}
12032
12033/// Stack an axis-aligned band profile over every frame (silx
12034/// `ProfileImageStackHorizontalLineROI` / `...VerticalLineROI`).
12035///
12036/// Each frame is profiled with [`aligned_profile_values`] (`position`,
12037/// `roi_width`, `horizontal`, `method`); see that function for the band-placement
12038/// rule. Returns `None` on a stack/shape mismatch.
12039pub fn stack_aligned_profile(
12040    data: &[f32],
12041    shape: [usize; 3],
12042    perspective: StackPerspective,
12043    position: f64,
12044    roi_width: u32,
12045    horizontal: bool,
12046    method: ProfileMethod,
12047) -> Option<StackProfile> {
12048    stack_profile_with(data, shape, perspective, |w, h, pixels| {
12049        aligned_profile_values(w, h, pixels, position, roi_width, horizontal, method).ok()
12050    })
12051}
12052
12053/// Stack a line-segment profile over every frame (silx
12054/// `ProfileImageStackLineROI`), using [`line_profile_values`] per frame.
12055pub fn stack_line_profile(
12056    data: &[f32],
12057    shape: [usize; 3],
12058    perspective: StackPerspective,
12059    start: (f64, f64),
12060    end: (f64, f64),
12061) -> Option<StackProfile> {
12062    stack_profile_with(data, shape, perspective, |w, h, pixels| {
12063        // line_profile_values returns (arc positions, sampled values); stack the
12064        // sampled values.
12065        line_profile_values(w, h, pixels, start, end)
12066            .ok()
12067            .map(|(_positions, values)| values)
12068    })
12069}
12070
12071/// The default label for volume dimension `axis` — silx `"Dimension %d"`
12072/// (with `_first_stack_dimension == 0`).
12073pub fn default_dimension_label(axis: usize) -> String {
12074    format!("Dimension {axis}")
12075}
12076
12077/// Plot axis labels `(x_label, y_label)` for `perspective`, picked from the 3
12078/// per-dimension `labels` — silx `__updatePlotLabels`: X uses the width axis's
12079/// label, Y uses the height axis's label.
12080pub fn dimension_axis_labels(
12081    perspective: StackPerspective,
12082    labels: &[String; 3],
12083) -> (String, String) {
12084    let (height_axis, width_axis) = perspective.display_axes();
12085    (labels[width_axis].clone(), labels[height_axis].clone())
12086}
12087
12088/// Reorder the per-dimension `calibrations` (array order `[d0, d1, d2]`) into
12089/// graph-axis order `(x, y, z)` for `perspective` — silx
12090/// `StackView.getCalibrations(order="axes")`: X uses the higher-index non-browsed
12091/// dimension (the width axis), Y the lower-index one (the height axis), Z the
12092/// browsed dimension. silx additionally replaces any non-affine calibration with
12093/// `NoCalibration` for the graph axes; that filter is a structural no-op here
12094/// because every [`Calibration`] variant is affine.
12095pub fn calibrations_axes_order(
12096    perspective: StackPerspective,
12097    calibrations: &[Calibration; 3],
12098) -> (Calibration, Calibration, Calibration) {
12099    let (height_axis, width_axis) = perspective.display_axes(); // (min, max) non-browsed
12100    (
12101        calibrations[width_axis],         // X = max non-browsed dim
12102        calibrations[height_axis],        // Y = min non-browsed dim
12103        calibrations[perspective.axis()], // Z = browsed dim
12104    )
12105}
12106
12107/// Data-space `(origin, scale)` of the displayed image for `perspective` under
12108/// `calibrations` — silx `_getImageOrigin` (`xcalib(0), ycalib(0)`) and
12109/// `_getImageScale` (`xcalib.get_slope(), ycalib.get_slope()`).
12110pub fn calibrated_image_geometry(
12111    perspective: StackPerspective,
12112    calibrations: &[Calibration; 3],
12113) -> ((f64, f64), (f64, f64)) {
12114    let (xcalib, ycalib, _zcalib) = calibrations_axes_order(perspective, calibrations);
12115    let origin = (xcalib.apply(0.0), ycalib.apply(0.0));
12116    let scale = (xcalib.slope(), ycalib.slope());
12117    (origin, scale)
12118}
12119
12120/// Calibrated Z value for frame `index` under `perspective`/`calibrations` —
12121/// silx `_getImageZ` (`zcalib(index)`), used for the per-frame title.
12122pub fn calibrated_image_z(
12123    index: usize,
12124    perspective: StackPerspective,
12125    calibrations: &[Calibration; 3],
12126) -> f64 {
12127    let (_xcalib, _ycalib, zcalib) = calibrations_axes_order(perspective, calibrations);
12128    zcalib.apply(index as f64)
12129}
12130
12131/// A 3D image stack viewer with a frame-selection slider, mirroring silx
12132/// `StackView`.
12133///
12134/// Stores all frames as flat `Vec<f32>` slices.  Only the selected frame is
12135/// uploaded to the GPU each time it changes.
12136///
12137/// ```ignore
12138/// let mut sv = StackView::new(render_state, 0);
12139/// sv.set_stack(width, height, frames)?;  // frames: Vec<Vec<f32>>
12140/// // frame loop
12141/// sv.show_toolbar(ui);
12142/// sv.show_frame_controls(ui);
12143/// sv.show(ui);
12144/// ```
12145pub struct StackView {
12146    inner: Plot2D,
12147    width: u32,
12148    height: u32,
12149    frames: Vec<Vec<f32>>,
12150    colormap: Colormap,
12151    image_handle: Option<ItemHandle>,
12152    current_frame: usize,
12153    dirty: bool,
12154    /// The source 3D volume (`flat data`, `[d0, d1, d2]`) when loaded via
12155    /// [`set_volume`](Self::set_volume); `None` in flat-frames mode
12156    /// ([`set_stack`](Self::set_stack)). Re-sliced when the perspective changes.
12157    volume: Option<(Vec<f32>, [usize; 3])>,
12158    /// Which volume dimension the frame slider browses (silx perspective).
12159    perspective: StackPerspective,
12160    /// Per-dimension labels (silx `setLabels`); the plot axis labels are chosen
12161    /// from these as the perspective rotates. Defaults to `"Dimension 0/1/2"`.
12162    dim_labels: [String; 3],
12163    /// Per-dimension axis calibrations (silx `calibrations3D`, array order
12164    /// `[d0, d1, d2]`). Default identity. They place the displayed image
12165    /// (origin + scale) and compute the per-frame Z value.
12166    calibrations: [Calibration; 3],
12167    /// Block aggregation applied to each displayed frame (silx StackView
12168    /// `AggregationModeAction` -> `_stackItem.setAggregationMode`, the same
12169    /// `ImageDataAggregated` max/mean/min as [`ImageView`]). Default
12170    /// [`AggregationMode::None`].
12171    aggregation: AggregationMode,
12172    /// Per-axis block factors `(block_x, block_y)` for [`aggregation`] (silx
12173    /// level-of-detail `(lodx, lody)`); each `>= 1`, `(1, 1)` is a no-op.
12174    aggregation_block: (u32, u32),
12175    /// Armed profile-ROI tool of the Profile3D toolbar (silx
12176    /// `Profile3DToolBar`'s `ProfileImageStack*ROI` actions); [`ProfileMode::None`]
12177    /// when no profile tool is active.
12178    profile_mode: ProfileMode,
12179    /// Whether the profile tool extracts a 1D current-frame profile or a 2D
12180    /// stacked profile (silx `_DefaultImageStackProfileRoiMixIn.profileType`).
12181    profile_dimension: StackProfileDimension,
12182    /// Data-space start of the in-progress profile drag, set on `drag_started`
12183    /// and cleared on `drag_stopped` (silx profile ROI first point).
12184    profile_drag_start: Option<(f64, f64)>,
12185    /// Side window for the 1D current-frame profile (silx profileType `"1D"`),
12186    /// fed from `self.frames[self.current_frame]`.
12187    profile_window: crate::widget::profile_window::ProfileWindow,
12188    /// Side window for the 2D stacked profile over all frames (silx profileType
12189    /// `"2D"`), the distinguishing feature of the Profile3D toolbar.
12190    stack_profile_window: crate::widget::stack_profile_window::StackProfileWindow,
12191}
12192
12193impl StackView {
12194    /// Create a new `StackView`.
12195    ///
12196    /// Reserves three plot ids: `id` for the image plot, `id + 1` for the 1D
12197    /// profile window, and `id + 2` for the 2D stacked-profile window (mirroring
12198    /// [`ImageView`], which reserves a small id range for its profile window).
12199    pub fn new(render_state: &RenderState, id: PlotId) -> Self {
12200        let mut inner = Plot2D::new(render_state, id);
12201        inner.set_keep_data_aspect_ratio(true);
12202        inner.set_graph_cursor(true);
12203        Self {
12204            inner,
12205            width: 0,
12206            height: 0,
12207            frames: Vec::new(),
12208            colormap: Colormap::viridis(0.0, 1.0),
12209            image_handle: None,
12210            current_frame: 0,
12211            dirty: false,
12212            volume: None,
12213            perspective: StackPerspective::default(),
12214            dim_labels: [
12215                default_dimension_label(0),
12216                default_dimension_label(1),
12217                default_dimension_label(2),
12218            ],
12219            calibrations: [Calibration::None; 3],
12220            aggregation: AggregationMode::None,
12221            aggregation_block: (1, 1),
12222            profile_mode: ProfileMode::None,
12223            profile_dimension: StackProfileDimension::default(),
12224            profile_drag_start: None,
12225            profile_window: crate::widget::profile_window::ProfileWindow::new(render_state, id + 1),
12226            stack_profile_window: crate::widget::stack_profile_window::StackProfileWindow::new(
12227                render_state,
12228                id + 2,
12229            ),
12230        }
12231    }
12232
12233    /// Load a stack of frames.  Each frame must have `width * height` elements.
12234    pub fn set_stack(
12235        &mut self,
12236        width: u32,
12237        height: u32,
12238        frames: Vec<Vec<f32>>,
12239        colormap: Colormap,
12240    ) -> Result<(), PlotDataError> {
12241        let expected = (width as usize).saturating_mul(height as usize);
12242        for frame in &frames {
12243            if frame.len() != expected {
12244                return Err(PlotDataError::ImageDataLength {
12245                    expected,
12246                    actual: frame.len(),
12247                });
12248            }
12249        }
12250        self.width = width;
12251        self.height = height;
12252        self.frames = frames;
12253        self.colormap = colormap;
12254        self.current_frame = 0;
12255        self.dirty = true;
12256        // Pre-sliced frames are not volume-browsable; leave perspective mode.
12257        self.volume = None;
12258        Ok(())
12259    }
12260
12261    /// Load a 3D volume and browse it as a stack along the current
12262    /// [`perspective`](Self::perspective) — silx `StackView.setStack`.
12263    ///
12264    /// `data` is the flat row-major volume of shape `shape` (`[d0, d1, d2]`),
12265    /// element `(i, j, k)` at offset `(i * d1 + j) * d2 + k`. Unlike
12266    /// [`set_stack`](Self::set_stack) (which takes already-sliced frames), this
12267    /// keeps the volume so [`set_perspective`](Self::set_perspective) can
12268    /// re-slice it along a different dimension. The axis labels are updated for
12269    /// the current perspective.
12270    pub fn set_volume(
12271        &mut self,
12272        data: Vec<f32>,
12273        shape: [usize; 3],
12274        colormap: Colormap,
12275    ) -> Result<(), PlotDataError> {
12276        let [d0, d1, d2] = shape;
12277        let expected = d0.saturating_mul(d1).saturating_mul(d2);
12278        if data.len() != expected {
12279            return Err(PlotDataError::ImageDataLength {
12280                expected,
12281                actual: data.len(),
12282            });
12283        }
12284        self.volume = Some((data, shape));
12285        self.colormap = colormap;
12286        self.rebuild_volume_frames();
12287        Ok(())
12288    }
12289
12290    /// The volume dimension currently browsed by the frame slider (silx
12291    /// `getPerspective`). Always [`StackPerspective::Axis0`] in flat-frames mode.
12292    pub fn perspective(&self) -> StackPerspective {
12293        self.perspective
12294    }
12295
12296    /// Browse the volume along a different dimension — silx
12297    /// `StackView.setPerspective`. Re-slices the loaded volume, resets the frame
12298    /// index to 0, re-fits the view, and updates the axis labels. No-op (the
12299    /// perspective is still stored) when no volume is loaded.
12300    pub fn set_perspective(&mut self, perspective: StackPerspective) {
12301        if perspective == self.perspective {
12302            return;
12303        }
12304        self.perspective = perspective;
12305        if self.volume.is_some() {
12306            self.rebuild_volume_frames();
12307            self.inner.reset_zoom();
12308        }
12309    }
12310
12311    /// Re-slice every frame out of the loaded volume for the current
12312    /// perspective, resetting dimensions, the frame index, and the GPU image.
12313    fn rebuild_volume_frames(&mut self) {
12314        let Some((data, shape)) = self.volume.as_ref() else {
12315            return;
12316        };
12317        let (data, shape) = (data.clone(), *shape);
12318        let n = stack_frame_count(shape, self.perspective);
12319        let mut frames = Vec::with_capacity(n);
12320        let (mut width, mut height) = (0u32, 0u32);
12321        for index in 0..n {
12322            if let Some((w, h, pixels)) = stack_frame(&data, shape, self.perspective, index) {
12323                width = w;
12324                height = h;
12325                frames.push(pixels);
12326            }
12327        }
12328        self.width = width;
12329        self.height = height;
12330        self.frames = frames;
12331        self.current_frame = 0;
12332        // Frame dimensions change with perspective, so drop the old image item
12333        // and re-add it fresh on the next show().
12334        if let Some(handle) = self.image_handle.take() {
12335            self.inner.remove_image(handle);
12336        }
12337        self.dirty = true;
12338        self.apply_axis_labels();
12339    }
12340
12341    /// The resolved per-dimension labels (silx `getLabels`).
12342    pub fn dimension_labels(&self) -> &[String; 3] {
12343        &self.dim_labels
12344    }
12345
12346    /// Set the per-dimension labels used for the plot axes — silx
12347    /// `StackView.setLabels`. Provide 3 labels for the 3 volume dimensions; an
12348    /// empty label falls back to the default `"Dimension N"` for that dimension
12349    /// (mirroring silx's `label or default`). The proper label is chosen for
12350    /// each axis automatically as the perspective rotates.
12351    pub fn set_dimension_labels(&mut self, labels: [&str; 3]) {
12352        for (i, label) in labels.iter().enumerate() {
12353            self.dim_labels[i] = if label.is_empty() {
12354                default_dimension_label(i)
12355            } else {
12356                (*label).to_string()
12357            };
12358        }
12359        self.apply_axis_labels();
12360    }
12361
12362    /// Set the plot axis labels for the current perspective from the resolved
12363    /// per-dimension labels — silx `__updatePlotLabels`.
12364    fn apply_axis_labels(&mut self) {
12365        let (x_label, y_label) = dimension_axis_labels(self.perspective, &self.dim_labels);
12366        self.inner.set_graph_x_label(x_label);
12367        self.inner.set_graph_y_label(y_label, YAxis::Left);
12368    }
12369
12370    /// The per-dimension axis calibrations in array order `[d0, d1, d2]` — silx
12371    /// `StackView.getCalibrations(order="array")`. (silx's non-affine filter is a
12372    /// no-op here: every [`Calibration`] is affine.)
12373    pub fn calibrations(&self) -> &[Calibration; 3] {
12374        &self.calibrations
12375    }
12376
12377    /// The calibrations in graph-axis order `(x, y, z)` for the current
12378    /// perspective — silx `StackView.getCalibrations(order="axes")`.
12379    pub fn calibrations_axes(&self) -> (Calibration, Calibration, Calibration) {
12380        calibrations_axes_order(self.perspective, &self.calibrations)
12381    }
12382
12383    /// Set the per-dimension axis calibrations (array order `[d0, d1, d2]`) —
12384    /// silx `StackView.setStack(calibrations=...)`. They place the displayed
12385    /// image (data-space origin from `calib(0)`, pixel size from the slope) and
12386    /// drive the per-frame Z value. Re-applies the image geometry and re-fits the
12387    /// view so the calibrated extent is visible.
12388    pub fn set_calibrations(&mut self, calibrations: [Calibration; 3]) {
12389        if calibrations == self.calibrations {
12390            return;
12391        }
12392        self.calibrations = calibrations;
12393        // Geometry is applied at image-add time; drop the handle so the next
12394        // show() re-adds with the new origin/scale.
12395        if let Some(handle) = self.image_handle.take() {
12396            self.inner.remove_image(handle);
12397        }
12398        self.dirty = true;
12399        if !self.frames.is_empty() {
12400            self.inner.reset_zoom();
12401        }
12402    }
12403
12404    /// Calibrated Z value for frame `index` under the current perspective and
12405    /// calibrations — silx `_getImageZ` (`zcalib(index)`).
12406    pub fn image_z(&self, index: usize) -> f64 {
12407        calibrated_image_z(index, self.perspective, &self.calibrations)
12408    }
12409
12410    /// Extract an axis-aligned band profile from every frame along the browsed
12411    /// dimension and stack them, mirroring silx `Profile3DToolBar`'s
12412    /// `ProfileImageStackHorizontalLineROI` / `...VerticalLineROI`.
12413    ///
12414    /// Operates on the loaded volume under the current
12415    /// [`perspective`](Self::perspective); see [`stack_aligned_profile`] for the
12416    /// band-placement rule. Returns `None` in flat-frames mode (no volume) or on
12417    /// a shape mismatch. The data-layer half of the 3D-profile tool; rendering the
12418    /// stacked profile in a side plot is the caller's UI.
12419    pub fn stack_aligned_profile(
12420        &self,
12421        position: f64,
12422        roi_width: u32,
12423        horizontal: bool,
12424        method: ProfileMethod,
12425    ) -> Option<StackProfile> {
12426        let (data, shape) = self.volume.as_ref()?;
12427        stack_aligned_profile(
12428            data,
12429            *shape,
12430            self.perspective,
12431            position,
12432            roi_width,
12433            horizontal,
12434            method,
12435        )
12436    }
12437
12438    /// Extract a line-segment profile from every frame along the browsed
12439    /// dimension and stack them (silx `Profile3DToolBar`'s
12440    /// `ProfileImageStackLineROI`). Requires a loaded volume; returns `None` in
12441    /// flat-frames mode.
12442    pub fn stack_line_profile(&self, start: (f64, f64), end: (f64, f64)) -> Option<StackProfile> {
12443        let (data, shape) = self.volume.as_ref()?;
12444        stack_line_profile(data, *shape, self.perspective, start, end)
12445    }
12446
12447    /// The armed profile-ROI tool of the Profile3D toolbar (silx
12448    /// `Profile3DToolBar`).
12449    pub fn profile_mode(&self) -> ProfileMode {
12450        self.profile_mode
12451    }
12452
12453    /// Arm or disarm the Profile3D ROI tool (silx `Profile3DToolBar`'s
12454    /// `ProfileImageStack*ROI` actions). While armed, a primary drag on the image
12455    /// extracts a profile and shows it in the 1D or 2D profile window per
12456    /// [`profile_dimension`](Self::profile_dimension). [`ProfileMode::None`]
12457    /// disables the tool and closes both windows.
12458    pub fn set_profile_mode(&mut self, mode: ProfileMode) {
12459        self.profile_mode = mode;
12460        if mode == ProfileMode::None {
12461            self.profile_drag_start = None;
12462            self.profile_window.set_open(false);
12463            self.stack_profile_window.set_open(false);
12464        }
12465    }
12466
12467    /// Whether the profile tool yields a 1D current-frame profile or a 2D
12468    /// stacked profile (silx `_DefaultImageStackProfileRoiMixIn.profileType`).
12469    pub fn profile_dimension(&self) -> StackProfileDimension {
12470        self.profile_dimension
12471    }
12472
12473    /// Switch between the 1D current-frame and 2D stacked profile (silx
12474    /// `setProfileType`). Closes the now-inactive profile window so only the
12475    /// active profile is shown.
12476    pub fn set_profile_dimension(&mut self, dimension: StackProfileDimension) {
12477        if dimension == self.profile_dimension {
12478            return;
12479        }
12480        self.profile_dimension = dimension;
12481        match dimension {
12482            StackProfileDimension::OneD => self.stack_profile_window.set_open(false),
12483            StackProfileDimension::TwoD => self.profile_window.set_open(false),
12484        }
12485    }
12486
12487    /// The 1D current-frame profile window (silx profileType `"1D"`).
12488    pub fn profile_window(&self) -> &crate::widget::profile_window::ProfileWindow {
12489        &self.profile_window
12490    }
12491
12492    /// Mutable access to the 1D current-frame profile window.
12493    pub fn profile_window_mut(&mut self) -> &mut crate::widget::profile_window::ProfileWindow {
12494        &mut self.profile_window
12495    }
12496
12497    /// The 2D stacked-profile window (silx profileType `"2D"`).
12498    pub fn stack_profile_window(&self) -> &crate::widget::stack_profile_window::StackProfileWindow {
12499        &self.stack_profile_window
12500    }
12501
12502    /// Mutable access to the 2D stacked-profile window.
12503    pub fn stack_profile_window_mut(
12504        &mut self,
12505    ) -> &mut crate::widget::stack_profile_window::StackProfileWindow {
12506        &mut self.stack_profile_window
12507    }
12508
12509    /// Compute and display the profile for a drag between data-space `(col, row)`
12510    /// endpoints, routing to the 1D or 2D window per the current
12511    /// [`profile_dimension`](Self::profile_dimension) and the armed
12512    /// [`profile_mode`](Self::profile_mode). The shared body of the interactive
12513    /// drag (`handle_profile_drag`); also callable
12514    /// directly to drive the profile without a `Ui`. Returns `true` when a
12515    /// profile was produced and its window opened.
12516    ///
12517    /// In 2D mode the stacked profile requires a loaded volume
12518    /// ([`set_volume`](Self::set_volume)); in flat-frames mode it returns `false`.
12519    /// [`ProfileMode::Rectangle`] has no silx stack-profile ROI, so 2D mode
12520    /// ignores it (silx `Profile3DToolBar` offers only h-line / v-line / line).
12521    pub fn show_profile(&mut self, start: (f64, f64), end: (f64, f64)) -> bool {
12522        if self.frames.is_empty() {
12523            return false;
12524        }
12525        match self.profile_dimension {
12526            StackProfileDimension::OneD => {
12527                let Some(roi) = profile_roi_from_drag(self.profile_mode, start, end) else {
12528                    return false;
12529                };
12530                let frame = &self.frames[self.current_frame];
12531                self.profile_window
12532                    .update_profile(self.width, self.height, frame, &roi);
12533                self.profile_window.set_open(true);
12534                true
12535            }
12536            StackProfileDimension::TwoD => {
12537                let profile = match self.profile_mode {
12538                    ProfileMode::Line => self.stack_line_profile(start, end),
12539                    ProfileMode::Horizontal => {
12540                        self.stack_aligned_profile(end.1.floor(), 1, true, ProfileMethod::Mean)
12541                    }
12542                    ProfileMode::Vertical => {
12543                        self.stack_aligned_profile(end.0.floor(), 1, false, ProfileMethod::Mean)
12544                    }
12545                    ProfileMode::Rectangle | ProfileMode::None => None,
12546                };
12547                let Some(profile) = profile else {
12548                    return false;
12549                };
12550                self.stack_profile_window
12551                    .set_profile(&profile, self.colormap.clone());
12552                self.stack_profile_window.set_open(true);
12553                true
12554            }
12555        }
12556    }
12557
12558    /// Track a profile drag on the image plot and extract the profile live, the
12559    /// Profile3D analogue of [`ImageView::handle_profile_drag`]. Maps the drag
12560    /// start/current pixels to data-space `(col, row)` via the plot transform and
12561    /// feeds [`show_profile`](Self::show_profile). Gated on an armed
12562    /// [`profile_mode`](Self::profile_mode) so pan / zoom never extract a profile.
12563    fn handle_profile_drag(&mut self, plot_response: &PlotResponse) {
12564        if self.profile_mode == ProfileMode::None || self.frames.is_empty() {
12565            self.profile_drag_start = None;
12566            return;
12567        }
12568        let response = &plot_response.response;
12569        let transform = &plot_response.transform;
12570
12571        if response.drag_started()
12572            && let Some(p) = response.interact_pointer_pos()
12573        {
12574            self.profile_drag_start = Some(transform.pixel_to_data(p));
12575        }
12576
12577        if response.dragged()
12578            && let (Some(start), Some(p)) =
12579                (self.profile_drag_start, response.interact_pointer_pos())
12580        {
12581            let end = transform.pixel_to_data(p);
12582            self.show_profile(start, end);
12583        }
12584
12585        if response.drag_stopped() {
12586            self.profile_drag_start = None;
12587        }
12588    }
12589
12590    /// Show the Profile3D toolbar — the profile-ROI tool buttons plus the 1D/2D
12591    /// dimension toggle (silx `Profile3DToolBar`: the `ProfileImageStack*ROI`
12592    /// actions over a [`StackView`]). The selected tool/dimension drives the
12593    /// interactive drag in [`show`](Self::show).
12594    pub fn show_profile3d_toolbar(&mut self, ui: &mut egui::Ui) {
12595        ui.horizontal(|ui| {
12596            let mut mode = self.profile_mode;
12597            if ui
12598                .selectable_label(mode == ProfileMode::None, "○")
12599                .on_hover_text("No profile")
12600                .clicked()
12601            {
12602                mode = ProfileMode::None;
12603            }
12604            if ui
12605                .selectable_label(mode == ProfileMode::Horizontal, "H")
12606                .on_hover_text("Horizontal line profile over the stack")
12607                .clicked()
12608            {
12609                mode = ProfileMode::Horizontal;
12610            }
12611            if ui
12612                .selectable_label(mode == ProfileMode::Vertical, "V")
12613                .on_hover_text("Vertical line profile over the stack")
12614                .clicked()
12615            {
12616                mode = ProfileMode::Vertical;
12617            }
12618            if ui
12619                .selectable_label(mode == ProfileMode::Line, "L")
12620                .on_hover_text("Line profile over the stack (draw a line)")
12621                .clicked()
12622            {
12623                mode = ProfileMode::Line;
12624            }
12625            if mode != self.profile_mode {
12626                self.set_profile_mode(mode);
12627            }
12628
12629            ui.separator();
12630            ui.label("Profile:");
12631            let mut dimension = self.profile_dimension;
12632            if ui
12633                .selectable_label(dimension == StackProfileDimension::OneD, "1D")
12634                .on_hover_text("Profile of the current frame")
12635                .clicked()
12636            {
12637                dimension = StackProfileDimension::OneD;
12638            }
12639            if ui
12640                .selectable_label(dimension == StackProfileDimension::TwoD, "2D")
12641                .on_hover_text("Profile stacked over all frames")
12642                .clicked()
12643            {
12644                dimension = StackProfileDimension::TwoD;
12645            }
12646            if dimension != self.profile_dimension {
12647                self.set_profile_dimension(dimension);
12648            }
12649        });
12650    }
12651
12652    /// Number of frames in the stack.
12653    pub fn frame_count(&self) -> usize {
12654        self.frames.len()
12655    }
12656
12657    /// Index of the currently visible frame.
12658    pub fn frame(&self) -> usize {
12659        self.current_frame
12660    }
12661
12662    /// Jump to frame `index` (clamped to valid range).
12663    pub fn set_frame(&mut self, index: usize) {
12664        let clamped = index.min(self.frames.len().saturating_sub(1));
12665        if clamped != self.current_frame {
12666            self.current_frame = clamped;
12667            self.dirty = true;
12668        }
12669    }
12670
12671    /// Set the colormap applied to all frames.
12672    pub fn set_colormap(&mut self, colormap: Colormap) {
12673        self.colormap = colormap;
12674        self.dirty = true;
12675    }
12676
12677    /// Show a perspective selector — a combo box choosing which volume
12678    /// dimension the frame slider browses (silx `PlaneSelectionWidget`). Only
12679    /// meaningful after [`set_volume`](Self::set_volume); a no-op in flat-frames
12680    /// mode. Typically called before [`Self::show_frame_controls`].
12681    pub fn perspective_ui(&mut self, ui: &mut egui::Ui) {
12682        if self.volume.is_none() {
12683            return;
12684        }
12685        let labels = self.dim_labels.clone();
12686        let mut selected = self.perspective;
12687        egui::ComboBox::from_label("Browse dimension")
12688            .selected_text(labels[selected.axis()].clone())
12689            .show_ui(ui, |ui| {
12690                for option in [
12691                    StackPerspective::Axis0,
12692                    StackPerspective::Axis1,
12693                    StackPerspective::Axis2,
12694                ] {
12695                    ui.selectable_value(&mut selected, option, labels[option.axis()].clone());
12696                }
12697            });
12698        self.set_perspective(selected);
12699    }
12700
12701    /// The current per-frame block aggregation mode (silx StackView
12702    /// `getAggregationMode`).
12703    pub fn aggregation(&self) -> AggregationMode {
12704        self.aggregation
12705    }
12706
12707    /// The current per-axis aggregation block factors `(block_x, block_y)`.
12708    pub fn aggregation_block(&self) -> (u32, u32) {
12709        self.aggregation_block
12710    }
12711
12712    /// Set the per-frame block aggregation `mode` and per-axis block factors,
12713    /// then re-upload the current frame (silx StackView `AggregationModeAction`
12714    /// -> `_stackItem.setAggregationMode`). Each block factor is clamped to
12715    /// `>= 1`; the aggregated frame's scale grows with the block so it covers
12716    /// the same calibrated data extent (silx `ImageDataAggregated`).
12717    pub fn set_aggregation(&mut self, mode: AggregationMode, block: (u32, u32)) {
12718        let block = (block.0.max(1), block.1.max(1));
12719        if mode != self.aggregation || block != self.aggregation_block {
12720            self.aggregation = mode;
12721            self.aggregation_block = block;
12722            self.dirty = true;
12723        }
12724    }
12725
12726    /// Show a compact frame-navigation row: ← slider → with frame counter.
12727    ///
12728    /// Typically called before [`Self::show`].
12729    pub fn show_frame_controls(&mut self, ui: &mut egui::Ui) {
12730        if self.frames.is_empty() {
12731            return;
12732        }
12733        let n = self.frames.len();
12734        ui.horizontal(|ui| {
12735            if ui.button("◀").on_hover_text("Previous frame").clicked() && self.current_frame > 0
12736            {
12737                self.current_frame -= 1;
12738                self.dirty = true;
12739            }
12740            let mut idx = self.current_frame;
12741            if ui
12742                .add(egui::Slider::new(&mut idx, 0..=n.saturating_sub(1)).text("frame"))
12743                .changed()
12744            {
12745                self.current_frame = idx;
12746                self.dirty = true;
12747            }
12748            if ui.button("▶").on_hover_text("Next frame").clicked() && self.current_frame + 1 < n
12749            {
12750                self.current_frame += 1;
12751                self.dirty = true;
12752            }
12753            ui.label(format!("{}/{}", self.current_frame + 1, n));
12754
12755            // Per-frame aggregation selector (silx StackView
12756            // `AggregationModeAction` -> `_stackItem.setAggregationMode`,
12757            // mirroring [`ImageView::show_toolbar`]).
12758            let mut aggregation = self.aggregation;
12759            egui::ComboBox::from_label("agg")
12760                .selected_text(match aggregation {
12761                    AggregationMode::None => "none",
12762                    AggregationMode::Max => "max",
12763                    AggregationMode::Mean => "mean",
12764                    AggregationMode::Min => "min",
12765                })
12766                .show_ui(ui, |ui| {
12767                    ui.selectable_value(&mut aggregation, AggregationMode::None, "none");
12768                    ui.selectable_value(&mut aggregation, AggregationMode::Max, "max");
12769                    ui.selectable_value(&mut aggregation, AggregationMode::Mean, "mean");
12770                    ui.selectable_value(&mut aggregation, AggregationMode::Min, "min");
12771                });
12772
12773            // Per-axis block factors (silx level-of-detail (lodx, lody)).
12774            let mut block = self.aggregation_block;
12775            let bx = ui.add(
12776                egui::DragValue::new(&mut block.0)
12777                    .range(1..=64)
12778                    .prefix("bx "),
12779            );
12780            let by = ui.add(
12781                egui::DragValue::new(&mut block.1)
12782                    .range(1..=64)
12783                    .prefix("by "),
12784            );
12785            if aggregation != self.aggregation || bx.changed() || by.changed() {
12786                self.set_aggregation(aggregation, block);
12787            }
12788        });
12789    }
12790
12791    /// Render the currently selected frame.
12792    pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
12793        if self.dirty && !self.frames.is_empty() {
12794            let frame = &self.frames[self.current_frame];
12795            // The calibrated origin/scale (silx `_stackItem.setOrigin/setScale`
12796            // from `_getImageOrigin/Scale`) and the per-frame block aggregation
12797            // (silx `_stackItem.setAggregationMode`) both ride on the spec, so a
12798            // calibration or aggregation-mode change re-applies on every frame.
12799            let (origin, scale) = calibrated_image_geometry(self.perspective, &self.calibrations);
12800            let mut spec = ImageSpec::scalar(self.width, self.height, frame, self.colormap.clone());
12801            spec.origin = origin;
12802            spec.scale = scale;
12803            spec.aggregation = self.aggregation;
12804            spec.aggregation_block = self.aggregation_block;
12805            if let Some(handle) = self.image_handle {
12806                self.inner.update_image_spec(handle, spec);
12807            } else {
12808                self.image_handle = Some(self.inner.add_image_spec(spec));
12809            }
12810            self.dirty = false;
12811        }
12812        let response = self.inner.show(ui);
12813        // Profile3D tool: a drag on the image extracts a profile (1D current
12814        // frame or 2D stacked over all frames) and shows it in the matching
12815        // side window (silx `Profile3DToolBar`).
12816        self.handle_profile_drag(&response);
12817        self.profile_window.show(ui.ctx());
12818        self.stack_profile_window.show(ui.ctx());
12819        response
12820    }
12821}
12822
12823impl Deref for StackView {
12824    type Target = Plot2D;
12825
12826    fn deref(&self) -> &Self::Target {
12827        &self.inner
12828    }
12829}
12830
12831impl DerefMut for StackView {
12832    fn deref_mut(&mut self) -> &mut Self::Target {
12833        &mut self.inner
12834    }
12835}
12836
12837// ─────────────────────────────────────────────────────────────────────────────
12838
12839/// Short human-readable description of a single ROI for the ROI manager table.
12840fn roi_description(roi: &Roi) -> String {
12841    match roi {
12842        Roi::Rect { x, y } => format!(
12843            "Rect  x=[{:.3}, {:.3}]  y=[{:.3}, {:.3}]",
12844            x.0, x.1, y.0, y.1
12845        ),
12846        Roi::HRange { y } => format!("HRange  y=[{:.3}, {:.3}]", y.0, y.1),
12847        Roi::VRange { x } => format!("VRange  x=[{:.3}, {:.3}]", x.0, x.1),
12848        Roi::HLine { y } => format!("HLine  y={y:.3}"),
12849        Roi::VLine { x } => format!("VLine  x={x:.3}"),
12850        Roi::Point { x, y } => format!("Point  ({x:.3}, {y:.3})"),
12851        Roi::Line { start, end } => format!(
12852            "Line  ({:.3},{:.3}) → ({:.3},{:.3})",
12853            start.0, start.1, end.0, end.1
12854        ),
12855        Roi::Polygon { vertices } => format!("Polygon  {} vertices", vertices.len()),
12856        Roi::Cross { center } => format!("Cross  ({:.3}, {:.3})", center.0, center.1),
12857        Roi::Circle { center, radius } => {
12858            format!(
12859                "Circle  c=({:.3}, {:.3})  r={radius:.3}",
12860                center.0, center.1
12861            )
12862        }
12863        Roi::Ellipse {
12864            center,
12865            radii,
12866            orientation,
12867        } => format!(
12868            "Ellipse  c=({:.3}, {:.3})  r=({:.3}, {:.3})  θ={:.1}°",
12869            center.0,
12870            center.1,
12871            radii.0,
12872            radii.1,
12873            orientation.to_degrees()
12874        ),
12875        Roi::Arc {
12876            center,
12877            inner_radius,
12878            outer_radius,
12879            start_angle,
12880            end_angle,
12881        } => format!(
12882            "Arc  c=({:.3}, {:.3})  r=[{:.3}, {:.3}]  θ=[{:.3}, {:.3}]",
12883            center.0, center.1, inner_radius, outer_radius, start_angle, end_angle
12884        ),
12885        Roi::Band { begin, end, width } => format!(
12886            "Band  ({:.3},{:.3}) → ({:.3},{:.3})  w={width:.3}",
12887            begin.0, begin.1, end.0, end.1
12888        ),
12889    }
12890}
12891
12892#[cfg(test)]
12893mod tests {
12894    use super::*;
12895
12896    /// A row-major `[2, 3, 4]` volume whose element `(i, j, k)` encodes its own
12897    /// indices as `100*i + 10*j + k`, so a sliced frame is easy to verify.
12898    fn sample_volume() -> (Vec<f32>, [usize; 3]) {
12899        let shape = [2usize, 3, 4];
12900        let [d0, d1, d2] = shape;
12901        let mut data = vec![0.0f32; d0 * d1 * d2];
12902        for i in 0..d0 {
12903            for j in 0..d1 {
12904                for k in 0..d2 {
12905                    data[(i * d1 + j) * d2 + k] = (100 * i + 10 * j + k) as f32;
12906                }
12907            }
12908        }
12909        (data, shape)
12910    }
12911
12912    #[test]
12913    fn stack_frame_count_is_the_browsed_dimension() {
12914        let shape = [2usize, 3, 4];
12915        assert_eq!(stack_frame_count(shape, StackPerspective::Axis0), 2);
12916        assert_eq!(stack_frame_count(shape, StackPerspective::Axis1), 3);
12917        assert_eq!(stack_frame_count(shape, StackPerspective::Axis2), 4);
12918    }
12919
12920    #[test]
12921    fn stack_perspective_display_axes_are_non_browsed_ascending() {
12922        assert_eq!(StackPerspective::Axis0.display_axes(), (1, 2));
12923        assert_eq!(StackPerspective::Axis1.display_axes(), (0, 2));
12924        assert_eq!(StackPerspective::Axis2.display_axes(), (0, 1));
12925    }
12926
12927    #[test]
12928    fn stack_frame_axis0_browses_dim0_without_transpose() {
12929        let (data, shape) = sample_volume();
12930        // Browse d0 at index 1: frame is (d1=3, d2=4) = (height, width).
12931        let (w, h, pixels) = stack_frame(&data, shape, StackPerspective::Axis0, 1).unwrap();
12932        assert_eq!((w, h), (4, 3));
12933        assert_eq!(
12934            pixels,
12935            vec![
12936                100.0, 101.0, 102.0, 103.0, 110.0, 111.0, 112.0, 113.0, 120.0, 121.0, 122.0, 123.0
12937            ]
12938        );
12939    }
12940
12941    #[test]
12942    fn stack_frame_axis1_transposes_1_0_2() {
12943        let (data, shape) = sample_volume();
12944        // Browse d1 at index 2: frame is (d0=2, d2=4) = (height, width).
12945        let (w, h, pixels) = stack_frame(&data, shape, StackPerspective::Axis1, 2).unwrap();
12946        assert_eq!((w, h), (4, 2));
12947        assert_eq!(
12948            pixels,
12949            vec![20.0, 21.0, 22.0, 23.0, 120.0, 121.0, 122.0, 123.0]
12950        );
12951    }
12952
12953    #[test]
12954    fn stack_frame_axis2_transposes_2_0_1() {
12955        let (data, shape) = sample_volume();
12956        // Browse d2 at index 3: frame is (d0=2, d1=3) = (height, width).
12957        let (w, h, pixels) = stack_frame(&data, shape, StackPerspective::Axis2, 3).unwrap();
12958        assert_eq!((w, h), (3, 2));
12959        assert_eq!(pixels, vec![3.0, 13.0, 23.0, 103.0, 113.0, 123.0]);
12960    }
12961
12962    #[test]
12963    fn stack_frame_rejects_length_mismatch_and_out_of_range() {
12964        let (data, shape) = sample_volume();
12965        // Wrong flat length for the declared shape.
12966        assert!(stack_frame(&data[..23], shape, StackPerspective::Axis0, 0).is_none());
12967        // Index past the browsed dimension (d0 == 2, so index 2 is out of range).
12968        assert!(stack_frame(&data, shape, StackPerspective::Axis0, 2).is_none());
12969    }
12970
12971    #[test]
12972    fn stack_aligned_profile_horizontal_stacks_each_frame_row() {
12973        let (data, shape) = sample_volume(); // [2,3,4], value = 100*i + 10*j + k
12974        // Axis0: 2 frames (i), each 3 rows (j) × 4 cols (k). Row 1 of every frame.
12975        let sp = stack_aligned_profile(
12976            &data,
12977            shape,
12978            StackPerspective::Axis0,
12979            1.0,
12980            1,
12981            true,
12982            ProfileMethod::Mean,
12983        )
12984        .unwrap();
12985        assert_eq!(sp.frame_count, 2);
12986        assert_eq!(sp.profile_len, 4);
12987        // Frame 0 row 1 = [10,11,12,13]; frame 1 row 1 = [110,111,112,113].
12988        assert_eq!(
12989            sp.values,
12990            vec![10.0, 11.0, 12.0, 13.0, 110.0, 111.0, 112.0, 113.0]
12991        );
12992    }
12993
12994    #[test]
12995    fn stack_line_profile_separates_frames() {
12996        let (data, shape) = sample_volume();
12997        // A segment along row 0 from col 0 to col 3, profiled over both frames.
12998        let sp = stack_line_profile(
12999            &data,
13000            shape,
13001            StackPerspective::Axis0,
13002            (0.0, 0.0),
13003            (3.0, 0.0),
13004        )
13005        .unwrap();
13006        assert_eq!(sp.frame_count, 2);
13007        assert!(sp.profile_len > 0);
13008        assert_eq!(sp.values.len(), 2 * sp.profile_len);
13009        // Frame 0 values come from the 0..23 block, frame 1 from 100..123.
13010        let (frame0, frame1) = sp.values.split_at(sp.profile_len);
13011        assert!(frame0.iter().all(|&v| v < 100.0), "frame0 {frame0:?}");
13012        assert!(frame1.iter().all(|&v| v >= 100.0), "frame1 {frame1:?}");
13013    }
13014
13015    #[test]
13016    fn stack_profile_rejects_shape_mismatch() {
13017        let (data, shape) = sample_volume();
13018        assert!(
13019            stack_aligned_profile(
13020                &data[..23],
13021                shape,
13022                StackPerspective::Axis0,
13023                1.0,
13024                1,
13025                true,
13026                ProfileMethod::Mean,
13027            )
13028            .is_none()
13029        );
13030    }
13031
13032    #[test]
13033    fn stack_profile_empty_stack_is_none() {
13034        // A browsed dimension of length 0 yields no frames.
13035        assert!(
13036            stack_aligned_profile(
13037                &[],
13038                [0, 3, 4],
13039                StackPerspective::Axis0,
13040                0.0,
13041                1,
13042                true,
13043                ProfileMethod::Mean,
13044            )
13045            .is_none()
13046        );
13047    }
13048
13049    #[test]
13050    fn dimension_axis_labels_use_width_for_x_and_height_for_y() {
13051        let labels = ["z".to_string(), "y".to_string(), "x".to_string()];
13052        // X uses the width axis's label, Y the height axis's label.
13053        assert_eq!(
13054            dimension_axis_labels(StackPerspective::Axis0, &labels),
13055            ("x".to_string(), "y".to_string())
13056        );
13057        assert_eq!(
13058            dimension_axis_labels(StackPerspective::Axis1, &labels),
13059            ("x".to_string(), "z".to_string())
13060        );
13061        assert_eq!(
13062            dimension_axis_labels(StackPerspective::Axis2, &labels),
13063            ("y".to_string(), "z".to_string())
13064        );
13065    }
13066
13067    #[test]
13068    fn calibrations_axes_order_maps_x_to_width_y_to_height_z_to_browsed() {
13069        // Distinct calibrations per dimension so the reorder is unambiguous.
13070        let calibs = [
13071            Calibration::linear(0.0, 1.0),   // dim0
13072            Calibration::linear(10.0, 2.0),  // dim1
13073            Calibration::linear(100.0, 3.0), // dim2
13074        ];
13075        // Axis0: non-browsed (1, 2) -> Y = min(=dim1), X = max(=dim2), Z = dim0.
13076        let (x, y, z) = calibrations_axes_order(StackPerspective::Axis0, &calibs);
13077        assert_eq!(x, calibs[2]);
13078        assert_eq!(y, calibs[1]);
13079        assert_eq!(z, calibs[0]);
13080        // Axis1: non-browsed (0, 2) -> Y = dim0, X = dim2, Z = dim1.
13081        let (x, y, z) = calibrations_axes_order(StackPerspective::Axis1, &calibs);
13082        assert_eq!(x, calibs[2]);
13083        assert_eq!(y, calibs[0]);
13084        assert_eq!(z, calibs[1]);
13085        // Axis2: non-browsed (0, 1) -> Y = dim0, X = dim1, Z = dim2.
13086        let (x, y, z) = calibrations_axes_order(StackPerspective::Axis2, &calibs);
13087        assert_eq!(x, calibs[1]);
13088        assert_eq!(y, calibs[0]);
13089        assert_eq!(z, calibs[2]);
13090    }
13091
13092    #[test]
13093    fn calibrated_image_geometry_uses_intercept_for_origin_and_slope_for_scale() {
13094        // dim1 (Y for Axis0): 10 + 2x  -> origin.y = 10, scale.y = 2
13095        // dim2 (X for Axis0): 100 + 3x -> origin.x = 100, scale.x = 3
13096        let calibs = [
13097            Calibration::None,
13098            Calibration::linear(10.0, 2.0),
13099            Calibration::linear(100.0, 3.0),
13100        ];
13101        let (origin, scale) = calibrated_image_geometry(StackPerspective::Axis0, &calibs);
13102        assert_eq!(origin, (100.0, 10.0));
13103        assert_eq!(scale, (3.0, 2.0));
13104    }
13105
13106    #[test]
13107    fn calibrated_image_geometry_defaults_to_identity() {
13108        let calibs = [Calibration::None; 3];
13109        let (origin, scale) = calibrated_image_geometry(StackPerspective::Axis0, &calibs);
13110        assert_eq!(origin, (0.0, 0.0));
13111        assert_eq!(scale, (1.0, 1.0));
13112    }
13113
13114    #[test]
13115    fn calibrated_image_z_applies_browsed_dim_calibration() {
13116        // Browsed dim (Z) is dim0 for Axis0: z(index) = 5 + 0.5*index.
13117        let calibs = [
13118            Calibration::linear(5.0, 0.5),
13119            Calibration::None,
13120            Calibration::None,
13121        ];
13122        assert_eq!(calibrated_image_z(0, StackPerspective::Axis0, &calibs), 5.0);
13123        assert_eq!(calibrated_image_z(4, StackPerspective::Axis0, &calibs), 7.0);
13124        // Identity Z when the browsed dim is uncalibrated.
13125        assert_eq!(calibrated_image_z(4, StackPerspective::Axis1, &calibs), 4.0);
13126    }
13127
13128    #[test]
13129    fn default_dimension_labels_drive_axis_labels_per_perspective() {
13130        let labels = [
13131            default_dimension_label(0),
13132            default_dimension_label(1),
13133            default_dimension_label(2),
13134        ];
13135        assert_eq!(labels, ["Dimension 0", "Dimension 1", "Dimension 2"]);
13136        // Axis0: X = width axis (dim2), Y = height axis (dim1).
13137        assert_eq!(
13138            dimension_axis_labels(StackPerspective::Axis0, &labels),
13139            ("Dimension 2".to_string(), "Dimension 1".to_string())
13140        );
13141        // Axis2: X = width axis (dim1), Y = height axis (dim0).
13142        assert_eq!(
13143            dimension_axis_labels(StackPerspective::Axis2, &labels),
13144            ("Dimension 1".to_string(), "Dimension 0".to_string())
13145        );
13146    }
13147
13148    #[test]
13149    fn ordered_limits_swaps_reversed_bounds() {
13150        // Already ordered: returned unchanged.
13151        assert_eq!(ordered_limits(1.0, 5.0), (1.0, 5.0));
13152        // Reversed: swapped so min ≤ max (silx LimitsToolBar swap).
13153        assert_eq!(ordered_limits(5.0, 1.0), (1.0, 5.0));
13154        // Equal: unchanged.
13155        assert_eq!(ordered_limits(2.0, 2.0), (2.0, 2.0));
13156    }
13157
13158    #[test]
13159    fn scatter_pick_returns_nearest_point_within_radius() {
13160        // Pixel positions relative to a cursor at the origin.
13161        let points = [(10.0, 0.0), (3.0, 4.0), (100.0, 100.0)];
13162        // radius 8: (10,0) d=10 out, (3,4) d=5 in, (100,100) out → index 1.
13163        assert_eq!(scatter_pick_pixels((0.0, 0.0), &points, 8.0), Some(1));
13164    }
13165
13166    #[test]
13167    fn scatter_pick_none_when_all_outside_radius() {
13168        let points = [(100.0, 0.0), (0.0, 100.0)];
13169        assert_eq!(scatter_pick_pixels((0.0, 0.0), &points, 8.0), None);
13170    }
13171
13172    #[test]
13173    fn scatter_pick_ties_resolve_to_highest_index() {
13174        // Two coincident points at equal distance — the top-most (last) wins.
13175        let points = [(5.0, 0.0), (5.0, 0.0)];
13176        assert_eq!(scatter_pick_pixels((0.0, 0.0), &points, 8.0), Some(1));
13177    }
13178
13179    #[test]
13180    fn nearest_candidate_in_data_picks_closest_then_highest_index() {
13181        let xs = [0.0, 1.0, 2.0, 3.0];
13182        let ys = [0.0, 0.0, 0.0, 0.0];
13183        // Cursor near x=1.9: among the bin's candidates {0,1,2}, index 2 (x=2) is
13184        // closest in data space.
13185        assert_eq!(
13186            nearest_candidate_in_data(&[0, 1, 2], &xs, &ys, 1.9, 0.0),
13187            Some(2)
13188        );
13189        // Empty candidate set yields no pick (silx returns None).
13190        assert_eq!(nearest_candidate_in_data(&[], &xs, &ys, 0.0, 0.0), None);
13191    }
13192
13193    #[test]
13194    fn nearest_candidate_in_data_ties_resolve_to_highest_index() {
13195        // Two coincident candidates equidistant from the cursor: the higher index
13196        // wins, matching silx's reversed-order argmin (ScatterView.py:197-204).
13197        let xs = [5.0, 5.0];
13198        let ys = [0.0, 0.0];
13199        assert_eq!(
13200            nearest_candidate_in_data(&[0, 1], &xs, &ys, 0.0, 0.0),
13201            Some(1)
13202        );
13203    }
13204
13205    #[test]
13206    fn scatter_position_info_snaps_to_pick() {
13207        let pick = Some(ScatterPick {
13208            index: 7,
13209            x: 1.5,
13210            y: 2.5,
13211            value: 3.5,
13212        });
13213        // X/Y snap to the pick (ignoring the bare cursor), Data/Index show it.
13214        let cols = scatter_position_info(pick).values(Some([9.0, 9.0]));
13215        assert_eq!(cols, vec!["1.5", "2.5", "3.5", "7"]);
13216    }
13217
13218    #[test]
13219    fn scatter_position_info_falls_back_without_pick() {
13220        // No pick: X/Y show the cursor, Data/Index show "-".
13221        let cols = scatter_position_info(None).values(Some([1.5, 2.5]));
13222        assert_eq!(cols, vec!["1.5", "2.5", "-", "-"]);
13223        // No cursor at all: every column is the silx placeholder.
13224        let cols = scatter_position_info(None).values(None);
13225        assert_eq!(cols, vec!["------", "------", "------", "------"]);
13226    }
13227
13228    #[test]
13229    fn active_axis_label_overrides_routes_y_by_axis() {
13230        use crate::core::transform::YAxis;
13231        // Left-axis curve: X drives the X axis, Y drives the left Y axis, y2 unset.
13232        assert_eq!(
13233            active_axis_label_overrides(Some("Time"), Some("Counts"), YAxis::Left),
13234            (Some("Time".to_string()), Some("Counts".to_string()), None)
13235        );
13236        // Right-axis curve: X drives the X axis, Y drives the y2 axis, left Y unset.
13237        assert_eq!(
13238            active_axis_label_overrides(Some("Time"), Some("Counts"), YAxis::Right),
13239            (Some("Time".to_string()), None, Some("Counts".to_string()))
13240        );
13241        // Missing per-curve labels pass through as None on every axis (the plot
13242        // then falls back to the graph defaults).
13243        assert_eq!(
13244            active_axis_label_overrides(None, None, YAxis::Left),
13245            (None, None, None)
13246        );
13247    }
13248
13249    #[test]
13250    fn set_symbol_visibility_hide_then_show_is_lossless() {
13251        // silx checkable Points: hiding a Diamond then showing must restore the
13252        // SAME variant (Diamond), not a default. The cache holds it meanwhile.
13253        let mut cache = None;
13254        let hidden = set_symbol_visibility(Some(Symbol::Diamond), false, &mut cache);
13255        assert_eq!(hidden, None);
13256        assert_eq!(cache, Some(Symbol::Diamond));
13257        let shown = set_symbol_visibility(hidden, true, &mut cache);
13258        assert_eq!(shown, Some(Symbol::Diamond));
13259        // The cache was consumed on restore.
13260        assert_eq!(cache, None);
13261    }
13262
13263    #[test]
13264    fn set_line_visibility_hide_then_show_is_lossless() {
13265        // silx checkable Lines: hiding a Dashed line then showing must restore
13266        // Dashed, not Solid. The cache holds the exact style meanwhile.
13267        let mut cache = None;
13268        let hidden = set_line_visibility(LineStyle::Dashed, false, &mut cache);
13269        assert_eq!(hidden, LineStyle::None);
13270        assert_eq!(cache, Some(LineStyle::Dashed));
13271        let shown = set_line_visibility(hidden, true, &mut cache);
13272        assert_eq!(shown, LineStyle::Dashed);
13273        assert_eq!(cache, None);
13274    }
13275
13276    #[test]
13277    fn curve_legend_visual_carries_line_style_and_symbol() {
13278        // The legend icon must reflect the curve's own line style and marker
13279        // (silx LegendIcon built from the curve's CurveStyle), so the side panel
13280        // shows dashed / dotted / solid + the symbol, not just the color.
13281        let x = [0.0, 1.0];
13282        let y = [0.0, 1.0];
13283
13284        let mut dashed = CurveSpec::new(&x, &y, Color32::RED);
13285        dashed.line_style = LineStyle::Dashed;
13286        dashed.symbol = Some(Symbol::Square);
13287        let v = curve_spec_legend_visual(&dashed, PlotItemKind::Curve);
13288        assert_eq!(v.color, Color32::RED);
13289        assert_eq!(v.line_style, LineStyle::Dashed);
13290        assert_eq!(v.symbol, Some(Symbol::Square));
13291
13292        // Marker-only (no line): the icon draws no line and shows the marker.
13293        let mut markers = CurveSpec::new(&x, &y, Color32::BLUE);
13294        markers.line_style = LineStyle::None;
13295        markers.symbol = Some(Symbol::Circle);
13296        let v = curve_spec_legend_visual(&markers, PlotItemKind::Scatter);
13297        assert_eq!(v.line_style, LineStyle::None);
13298        assert!(!v.line_style.draws_line());
13299        assert_eq!(v.symbol, Some(Symbol::Circle));
13300
13301        // A plain solid curve: solid line, no marker (the CurveSpec defaults).
13302        let v =
13303            curve_spec_legend_visual(&CurveSpec::new(&x, &y, Color32::GREEN), PlotItemKind::Curve);
13304        assert_eq!(v.line_style, LineStyle::Solid);
13305        assert_eq!(v.symbol, None);
13306    }
13307
13308    #[test]
13309    fn set_symbol_visibility_no_op_when_already_in_state() {
13310        // Show while already visible: no change, cache untouched (a stale stash
13311        // from a prior hide must not be clobbered or consumed).
13312        let mut cache = Some(Symbol::Square);
13313        let out = set_symbol_visibility(Some(Symbol::Circle), true, &mut cache);
13314        assert_eq!(out, Some(Symbol::Circle));
13315        assert_eq!(cache, Some(Symbol::Square));
13316
13317        // Hide while already hidden: no change, cache not clobbered.
13318        let mut cache = Some(Symbol::Diamond);
13319        let out = set_symbol_visibility(None, false, &mut cache);
13320        assert_eq!(out, None);
13321        assert_eq!(cache, Some(Symbol::Diamond));
13322    }
13323
13324    #[test]
13325    fn set_line_visibility_no_op_when_already_in_state() {
13326        // Show while already drawing a line: no change, cache untouched.
13327        let mut cache = Some(LineStyle::Dotted);
13328        let out = set_line_visibility(LineStyle::Dashed, true, &mut cache);
13329        assert_eq!(out, LineStyle::Dashed);
13330        assert_eq!(cache, Some(LineStyle::Dotted));
13331
13332        // Hide while already hidden (LineStyle::None): no change, cache kept.
13333        let mut cache = Some(LineStyle::Dashed);
13334        let out = set_line_visibility(LineStyle::None, false, &mut cache);
13335        assert_eq!(out, LineStyle::None);
13336        assert_eq!(cache, Some(LineStyle::Dashed));
13337    }
13338
13339    fn highlight_base() -> CurveData {
13340        // A fully-distinct base style so any spurious override is detectable.
13341        let mut base = CurveData::new(vec![0.0, 1.0], vec![0.0, 1.0], Color32::RED);
13342        base.width = 1.0;
13343        base.line_style = LineStyle::Solid;
13344        base.symbol = Some(Symbol::Circle);
13345        base.marker_size = 7.0;
13346        base.gap_color = Some(Color32::BLUE);
13347        base
13348    }
13349
13350    #[test]
13351    fn current_curve_style_not_highlighted_returns_base_unchanged() {
13352        // silx getCurrentStyle: when not highlighted, the resolved style is the
13353        // curve's own fields verbatim, regardless of the highlight override.
13354        let base = highlight_base();
13355        let highlight = CurveStyle {
13356            color: Some(Color32::GREEN),
13357            line_width: Some(9.0),
13358            line_style: Some(LineStyle::Dashed),
13359            symbol: Some(Symbol::Square),
13360            symbol_size: Some(99.0),
13361            gap_color: Some(Color32::WHITE),
13362        };
13363        let resolved = current_curve_style(&base, &highlight, false);
13364        assert_eq!(resolved.color, base.color);
13365        assert_eq!(resolved.width, base.width);
13366        assert_eq!(resolved.line_style, base.line_style);
13367        assert_eq!(resolved.symbol, base.symbol);
13368        assert_eq!(resolved.marker_size, base.marker_size);
13369        assert_eq!(resolved.gap_color, base.gap_color);
13370    }
13371
13372    #[test]
13373    fn current_curve_style_default_highlight_overrides_only_width() {
13374        // silx DEFAULT highlight (linewidth=2, all else None) on a width-1 base:
13375        // width becomes 2.0; every other style field falls through to the base
13376        // (the no-op-on-unset-fields proof).
13377        let base = highlight_base();
13378        let highlight = CurveStyle {
13379            line_width: Some(2.0),
13380            ..CurveStyle::default()
13381        };
13382        let resolved = current_curve_style(&base, &highlight, true);
13383        assert_eq!(resolved.width, 2.0);
13384        assert_eq!(resolved.color, base.color);
13385        assert_eq!(resolved.line_style, base.line_style);
13386        assert_eq!(resolved.symbol, base.symbol);
13387        assert_eq!(resolved.marker_size, base.marker_size);
13388        assert_eq!(resolved.gap_color, base.gap_color);
13389    }
13390
13391    #[test]
13392    fn current_curve_style_per_field_override_color_and_line_style_only() {
13393        // silx per-field merge: only the Some fields win; line_width None falls
13394        // through to the base width.
13395        let base = highlight_base();
13396        let highlight = CurveStyle {
13397            color: Some(Color32::GREEN),
13398            line_style: Some(LineStyle::Dashed),
13399            ..CurveStyle::default()
13400        };
13401        let resolved = current_curve_style(&base, &highlight, true);
13402        assert_eq!(resolved.color, Color32::GREEN);
13403        assert_eq!(resolved.line_style, LineStyle::Dashed);
13404        assert_eq!(resolved.width, base.width); // None inherits the base width.
13405        assert_eq!(resolved.symbol, base.symbol);
13406        assert_eq!(resolved.marker_size, base.marker_size);
13407        assert_eq!(resolved.gap_color, base.gap_color);
13408    }
13409
13410    #[test]
13411    fn default_active_curve_style_is_width_two_only() {
13412        // The PlotWidget default highlight is silx's: linewidth 2, all else None.
13413        let default_style = CurveStyle {
13414            line_width: Some(2.0),
13415            ..CurveStyle::default()
13416        };
13417        assert_eq!(default_style.line_width, Some(2.0));
13418        assert_eq!(default_style.color, None);
13419        assert_eq!(default_style.line_style, None);
13420        assert_eq!(default_style.symbol, None);
13421        assert_eq!(default_style.symbol_size, None);
13422        assert_eq!(default_style.gap_color, None);
13423    }
13424
13425    #[test]
13426    fn set_symbol_visibility_show_from_never_cached_uses_default() {
13427        // A curve created hidden (symbol None) with an empty cache: showing
13428        // falls back to the documented default Symbol::Point.
13429        let mut cache = None;
13430        let out = set_symbol_visibility(None, true, &mut cache);
13431        assert_eq!(out, Some(Symbol::Point));
13432        assert_eq!(out, Some(DEFAULT_RESTORE_SYMBOL));
13433        assert_eq!(cache, None);
13434    }
13435
13436    #[test]
13437    fn set_line_visibility_show_from_never_cached_uses_default() {
13438        // A curve created with no line (LineStyle::None) with an empty cache:
13439        // showing falls back to the documented default LineStyle::Solid.
13440        let mut cache = None;
13441        let out = set_line_visibility(LineStyle::None, true, &mut cache);
13442        assert_eq!(out, LineStyle::Solid);
13443        assert_eq!(out, DEFAULT_RESTORE_LINE_STYLE);
13444        assert_eq!(cache, None);
13445    }
13446
13447    #[test]
13448    fn clamp_alpha_clamps_out_of_range_entries() {
13449        // silx Scatter.setData clips alpha to [0, 1] (scatter.py:1058-1059):
13450        // >1 -> 1, <0 -> 0, in-range unchanged.
13451        assert_eq!(
13452            clamp_alpha(vec![1.5, -0.5, 0.25, 1.0, 0.0]),
13453            vec![1.0, 0.0, 0.25, 1.0, 0.0]
13454        );
13455    }
13456
13457    #[test]
13458    fn split_composite_vertical_splits_columns() {
13459        // 3x2 image; A all [1,..], B all [2,..]; split at column 2 (vertical
13460        // separator) -> cols 0,1 from A, col 2 from B (silx VERTICAL_LINE).
13461        let a = vec![[1u8, 1, 1, 1]; 6];
13462        let b = vec![[2u8, 2, 2, 2]; 6];
13463        let out = split_composite(&a, &b, 3, 2, 2, false);
13464        // Row 0: [A, A, B]; row 1: [A, A, B].
13465        assert_eq!(out[0], a[0]);
13466        assert_eq!(out[1], a[1]);
13467        assert_eq!(out[2], b[2]);
13468        assert_eq!(out[3], a[3]);
13469        assert_eq!(out[4], a[4]);
13470        assert_eq!(out[5], b[5]);
13471    }
13472
13473    #[test]
13474    fn split_composite_horizontal_splits_rows() {
13475        // 3x2 image; split at row 1 (horizontal separator) -> row 0 from A, row
13476        // 1 from B (silx HORIZONTAL_LINE: rows < pos show A).
13477        let a = vec![[1u8, 1, 1, 1]; 6];
13478        let b = vec![[2u8, 2, 2, 2]; 6];
13479        let out = split_composite(&a, &b, 3, 2, 1, true);
13480        // Row 0 (indices 0,1,2) from A; row 1 (3,4,5) from B.
13481        assert_eq!(&out[0..3], &[a[0], a[1], a[2]]);
13482        assert_eq!(&out[3..6], &[b[3], b[4], b[5]]);
13483    }
13484
13485    #[test]
13486    fn split_composite_extremes_show_one_image() {
13487        let a = vec![[1u8, 1, 1, 1]; 6];
13488        let b = vec![[2u8, 2, 2, 2]; 6];
13489        // split 0 -> all B (no column/row satisfies idx < 0).
13490        assert!(
13491            split_composite(&a, &b, 3, 2, 0, false)
13492                .iter()
13493                .all(|&p| p == b[0])
13494        );
13495        assert!(
13496            split_composite(&a, &b, 3, 2, 0, true)
13497                .iter()
13498                .all(|&p| p == b[0])
13499        );
13500        // split == axis length -> all A.
13501        assert!(
13502            split_composite(&a, &b, 3, 2, 3, false)
13503                .iter()
13504                .all(|&p| p == a[0])
13505        );
13506        assert!(
13507            split_composite(&a, &b, 3, 2, 2, true)
13508                .iter()
13509                .all(|&p| p == a[0])
13510        );
13511    }
13512
13513    #[test]
13514    fn red_blue_gray_composite_matches_silx_channel_layout() {
13515        // silx __composeRgbImage: R=a, G=a//2+b//2, B=b; NEG inverts each.
13516        // Assert the channel layout against the same normalize→byte step, so the
13517        // test is about the composition, not the colormap's exact bytes.
13518        let cm = Colormap::viridis(0.0, 1.0);
13519        let data_a = vec![0.0f32, 1.0];
13520        let data_b = vec![1.0f32, 0.0];
13521        let byte = |v: f32| (cm.normalize(v as f64) * 255.0).clamp(0.0, 255.0) as u8;
13522        let (a0, b0) = (byte(0.0), byte(1.0));
13523        let (a1, b1) = (byte(1.0), byte(0.0));
13524
13525        let pos = red_blue_gray_composite(&data_a, &data_b, &cm, false);
13526        assert_eq!(pos[0], [a0, a0 / 2 + b0 / 2, b0, 255]);
13527        assert_eq!(pos[1], [a1, a1 / 2 + b1 / 2, b1, 255]);
13528        // A drives red, B drives blue: pixel 0 (A=min, B=max) is blue-heavy.
13529        assert!(pos[0][2] > pos[0][0]);
13530        assert!(pos[1][0] > pos[1][2]);
13531
13532        let neg = red_blue_gray_composite(&data_a, &data_b, &cm, true);
13533        assert_eq!(neg[0], [255 - b0, 255 - (a0 / 2 + b0 / 2), 255 - a0, 255]);
13534        assert_eq!(neg[1], [255 - b1, 255 - (a1 / 2 + b1 / 2), 255 - a1, 255]);
13535    }
13536
13537    #[test]
13538    fn compose_per_point_alpha_multiplies_color_alpha() {
13539        // silx `rgbacolors[:, -1] *= __alpha`: a color with straight alpha 200
13540        // and per-point alpha 0.5 -> 100 (200 * 0.5). RGB is unchanged.
13541        let mut colors = vec![Color32::from_rgba_unmultiplied(10, 20, 30, 200)];
13542        compose_per_point_alpha(&mut colors, &[0.5]);
13543        assert_eq!(colors[0], Color32::from_rgba_unmultiplied(10, 20, 30, 100));
13544    }
13545
13546    #[test]
13547    fn compose_per_point_alpha_clamps_each_entry() {
13548        // An out-of-range alpha clamps to [0, 1] inside the compose step too:
13549        // 2.0 -> 1.0 (alpha unchanged at 200), -1.0 -> 0.0 (alpha 0).
13550        let mut colors = vec![
13551            Color32::from_rgba_unmultiplied(10, 20, 30, 200),
13552            Color32::from_rgba_unmultiplied(40, 50, 60, 200),
13553        ];
13554        compose_per_point_alpha(&mut colors, &[2.0, -1.0]);
13555        assert_eq!(colors[0], Color32::from_rgba_unmultiplied(10, 20, 30, 200));
13556        assert_eq!(colors[1], Color32::from_rgba_unmultiplied(40, 50, 60, 0));
13557    }
13558
13559    #[test]
13560    fn compose_per_point_alpha_handles_length_mismatch() {
13561        // alpha shorter than colors: the trailing colors keep their alpha
13562        // (composition runs over min(len), no panic).
13563        let mut colors = vec![
13564            Color32::from_rgba_unmultiplied(0, 0, 0, 200),
13565            Color32::from_rgba_unmultiplied(0, 0, 0, 200),
13566        ];
13567        compose_per_point_alpha(&mut colors, &[0.5]);
13568        assert_eq!(colors[0].a(), 100);
13569        assert_eq!(colors[1].a(), 200);
13570
13571        // alpha longer than colors: the extra entries are ignored, no panic.
13572        let mut colors = vec![Color32::from_rgba_unmultiplied(0, 0, 0, 200)];
13573        compose_per_point_alpha(&mut colors, &[0.5, 0.25, 0.1]);
13574        assert_eq!(colors[0].a(), 100);
13575    }
13576
13577    /// Build the `DataBounds` the widget would accumulate, with non-degenerate
13578    /// spans on every axis so `as_non_degenerate` does not pad.
13579    fn data_bounds(x: (f64, f64), y_left: (f64, f64), y_right: Option<(f64, f64)>) -> DataBounds {
13580        DataBounds {
13581            x: Some(Bounds1D::new(x.0, x.1).unwrap()),
13582            y_left: Some(Bounds1D::new(y_left.0, y_left.1).unwrap()),
13583            y_right: y_right.map(|(lo, hi)| Bounds1D::new(lo, hi).unwrap()),
13584            extra: Vec::new(),
13585        }
13586    }
13587
13588    /// Reproduce the exact composition `apply_limits_from_data_bounds` now
13589    /// performs on its model owner: map widget `DataBounds` -> `DataRange`, then
13590    /// apply through `Plot::reset_zoom_to_data_range`. `PlotWidget` itself needs
13591    /// a GPU `RenderState`, so this asserts the flag-aware behavior via the
13592    /// model owner the widget routes through.
13593    fn apply_widget_reset(plot: &mut Plot, bounds: DataBounds) {
13594        plot.reset_zoom_to_data_range(data_range_from_bounds(&bounds));
13595    }
13596
13597    #[test]
13598    fn widget_reset_keeps_x_and_refits_y_when_only_y_autoscale_on() {
13599        // x_autoscale OFF + y_autoscale ON: current X limits preserved, Y refit.
13600        let mut plot = Plot::new(0);
13601        plot.limits = (0.0, 1.0, 0.0, 1.0);
13602        plot.set_x_autoscale(false);
13603        plot.set_y_autoscale(true);
13604        apply_widget_reset(&mut plot, data_bounds((10.0, 20.0), (-5.0, 5.0), None));
13605        assert_eq!(plot.limits, (0.0, 1.0, -5.0, 5.0));
13606    }
13607
13608    #[test]
13609    fn widget_reset_keeps_y_and_refits_x_when_only_x_autoscale_on() {
13610        // x_autoscale ON + y_autoscale OFF: X refit, current Y limits preserved.
13611        let mut plot = Plot::new(0);
13612        plot.limits = (0.0, 1.0, 0.0, 1.0);
13613        plot.set_x_autoscale(true);
13614        plot.set_y_autoscale(false);
13615        apply_widget_reset(&mut plot, data_bounds((10.0, 20.0), (-5.0, 5.0), None));
13616        assert_eq!(plot.limits, (10.0, 20.0, 0.0, 1.0));
13617    }
13618
13619    #[test]
13620    fn widget_reset_with_all_autoscale_off_is_noop() {
13621        let mut plot = Plot::new(0);
13622        plot.limits = (0.0, 1.0, 0.0, 1.0);
13623        plot.y2 = Some((0.0, 2.0));
13624        plot.set_x_autoscale(false);
13625        plot.set_y_autoscale(false);
13626        plot.set_y2_autoscale(false);
13627        apply_widget_reset(
13628            &mut plot,
13629            data_bounds((10.0, 20.0), (-5.0, 5.0), Some((-1.0, 1.0))),
13630        );
13631        assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
13632        assert_eq!(plot.y2, Some((0.0, 2.0)));
13633    }
13634
13635    #[test]
13636    fn data_range_from_bounds_pads_degenerate_axis() {
13637        // A single-point X span pads via as_non_degenerate before reaching the
13638        // model, so a refit axis never gets a zero-width range.
13639        let bounds = DataBounds {
13640            x: Some(Bounds1D::new(4.0, 4.0).unwrap()),
13641            y_left: Some(Bounds1D::new(-1.0, 1.0).unwrap()),
13642            y_right: None,
13643            extra: Vec::new(),
13644        };
13645        let range = data_range_from_bounds(&bounds);
13646        let (xmin, xmax) = range.x.unwrap();
13647        assert!(xmax > xmin, "degenerate X must be padded: {xmin}..{xmax}");
13648        assert_eq!(range.y, Some((-1.0, 1.0)));
13649        assert_eq!(range.y2, None);
13650    }
13651
13652    #[test]
13653    fn raw_data_range_from_bounds_keeps_raw_bounds_unpadded() {
13654        // The data-range CACHE (silx getDataRange) holds the raw min/max: a
13655        // single data point reads as (v, v), NOT the as_non_degenerate padding
13656        // the refit path applies. An axis with no data stays None.
13657        let bounds = DataBounds {
13658            x: Some(Bounds1D::new(4.0, 4.0).unwrap()),
13659            y_left: Some(Bounds1D::new(-5.0, 5.0).unwrap()),
13660            y_right: None,
13661            extra: Vec::new(),
13662        };
13663        let range = raw_data_range_from_bounds(&bounds);
13664        assert_eq!(range.x, Some((4.0, 4.0)), "single point must stay (v, v)");
13665        assert_eq!(range.y, Some((-5.0, 5.0)));
13666        assert_eq!(range.y2, None);
13667    }
13668
13669    #[test]
13670    fn recompute_data_bounds_populates_live_data_range_cache() {
13671        // Reproduce the cache write `recompute_data_bounds` now performs on its
13672        // model owner (`PlotWidget` itself needs a GPU `RenderState`): every
13673        // content change pushes the raw bounds, so `Plot::data_range()` reflects
13674        // the data instead of reading as all-`None` (closes row 1028).
13675        let mut plot = Plot::new(0);
13676        assert_eq!(
13677            plot.data_range(),
13678            DataRange::default(),
13679            "empty before any data"
13680        );
13681        let bounds = data_bounds((10.0, 20.0), (-5.0, 5.0), Some((-1.0, 1.0)));
13682        plot.set_data_range(raw_data_range_from_bounds(&bounds));
13683        let range = plot.data_range();
13684        assert_eq!(range.x, Some((10.0, 20.0)));
13685        assert_eq!(range.y, Some((-5.0, 5.0)));
13686        assert_eq!(range.y2, Some((-1.0, 1.0)));
13687    }
13688
13689    #[test]
13690    fn save_to_path_dispatch_resolves_format_per_extension() {
13691        // save_to_path branches on SaveTarget::from_path; the GPU readback +
13692        // file write are shims, but the extension->target decision (which
13693        // SaveFormat each figure extension routes to, vs CSV) is pure. Assert
13694        // the full dispatch table this entry point relies on, without a GPU.
13695        use crate::widget::actions::io::SaveTarget;
13696
13697        assert_eq!(
13698            SaveTarget::from_path(Path::new("/tmp/fig.png")),
13699            Some(SaveTarget::Figure(SaveFormat::Png))
13700        );
13701        assert_eq!(
13702            SaveTarget::from_path(Path::new("/tmp/fig.ppm")),
13703            Some(SaveTarget::Figure(SaveFormat::Ppm))
13704        );
13705        assert_eq!(
13706            SaveTarget::from_path(Path::new("/tmp/fig.svg")),
13707            Some(SaveTarget::Figure(SaveFormat::Svg))
13708        );
13709        assert_eq!(
13710            SaveTarget::from_path(Path::new("/tmp/fig.tif")),
13711            Some(SaveTarget::Figure(SaveFormat::Tiff))
13712        );
13713        assert_eq!(
13714            SaveTarget::from_path(Path::new("/tmp/fig.tiff")),
13715            Some(SaveTarget::Figure(SaveFormat::Tiff))
13716        );
13717        assert_eq!(
13718            SaveTarget::from_path(Path::new("/tmp/fig.eps")),
13719            Some(SaveTarget::Figure(SaveFormat::Eps))
13720        );
13721        assert_eq!(
13722            SaveTarget::from_path(Path::new("/tmp/fig.pdf")),
13723            Some(SaveTarget::Figure(SaveFormat::Pdf))
13724        );
13725        assert_eq!(
13726            SaveTarget::from_path(Path::new("/tmp/curve.csv")),
13727            Some(SaveTarget::CurveCsv)
13728        );
13729        assert_eq!(
13730            SaveTarget::from_path(Path::new("/tmp/fig.jpeg")),
13731            Some(SaveTarget::Figure(SaveFormat::Jpeg))
13732        );
13733        // Still-unsupported (ps) / extensionless paths are not save targets,
13734        // so save_to_path returns Ok(false) for them.
13735        assert_eq!(SaveTarget::from_path(Path::new("/tmp/fig.ps")), None);
13736        assert_eq!(SaveTarget::from_path(Path::new("/tmp/noext")), None);
13737    }
13738
13739    #[test]
13740    fn print_temp_png_path_is_process_unique_under_dir() {
13741        // The print shim rasterizes into this temp path before submitting to the
13742        // printer; the GPU readback + submit are shims, but the naming is pure.
13743        let dir = Path::new("/tmp/siplot-test");
13744        let p = print_temp_png_path(dir, 4242);
13745        assert_eq!(p, Path::new("/tmp/siplot-test/siplot-print-4242.png"));
13746        // Always under the requested dir, always a .png, with the pid embedded so
13747        // concurrent plots / a copy in flight do not collide.
13748        assert!(p.starts_with(dir));
13749        assert_eq!(p.extension().and_then(|e| e.to_str()), Some("png"));
13750        assert_ne!(p, print_temp_png_path(dir, 4243));
13751    }
13752
13753    #[test]
13754    fn colorbar_column_width_reserves_only_when_shown_and_available() {
13755        // Shown and available: column reserved.
13756        assert_eq!(colorbar_column_width(true, true), COLORBAR_WIDTH);
13757        // Hidden: no column even when available.
13758        assert_eq!(colorbar_column_width(false, true), 0.0);
13759        // Shown but no colorbar available (e.g. ScatterView before data): none.
13760        assert_eq!(colorbar_column_width(true, false), 0.0);
13761        // Hidden and unavailable: none.
13762        assert_eq!(colorbar_column_width(false, false), 0.0);
13763    }
13764
13765    #[test]
13766    fn row_content_width_subtracts_columns_and_gaps() {
13767        // ImageView bottom row: histo_v + colorbar -> two gaps. The flexible
13768        // child plus columns plus gaps must exactly fill the available width
13769        // (sizing without the gaps overflowed the window and clipped the
13770        // colorbar labels).
13771        let w = row_content_width(1000.0, 200.0 + 175.0, 2, 8.0);
13772        assert_eq!(w + 200.0 + 175.0 + 2.0 * 8.0, 1000.0);
13773        // ScatterView row with the colorbar hidden: no columns, no gaps.
13774        assert_eq!(row_content_width(1000.0, 0.0, 0, 8.0), 1000.0);
13775        // Never negative, even when the columns exceed the available width.
13776        assert_eq!(row_content_width(100.0, 375.0, 2, 8.0), 0.0);
13777    }
13778
13779    #[test]
13780    fn side_histogram_extent_reserves_only_when_shown() {
13781        // Shown: the requested strip size is reserved verbatim.
13782        assert_eq!(side_histogram_extent(true, 200.0), 200.0);
13783        assert_eq!(side_histogram_extent(true, 80.0), 80.0);
13784        // Hidden: no space, regardless of the requested size.
13785        assert_eq!(side_histogram_extent(false, 200.0), 0.0);
13786        assert_eq!(side_histogram_extent(false, 80.0), 0.0);
13787    }
13788
13789    // ── ImageView side-histogram profile sums (silx getHistogram data) ────────
13790
13791    #[test]
13792    fn image_column_and_row_sums_match_silx_profile() {
13793        // 3×2 row-major image:
13794        //   row 0: 1 2 3
13795        //   row 1: 4 5 6
13796        let pixels = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
13797        let (w, h) = (3usize, 2usize);
13798        // histoH = per-column sums over rows: [1+4, 2+5, 3+6].
13799        assert_eq!(image_column_sums(&pixels, w, h), vec![5.0, 7.0, 9.0]);
13800        // histoV = per-row sums over columns: [1+2+3, 4+5+6].
13801        assert_eq!(image_row_sums(&pixels, w, h), vec![6.0, 15.0]);
13802    }
13803
13804    #[test]
13805    fn image_profile_sums_have_one_entry_per_index() {
13806        // The X profile has `width` entries, the Y profile `height` — matching
13807        // the extent silx reports as (0, width) / (0, height).
13808        let pixels = vec![1.0f32; 12]; // 4×3
13809        let (w, h) = (4usize, 3usize);
13810        assert_eq!(image_column_sums(&pixels, w, h).len(), w);
13811        assert_eq!(image_row_sums(&pixels, w, h).len(), h);
13812        // Uniform image: each column sums to h, each row to w.
13813        assert!(
13814            image_column_sums(&pixels, w, h)
13815                .iter()
13816                .all(|&s| s == h as f64)
13817        );
13818        assert!(image_row_sums(&pixels, w, h).iter().all(|&s| s == w as f64));
13819    }
13820
13821    // ── ImageView valueChanged pixel-under-cursor (silx _imagePlotCB) ─────────
13822
13823    #[test]
13824    fn image_value_at_maps_cursor_to_pixel_like_silx() {
13825        // 3×2 row-major image (row-major: pixels[row*w + col]):
13826        //   row 0: 1 2 3
13827        //   row 1: 4 5 6
13828        let pixels = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
13829        let (w, h) = (3usize, 2usize);
13830        // Cursor inside a cell: truncation toward zero picks the pixel index.
13831        // (col 0, row 0) → value 1; (col 2, row 1) → value 6.
13832        assert_eq!(
13833            image_value_at(0.4, 0.9, &pixels, w, h),
13834            Some((0.0, 0.0, 1.0))
13835        );
13836        assert_eq!(
13837            image_value_at(2.7, 1.2, &pixels, w, h),
13838            Some((2.0, 1.0, 6.0))
13839        );
13840        // Exact integer coordinate maps to that index.
13841        assert_eq!(
13842            image_value_at(1.0, 1.0, &pixels, w, h),
13843            Some((1.0, 1.0, 5.0))
13844        );
13845    }
13846
13847    #[test]
13848    fn image_value_at_returns_none_off_image() {
13849        let pixels = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
13850        let (w, h) = (3usize, 2usize);
13851        // Left of / below the origin (silx `x >= origin` guard).
13852        assert_eq!(image_value_at(-0.1, 0.5, &pixels, w, h), None);
13853        assert_eq!(image_value_at(0.5, -0.1, &pixels, w, h), None);
13854        // At or beyond the far edge (extent is exclusive at width/height).
13855        assert_eq!(image_value_at(3.0, 0.5, &pixels, w, h), None);
13856        assert_eq!(image_value_at(0.5, 2.0, &pixels, w, h), None);
13857        // No image loaded.
13858        assert_eq!(image_value_at(0.5, 0.5, &[], 0, 0), None);
13859    }
13860
13861    #[test]
13862    fn colorbar_toggle_frees_and_restores_the_column() {
13863        // image_colorbar_toggle / scatter_colorbar_toggle (actions::control) are
13864        // `set_show_colorbar(!show_colorbar())` on an ImageView/ScatterView, which
13865        // require a RenderState to construct. Their observable effect — whether the
13866        // side colorbar column is reserved — is the show_colorbar transition fed to
13867        // colorbar_column_width, exercised here without a GPU (the analog of
13868        // show_axis_toggle_flips_axes_displayed driving the bare Plot model).
13869        let mut show = true;
13870        assert_eq!(
13871            colorbar_column_width(show, true),
13872            COLORBAR_WIDTH,
13873            "shown reserves the column"
13874        );
13875        show = !show; // toggle hides
13876        assert_eq!(
13877            colorbar_column_width(show, true),
13878            0.0,
13879            "toggling off frees the column"
13880        );
13881        show = !show; // toggle shows again
13882        assert_eq!(
13883            colorbar_column_width(show, true),
13884            COLORBAR_WIDTH,
13885            "toggling back on reserves it again"
13886        );
13887    }
13888
13889    #[test]
13890    fn finite_bounds_ignores_non_finite_values() {
13891        let bounds = finite_bounds(&[f64::NAN, 2.0, -1.0, f64::INFINITY]).unwrap();
13892        assert_eq!(bounds.as_non_degenerate(), (-1.0, 2.0));
13893    }
13894
13895    #[test]
13896    fn degenerate_bounds_get_padded() {
13897        assert_eq!(
13898            Bounds1D::new(2.0, 2.0).unwrap().as_non_degenerate(),
13899            (1.5, 2.5)
13900        );
13901    }
13902
13903    #[test]
13904    fn data_bounds_tracks_left_and_right_y_separately() {
13905        let mut bounds = DataBounds::default();
13906        bounds.include(
13907            Bounds1D::new(0.0, 10.0).unwrap(),
13908            Bounds1D::new(-1.0, 1.0).unwrap(),
13909            YAxis::Left,
13910        );
13911        bounds.include(
13912            Bounds1D::new(5.0, 20.0).unwrap(),
13913            Bounds1D::new(100.0, 200.0).unwrap(),
13914            YAxis::Right,
13915        );
13916        assert_eq!(bounds.x.unwrap().as_non_degenerate(), (0.0, 20.0));
13917        assert_eq!(bounds.y_left.unwrap().as_non_degenerate(), (-1.0, 1.0));
13918        assert_eq!(bounds.y_right.unwrap().as_non_degenerate(), (100.0, 200.0));
13919    }
13920
13921    #[test]
13922    fn histogram_step_values_builds_closed_outline() {
13923        let (x, y) = histogram_step_values(&[0.0, 1.0, 3.0], &[2.0, 4.0]).unwrap();
13924        assert_eq!(x, vec![0.0, 0.0, 1.0, 1.0, 3.0, 3.0]);
13925        assert_eq!(y, vec![0.0, 2.0, 2.0, 4.0, 4.0, 0.0]);
13926    }
13927
13928    #[test]
13929    fn histogram_step_values_validates_edges() {
13930        assert_eq!(
13931            histogram_step_values(&[0.0, 1.0], &[2.0, 4.0]).unwrap_err(),
13932            PlotDataError::HistogramLength { bins: 2, edges: 2 }
13933        );
13934    }
13935
13936    #[test]
13937    fn histogram_edges_left_treats_positions_as_left_edges() {
13938        // Row 108: silx `_computeEdges(x, "right")` — append x[-1] + last gap.
13939        // Positions [0,1,2] (gap 1) -> edges [0,1,2,3].
13940        assert_eq!(
13941            histogram_edges(&[0.0, 1.0, 2.0], HistogramAlign::Left),
13942            vec![0.0, 1.0, 2.0, 3.0]
13943        );
13944    }
13945
13946    #[test]
13947    fn histogram_edges_right_treats_positions_as_right_edges() {
13948        // Row 108: silx `_computeEdges(x, "left")` — prepend x[0] - first gap.
13949        // Positions [1,2,3] (gap 1) -> edges [0,1,2,3].
13950        assert_eq!(
13951            histogram_edges(&[1.0, 2.0, 3.0], HistogramAlign::Right),
13952            vec![0.0, 1.0, 2.0, 3.0]
13953        );
13954    }
13955
13956    #[test]
13957    fn histogram_edges_center_puts_positions_at_bin_centres() {
13958        // Row 108: silx `_computeEdges(x, "center")` right-aligns then shifts each
13959        // edge left by half its following gap. Centres [1,2,3] -> [0.5,1.5,2.5,3.5].
13960        assert_eq!(
13961            histogram_edges(&[1.0, 2.0, 3.0], HistogramAlign::Center),
13962            vec![0.5, 1.5, 2.5, 3.5]
13963        );
13964    }
13965
13966    #[test]
13967    fn histogram_edges_single_position_uses_unit_gap() {
13968        // Row 108: a lone position uses silx's width = 1 fallback.
13969        assert_eq!(
13970            histogram_edges(&[5.0], HistogramAlign::Left),
13971            vec![5.0, 6.0]
13972        );
13973        assert_eq!(
13974            histogram_edges(&[5.0], HistogramAlign::Right),
13975            vec![4.0, 5.0]
13976        );
13977        assert_eq!(
13978            histogram_edges(&[5.0], HistogramAlign::Center),
13979            vec![4.5, 5.5]
13980        );
13981    }
13982
13983    #[test]
13984    fn histogram_edges_nonuniform_center_uses_following_gap() {
13985        // Row 108: with non-uniform spacing the centre rule shifts each edge by
13986        // half of its *following* right-aligned gap (last half-gap reused).
13987        // Positions [0, 1, 3]: right-align -> [0,1,3,5] (last gap 3->5 = 2);
13988        // half-gaps -> [0.5,1.0,1.0,1.0]; edges = [-0.5, 0.0, 2.0, 4.0].
13989        assert_eq!(
13990            histogram_edges(&[0.0, 1.0, 3.0], HistogramAlign::Center),
13991            vec![-0.5, 0.0, 2.0, 4.0]
13992        );
13993    }
13994
13995    #[test]
13996    fn histogram_edges_empty_is_empty() {
13997        assert!(histogram_edges(&[], HistogramAlign::Center).is_empty());
13998    }
13999
14000    #[test]
14001    fn pick_histogram_locates_bin_and_checks_fill() {
14002        // 3 bins on edges [0,1,2,3]; heights [2, -1, 3]; baseline 0.
14003        let edges = [0.0, 1.0, 2.0, 3.0];
14004        let values = [2.0, -1.0, 3.0];
14005        // Inside bin 0 (bar [0,2]): y between baseline and value hits.
14006        assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, 1.0), Some(0));
14007        // Bin 0, above the bar top (y 2.5 > value 2): miss.
14008        assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, 2.5), None);
14009        // Bin 1 points down (value -1): y in [-1, 0] hits.
14010        assert_eq!(pick_histogram(&edges, &values, 0.0, 1.5, -0.5), Some(1));
14011        // Bin 1, y above baseline while the bar is below it: miss.
14012        assert_eq!(pick_histogram(&edges, &values, 0.0, 1.5, 0.5), None);
14013        // Bin 2 (bar [0,3]): hit.
14014        assert_eq!(pick_histogram(&edges, &values, 0.0, 2.5, 2.0), Some(2));
14015    }
14016
14017    #[test]
14018    fn pick_histogram_outside_bbox_is_none() {
14019        let edges = [0.0, 1.0, 2.0, 3.0];
14020        let values = [2.0, 1.0, 3.0];
14021        // Left of xmin / right of xmax.
14022        assert_eq!(pick_histogram(&edges, &values, 0.0, -0.1, 1.0), None);
14023        assert_eq!(pick_histogram(&edges, &values, 0.0, 3.1, 1.0), None);
14024        // Below ymin (0) / above ymax (3).
14025        assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, -0.1), None);
14026        assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, 3.1), None);
14027        // Exactly on the box edge is excluded (strict bounds).
14028        assert_eq!(pick_histogram(&edges, &values, 0.0, 0.0, 1.0), None);
14029        // Malformed: edges/values length mismatch and empty input.
14030        assert_eq!(pick_histogram(&edges, &[1.0, 2.0], 0.0, 0.5, 1.0), None);
14031        assert_eq!(pick_histogram(&[], &[], 0.0, 0.5, 1.0), None);
14032    }
14033
14034    #[test]
14035    fn pick_histogram_honours_nonzero_baseline() {
14036        // Bars rise from baseline 5; bbox y-bounds = [min(0,3), max(0,8)] = [0,8].
14037        let edges = [0.0, 1.0, 2.0];
14038        let values = [8.0, 3.0];
14039        // Bin 0 bar [5,8]: y 6 hits.
14040        assert_eq!(pick_histogram(&edges, &values, 5.0, 0.5, 6.0), Some(0));
14041        // Bin 1 value 3 < baseline 5 → bar [3,5]: y 4 hits.
14042        assert_eq!(pick_histogram(&edges, &values, 5.0, 1.5, 4.0), Some(1));
14043        // Bin 0, y 4 below the bar [5,8]: miss.
14044        assert_eq!(pick_histogram(&edges, &values, 5.0, 0.5, 4.0), None);
14045    }
14046
14047    #[test]
14048    fn aligned_histogram_edges_feed_valid_step_values() {
14049        // Row 108: the path add_histogram_aligned takes (sans the GPU add) — N
14050        // positions + N counts derive N+1 edges that histogram_step_values
14051        // accepts. Centres [1,2,3] -> edges [0.5,1.5,2.5,3.5], stairs at counts.
14052        let positions = [1.0, 2.0, 3.0];
14053        let counts = [5.0, 6.0, 7.0];
14054        let edges = histogram_edges(&positions, HistogramAlign::Center);
14055        let (x, y) = histogram_step_values(&edges, &counts).unwrap();
14056        assert_eq!(x, vec![0.5, 0.5, 1.5, 1.5, 2.5, 2.5, 3.5, 3.5]);
14057        assert_eq!(y, vec![0.0, 5.0, 5.0, 6.0, 6.0, 7.0, 7.0, 0.0]);
14058    }
14059
14060    #[test]
14061    fn profile_helpers_extract_rows_and_columns() {
14062        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14063        assert_eq!(
14064            horizontal_profile_values(3, 2, &data, 1).unwrap(),
14065            vec![4.0, 5.0, 6.0]
14066        );
14067        assert_eq!(
14068            vertical_profile_values(3, 2, &data, 2).unwrap(),
14069            vec![3.0, 6.0]
14070        );
14071    }
14072
14073    #[test]
14074    fn aligned_profile_width_one_mean_matches_single_line() {
14075        // 3x2: row0 [1,2,3], row1 [4,5,6].
14076        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14077        // Horizontal, row 0, width 1, mean == horizontal_profile_values(row 0).
14078        assert_eq!(
14079            aligned_profile_values(3, 2, &data, 0.0, 1, true, ProfileMethod::Mean).unwrap(),
14080            vec![1.0, 2.0, 3.0]
14081        );
14082        // Vertical, column 1, width 1, mean == vertical_profile_values(col 1).
14083        assert_eq!(
14084            aligned_profile_values(3, 2, &data, 1.0, 1, false, ProfileMethod::Mean).unwrap(),
14085            vec![2.0, 5.0]
14086        );
14087    }
14088
14089    #[test]
14090    fn aligned_profile_full_band_mean_and_sum() {
14091        // 3x2 image; a full-height band reduces every row per column.
14092        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14093        // Horizontal, centered, width 2 (whole height): mean = column means.
14094        assert_eq!(
14095            aligned_profile_values(3, 2, &data, 0.5, 2, true, ProfileMethod::Mean).unwrap(),
14096            vec![2.5, 3.5, 4.5]
14097        );
14098        // Sum over the whole height equals the per-column sums.
14099        assert_eq!(
14100            aligned_profile_values(3, 2, &data, 0.5, 2, true, ProfileMethod::Sum).unwrap(),
14101            vec![5.0, 7.0, 9.0]
14102        );
14103        // Vertical, full-width sum equals per-row sums.
14104        assert_eq!(
14105            aligned_profile_values(3, 2, &data, 1.0, 3, false, ProfileMethod::Sum).unwrap(),
14106            vec![6.0, 15.0]
14107        );
14108    }
14109
14110    #[test]
14111    fn rect_profile_mean_and_sum_reduce_the_band() {
14112        // 3x2 image, rows: [1,2,3],[4,5,6]. Whole-rectangle horizontal profile.
14113        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14114        let rect = (0.0, 2.0, 0.0, 1.0);
14115        // Mean over the two rows per column.
14116        let (x, y) = rect_profile_values(3, 2, &data, rect, true, ProfileMethod::Mean).unwrap();
14117        assert_eq!(x, vec![0.0, 1.0, 2.0]);
14118        assert_eq!(y, vec![2.5, 3.5, 4.5]);
14119        // Sum over the two rows per column.
14120        let (_, y_sum) = rect_profile_values(3, 2, &data, rect, true, ProfileMethod::Sum).unwrap();
14121        assert_eq!(y_sum, vec![5.0, 7.0, 9.0]);
14122        // Vertical sum reduces along columns -> per-row totals.
14123        let (xr, yr) = rect_profile_values(3, 2, &data, rect, false, ProfileMethod::Sum).unwrap();
14124        assert_eq!(xr, vec![0.0, 1.0]);
14125        assert_eq!(yr, vec![6.0, 15.0]);
14126    }
14127
14128    #[test]
14129    fn line_profile_band_width_one_samples_along_row() {
14130        // 3x2: row 0 = [1,2,3], row 1 = [4,5,6]. Width-1 line along row 0.
14131        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14132        let (x, y) =
14133            line_profile_band(3, 2, &data, (0.0, 0.0), (2.0, 0.0), 1, ProfileMethod::Mean).unwrap();
14134        assert_eq!(x, vec![0.0, 1.0, 2.0]);
14135        assert_eq!(y, vec![1.0, 2.0, 3.0]);
14136    }
14137
14138    #[test]
14139    fn line_profile_band_width_two_averages_perpendicular_rows() {
14140        // Line centred at row 0.5 with linewidth 2 spans both rows; the band mean
14141        // matches the per-column average, the sum matches the per-column total.
14142        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14143        let (_, mean) =
14144            line_profile_band(3, 2, &data, (0.0, 0.5), (2.0, 0.5), 2, ProfileMethod::Mean).unwrap();
14145        assert_eq!(mean, vec![2.5, 3.5, 4.5]);
14146        let (_, sum) =
14147            line_profile_band(3, 2, &data, (0.0, 0.5), (2.0, 0.5), 2, ProfileMethod::Sum).unwrap();
14148        assert_eq!(sum, vec![5.0, 7.0, 9.0]);
14149    }
14150
14151    #[test]
14152    fn line_profile_band_vertical_width_one() {
14153        // Vertical width-1 line down column 0: samples rows 0 and 1.
14154        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14155        let (x, y) =
14156            line_profile_band(3, 2, &data, (0.0, 0.0), (0.0, 1.0), 1, ProfileMethod::Mean).unwrap();
14157        assert_eq!(x, vec![0.0, 1.0]);
14158        assert_eq!(y, vec![1.0, 4.0]);
14159    }
14160
14161    #[test]
14162    fn line_profile_band_diagonal_is_linear_on_gradient() {
14163        // 3x3 gradient 0..8; the main diagonal profile is linear (silx
14164        // test_profile_grad), i.e. equal successive differences.
14165        let data = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
14166        let (_, y) =
14167            line_profile_band(3, 3, &data, (0.0, 0.0), (2.0, 2.0), 1, ProfileMethod::Mean).unwrap();
14168        // length = sqrt(8) ~= 2.83 -> ceil(length + 1) = 4 samples.
14169        assert_eq!(y.len(), 4);
14170        let diffs: Vec<f64> = y.windows(2).map(|w| w[1] - w[0]).collect();
14171        for d in &diffs {
14172            assert!((d - diffs[0]).abs() < 1e-9, "diffs not constant: {diffs:?}");
14173        }
14174        // Endpoints are the exact corner pixels.
14175        assert!((y[0] - 0.0).abs() < 1e-9);
14176        assert!((y[3] - 8.0).abs() < 1e-9);
14177    }
14178
14179    #[test]
14180    fn line_profile_band_degenerate_returns_single_sample() {
14181        let data = [1.0, 2.0, 3.0, 4.0];
14182        let (x, y) =
14183            line_profile_band(2, 2, &data, (1.0, 1.0), (1.0, 1.0), 1, ProfileMethod::Mean).unwrap();
14184        assert_eq!(x, vec![0.0]);
14185        assert_eq!(y, vec![4.0]); // data[row 1, col 1] = 4
14186    }
14187
14188    #[test]
14189    fn line_profile_band_validates_length() {
14190        assert_eq!(
14191            line_profile_band(
14192                2,
14193                2,
14194                &[1.0, 2.0, 3.0],
14195                (0.0, 0.0),
14196                (1.0, 1.0),
14197                1,
14198                ProfileMethod::Mean
14199            )
14200            .unwrap_err(),
14201            PlotDataError::ImageDataLength {
14202                expected: 4,
14203                actual: 3,
14204            }
14205        );
14206    }
14207
14208    #[test]
14209    fn aligned_profile_band_clamps_to_image_edges() {
14210        // 2x4 image, rows: [10,11],[12,13],[14,15],[16,17].
14211        let data = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0];
14212        // Bottom edge, width 2: band clamps to the last two rows (2,3).
14213        assert_eq!(
14214            aligned_profile_values(2, 4, &data, 3.0, 2, true, ProfileMethod::Mean).unwrap(),
14215            vec![15.0, 16.0]
14216        );
14217        // Top edge, width 2: band clamps to rows 0,1.
14218        assert_eq!(
14219            aligned_profile_values(2, 4, &data, 0.0, 2, true, ProfileMethod::Mean).unwrap(),
14220            vec![11.0, 12.0]
14221        );
14222        // roi_width larger than the image: clipped to the whole height.
14223        assert_eq!(
14224            aligned_profile_values(2, 4, &data, 1.0, 10, true, ProfileMethod::Mean).unwrap(),
14225            vec![13.0, 14.0]
14226        );
14227    }
14228
14229    #[test]
14230    fn aligned_profile_validates_length() {
14231        assert_eq!(
14232            aligned_profile_values(2, 2, &[1.0, 2.0, 3.0], 0.0, 1, true, ProfileMethod::Mean)
14233                .unwrap_err(),
14234            PlotDataError::ImageDataLength {
14235                expected: 4,
14236                actual: 3,
14237            }
14238        );
14239    }
14240
14241    #[test]
14242    fn profile_helpers_validate_shape_and_index() {
14243        assert_eq!(
14244            horizontal_profile_values(2, 2, &[1.0, 2.0, 3.0], 0).unwrap_err(),
14245            PlotDataError::ImageDataLength {
14246                expected: 4,
14247                actual: 3,
14248            }
14249        );
14250        assert_eq!(
14251            horizontal_profile_values(2, 2, &[1.0, 2.0, 3.0, 4.0], 2).unwrap_err(),
14252            PlotDataError::ProfileRow { row: 2, height: 2 }
14253        );
14254        assert_eq!(
14255            vertical_profile_values(2, 2, &[1.0, 2.0, 3.0, 4.0], 2).unwrap_err(),
14256            PlotDataError::ProfileColumn {
14257                column: 2,
14258                width: 2,
14259            }
14260        );
14261    }
14262
14263    #[test]
14264    fn value_stats_ignore_non_finite_values() {
14265        let stats = ValueStats::from_f64(&[1.0, f64::NAN, 4.0, f64::INFINITY]);
14266        assert_eq!(stats.count, 4);
14267        assert_eq!(stats.finite_count, 2);
14268        assert_eq!(stats.min, Some(1.0));
14269        assert_eq!(stats.max, Some(4.0));
14270        assert_eq!(stats.mean, Some(2.5));
14271    }
14272
14273    #[test]
14274    fn value_stats_from_f32_matches_f64_semantics() {
14275        let stats = ValueStats::from_f32(&[1.0, f32::NAN, 4.0, f32::INFINITY]);
14276        assert_eq!(stats.count, 4);
14277        assert_eq!(stats.finite_count, 2);
14278        assert_eq!(stats.min, Some(1.0));
14279        assert_eq!(stats.max, Some(4.0));
14280        assert_eq!(stats.mean, Some(2.5));
14281    }
14282
14283    #[test]
14284    fn image_view_alpha_propagates_to_image_spec() {
14285        // Item 2: the alpha slider value propagates to the active image's spec
14286        // alpha (silx ActiveImageAlphaSlider -> image.setAlpha).
14287        let pixels = [1.0_f32, 2.0, 3.0, 4.0];
14288        let cmap = Colormap::viridis(0.0, 4.0);
14289        let mut slider = crate::widget::alpha_slider::AlphaSlider::default();
14290        slider.set_alpha(0.25);
14291        let spec = image_view_image_spec(
14292            2,
14293            2,
14294            &pixels,
14295            &cmap,
14296            slider.alpha(),
14297            InterpolationMode::default(),
14298            AggregationMode::default(),
14299            (1, 1),
14300        );
14301        // 0.25 -> round(255*0.25)=64 -> 64/255 == slider.alpha().
14302        assert_eq!(spec.alpha, slider.alpha());
14303        assert!((spec.alpha - 64.0 / 255.0).abs() < 1e-6);
14304    }
14305
14306    #[test]
14307    fn image_view_interpolation_aggregation_update_spec() {
14308        // Item 5: selecting interpolation / aggregation modes updates the image
14309        // spec's interpolation/aggregation/block (silx image interpolation +
14310        // ImageDataAggregated).
14311        let pixels = [1.0_f32, 2.0, 3.0, 4.0];
14312        let cmap = Colormap::viridis(0.0, 4.0);
14313        let spec = image_view_image_spec(
14314            2,
14315            2,
14316            &pixels,
14317            &cmap,
14318            1.0,
14319            InterpolationMode::Linear,
14320            AggregationMode::Mean,
14321            (2, 3),
14322        );
14323        assert_eq!(spec.interpolation, InterpolationMode::Linear);
14324        assert_eq!(spec.aggregation, AggregationMode::Mean);
14325        assert_eq!(spec.aggregation_block, (2, 3));
14326    }
14327
14328    #[test]
14329    fn image_view_profile_drag_samples_expected_values() {
14330        // Item 7: a profile drag extracts the expected sampled values via the
14331        // existing profile helpers (silx _ProfileToolBar).
14332        // 3x2 image, row-major:
14333        //   row 0: [1, 2, 3]
14334        //   row 1: [4, 5, 6]
14335        let pixels = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0];
14336
14337        // Horizontal profile at row 1 -> [4, 5, 6], x = column indices.
14338        let (hx, hy) = image_view_profile_values(
14339            ProfileMode::Horizontal,
14340            3,
14341            2,
14342            &pixels,
14343            (0.0, 1.0),
14344            (2.0, 1.0),
14345        )
14346        .unwrap();
14347        assert_eq!(hx, vec![0.0, 1.0, 2.0]);
14348        assert_eq!(hy, vec![4.0, 5.0, 6.0]);
14349
14350        // Vertical profile at column 2 -> [3, 6], x = row indices.
14351        let (vx, vy) =
14352            image_view_profile_values(ProfileMode::Vertical, 3, 2, &pixels, (2.0, 0.0), (2.0, 1.0))
14353                .unwrap();
14354        assert_eq!(vx, vec![0.0, 1.0]);
14355        assert_eq!(vy, vec![3.0, 6.0]);
14356
14357        // Line profile from (0,0) to (2,0) samples row 0: [1, 2, 3].
14358        let (_, ly) =
14359            image_view_profile_values(ProfileMode::Line, 3, 2, &pixels, (0.0, 0.0), (2.0, 0.0))
14360                .unwrap();
14361        assert_eq!(ly, vec![1.0, 2.0, 3.0]);
14362
14363        // None mode yields nothing.
14364        assert!(
14365            image_view_profile_values(ProfileMode::None, 3, 2, &pixels, (0.0, 0.0), (2.0, 0.0))
14366                .is_none()
14367        );
14368    }
14369
14370    #[test]
14371    fn profile_roi_from_drag_matches_mode() {
14372        // The ROI handed to the profile window encodes the drag per mode.
14373        assert_eq!(
14374            profile_roi_from_drag(ProfileMode::Line, (1.0, 2.0), (3.0, 4.0)),
14375            Some(Roi::Line {
14376                start: (1.0, 2.0),
14377                end: (3.0, 4.0)
14378            })
14379        );
14380        assert_eq!(
14381            profile_roi_from_drag(ProfileMode::Horizontal, (0.0, 1.7), (5.0, 1.7)),
14382            Some(Roi::HRange { y: (1.0, 1.0) })
14383        );
14384        assert_eq!(
14385            profile_roi_from_drag(ProfileMode::Vertical, (2.9, 0.0), (2.9, 4.0)),
14386            Some(Roi::VRange { x: (2.0, 2.0) })
14387        );
14388        assert_eq!(
14389            profile_roi_from_drag(ProfileMode::Rectangle, (4.0, 5.0), (1.0, 2.0)),
14390            Some(Roi::Rect {
14391                x: (1.0, 4.0),
14392                y: (2.0, 5.0)
14393            })
14394        );
14395        assert_eq!(
14396            profile_roi_from_drag(ProfileMode::None, (0.0, 0.0), (1.0, 1.0)),
14397            None
14398        );
14399    }
14400
14401    #[test]
14402    fn click_event_for_pick_maps_each_pick_variant() {
14403        // One case per PickResult variant — the pure seam of the click path that
14404        // the GPU-bound pick_item cannot exercise headlessly.
14405        let handle: ItemHandle = 7;
14406
14407        // CurvePoint carries the picked vertex; distance_px is dropped.
14408        assert_eq!(
14409            PlotWidget::click_event_for_pick(
14410                handle,
14411                &PickResult::CurvePoint {
14412                    index: 4,
14413                    x: 1.5,
14414                    y: -2.0,
14415                    distance_px: 3.0,
14416                },
14417                MouseButton::Left,
14418            ),
14419            PlotEvent::CurveClicked {
14420                handle,
14421                index: 4,
14422                x: 1.5,
14423                y: -2.0,
14424                button: MouseButton::Left,
14425            }
14426        );
14427
14428        // ImagePixel carries the pixel (col, row).
14429        assert_eq!(
14430            PlotWidget::click_event_for_pick(
14431                handle,
14432                &PickResult::ImagePixel { col: 12, row: 9 },
14433                MouseButton::Middle,
14434            ),
14435            PlotEvent::ImageClicked {
14436                handle,
14437                col: 12,
14438                row: 9,
14439                button: MouseButton::Middle,
14440            }
14441        );
14442
14443        // A non-indexed overlay item maps to ItemClicked, preserving the button.
14444        assert_eq!(
14445            PlotWidget::click_event_for_pick(
14446                handle,
14447                &PickResult::Item { handle: 99 },
14448                MouseButton::Right,
14449            ),
14450            PlotEvent::ItemClicked {
14451                handle,
14452                button: MouseButton::Right,
14453            }
14454        );
14455    }
14456
14457    #[test]
14458    fn radar_drag_output_maps_to_image_plot_limits() {
14459        // Item 3: a RadarView viewport drag emits (x0, x1, y0, y1) which the
14460        // ImageView forwards verbatim to image_plot.set_limits. Verify the
14461        // emitted limits via the same clamp+limits path the radar drag uses.
14462        use crate::widget::radar_view::{DataRect, RadarView, clamp_viewport};
14463
14464        // 100x80 image extent; viewport is a 20x16 window panned by (+30, +20).
14465        let mut radar = RadarView::default();
14466        radar.set_data_bounds(0.0, 100.0, 0.0, 80.0);
14467        radar.set_viewport(DataRect::new(0.0, 0.0, 20.0, 16.0));
14468
14469        let moved = DataRect {
14470            left: radar.viewport.left + 30.0,
14471            top: radar.viewport.top + 20.0,
14472            width: radar.viewport.width,
14473            height: radar.viewport.height,
14474        };
14475        let clamped = clamp_viewport(moved, &radar.data_extent);
14476        let (x0, x1, y0, y1) = clamped.limits();
14477        // Window stays inside the extent: left 30..50, top 20..36.
14478        assert_eq!((x0, x1, y0, y1), (30.0, 50.0, 20.0, 36.0));
14479        // This tuple is exactly what set_limits(x0, x1, y0, y1, None) receives.
14480        assert!(x1 > x0 && y1 > y0);
14481    }
14482
14483    #[test]
14484    fn image_view_position_info_reads_hover_cursor() {
14485        // Item 4: a Moved (hover) pointer event yields the cursor data coords,
14486        // which the PositionInfo readout formats.
14487        use crate::widget::interaction::PlotPointerEvent;
14488        let moved = PlotPointerEvent::Moved {
14489            button: None,
14490            data: (12.5, -3.0),
14491            pixel: (40.0, 60.0),
14492        };
14493        let cursor = cursor_from_pointer_event(Some(&moved));
14494        assert_eq!(cursor, Some([12.5, -3.0]));
14495
14496        let info = crate::widget::position_info::PositionInfo::with_xy();
14497        let values = info.values(cursor);
14498        assert_eq!(values, vec!["12.5".to_owned(), "-3".to_owned()]);
14499
14500        // A Clicked and a DoubleClicked event read the cursor the same way
14501        // (silx PositionInfo tracks the pointer for every mouse signal, not just
14502        // hover), so the readout follows the press position too.
14503        let clicked = PlotPointerEvent::Clicked {
14504            button: crate::widget::interaction::MouseButton::Left,
14505            data: (4.0, 8.0),
14506            pixel: (10.0, 20.0),
14507        };
14508        assert_eq!(cursor_from_pointer_event(Some(&clicked)), Some([4.0, 8.0]));
14509        let double = PlotPointerEvent::DoubleClicked {
14510            button: crate::widget::interaction::MouseButton::Left,
14511            data: (-1.5, 2.25),
14512            pixel: (10.0, 20.0),
14513        };
14514        assert_eq!(cursor_from_pointer_event(Some(&double)), Some([-1.5, 2.25]));
14515
14516        // A LimitsChanged event carries no cursor; absence of an event too.
14517        let limits = PlotPointerEvent::LimitsChanged {
14518            x: (0.0, 1.0),
14519            y: (0.0, 1.0),
14520            y2: None,
14521        };
14522        assert_eq!(cursor_from_pointer_event(Some(&limits)), None);
14523        assert_eq!(cursor_from_pointer_event(None), None);
14524        // No cursor -> placeholder readout.
14525        assert_eq!(
14526            info.values(None),
14527            vec!["------".to_owned(), "------".to_owned()]
14528        );
14529    }
14530
14531    #[test]
14532    fn image_view_colorbar_tracks_colormap_limits() {
14533        // Item 1: the ImageView side colorbar is synced to the active image's
14534        // colormap value limits (silx getColorBarWidget).
14535        let cmap = Colormap::viridis(-3.0, 9.5);
14536        let bar = image_view_colorbar(&cmap);
14537        assert_eq!(bar.colormap.vmin, -3.0);
14538        assert_eq!(bar.colormap.vmax, 9.5);
14539        assert_eq!(
14540            bar.orientation,
14541            crate::widget::colorbar::ColorBarOrientation::Vertical
14542        );
14543    }
14544
14545    #[test]
14546    fn scatter_view_colorbar_tracks_value_colormap_limits() {
14547        // Item 1: the ScatterView side colorbar is synced to the value
14548        // colormap's limits, and is absent before any data is uploaded (silx
14549        // ScatterView.getColorBarWidget).
14550        assert!(
14551            scatter_view_colorbar(None).is_none(),
14552            "no colorbar before set_data"
14553        );
14554        let cmap = Colormap::viridis(2.5, 41.0);
14555        let bar = scatter_view_colorbar(Some(&cmap)).expect("colorbar after set_data");
14556        assert_eq!(bar.colormap.vmin, 2.5);
14557        assert_eq!(bar.colormap.vmax, 41.0);
14558        assert_eq!(
14559            bar.orientation,
14560            crate::widget::colorbar::ColorBarOrientation::Vertical
14561        );
14562    }
14563
14564    #[test]
14565    fn scatter_points_mode_has_no_grid_image() {
14566        // Item 2: POINTS mode renders the marker cloud, not a grid image.
14567        let x = [0.0, 1.0, 0.0];
14568        let y = [0.0, 0.0, 1.0];
14569        let v = [1.0, 2.0, 3.0];
14570        assert!(scatter_grid_image(ScatterVisualization::Points, &x, &y, &v, (4, 4)).is_none());
14571    }
14572
14573    #[test]
14574    fn scatter_irregular_grid_mode_has_no_grid_image() {
14575        // IRREGULAR_GRID now renders through the triangle-mesh path
14576        // (`scatter_viz::irregular_grid_triangles`, silx
14577        // `_quadrilateral_grid_as_triangles`), not the image path, so
14578        // `scatter_grid_image` produces no grid image for it. The
14579        // barycentric-interpolated-image core itself is still covered by
14580        // `scatter_viz::irregular_grid_image_interpolates_inside_nan_outside`.
14581        let x = [0.0, 4.0, 0.0];
14582        let y = [0.0, 0.0, 4.0];
14583        let v = [0.0, 4.0, 0.0];
14584        assert!(
14585            scatter_grid_image(ScatterVisualization::IrregularGrid, &x, &y, &v, (4, 4)).is_none()
14586        );
14587    }
14588
14589    #[test]
14590    fn scatter_binned_statistic_mode_means_and_nan_empty() {
14591        // Item 2: BINNED_STATISTIC converts (x,y,value) to per-bin means;
14592        // two points in bin (0,0) average, an empty bin is NaN.
14593        let x = [0.0, 0.5, 2.0];
14594        let y = [0.0, 0.5, 2.0];
14595        let v = [10.0, 30.0, 7.0];
14596        let img = scatter_grid_image(ScatterVisualization::BinnedStatistic, &x, &y, &v, (2, 2))
14597            .expect("binned");
14598        assert_eq!(img.shape, (2, 2));
14599        // bin (0,0) = mean(10,30) = 20.
14600        assert!((img.get(0, 0).unwrap() - 20.0).abs() < 1e-12);
14601        // bin (0,1) is empty -> NaN.
14602        assert!(img.get(0, 1).unwrap().is_nan());
14603        // bin (1,1) holds the single point 7.
14604        assert!((img.get(1, 1).unwrap() - 7.0).abs() < 1e-12);
14605        assert_eq!(img.origin, (0.0, 0.0));
14606        assert_eq!(img.scale, (1.0, 1.0));
14607    }
14608
14609    #[test]
14610    fn scatter_regular_grid_mode_reshapes_row_major() {
14611        // Item 2: REGULAR_GRID reshapes the points onto the auto-detected grid.
14612        // A 2x3 row-major grid (X fast) -> values reshape directly row-major.
14613        let x = [0.0, 1.0, 2.0, 0.0, 1.0, 2.0];
14614        let y = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
14615        let v = [10.0, 11.0, 12.0, 20.0, 21.0, 22.0];
14616        let img = scatter_grid_image(ScatterVisualization::RegularGrid, &x, &y, &v, (0, 0))
14617            .expect("grid detected");
14618        assert_eq!(img.shape, (2, 3));
14619        assert_eq!(img.get(0, 0), Some(10.0));
14620        assert_eq!(img.get(0, 2), Some(12.0));
14621        assert_eq!(img.get(1, 0), Some(20.0));
14622        assert_eq!(img.get(1, 2), Some(22.0));
14623        // scale = span / (n - 1): x span 2 over 2 cols-1 = 1; y span 1 over 1 = 1.
14624        assert_eq!(img.scale, (1.0, 1.0));
14625        // origin = begin - 0.5*scale.
14626        assert_eq!(img.origin, (-0.5, -0.5));
14627    }
14628
14629    #[test]
14630    fn scatter_regular_grid_mode_column_major_transposes() {
14631        // Item 2: REGULAR_GRID with Y fast (column-major) reshapes down columns,
14632        // yielding a row-major (rows, cols) image (silx transpose).
14633        // Fill column 0 top-to-bottom, then column 1 (rows=3).
14634        let mut x = Vec::new();
14635        let mut y = Vec::new();
14636        let mut v = Vec::new();
14637        for c in 0..2 {
14638            for r in 0..3 {
14639                x.push(c as f64);
14640                y.push(r as f64);
14641                v.push((c * 10 + r) as f64); // distinct per cell
14642            }
14643        }
14644        let img = scatter_grid_image(ScatterVisualization::RegularGrid, &x, &y, &v, (0, 0))
14645            .expect("grid detected");
14646        assert_eq!(img.shape, (3, 2));
14647        // Point 0 -> (r=0,c=0); point 3 -> (r=0,c=1).
14648        assert_eq!(img.get(0, 0), Some(0.0));
14649        assert_eq!(img.get(0, 1), Some(10.0));
14650        assert_eq!(img.get(2, 0), Some(2.0));
14651        assert_eq!(img.get(2, 1), Some(12.0));
14652    }
14653
14654    #[test]
14655    fn scatter_regular_grid_mode_trailing_cells_are_nan() {
14656        // Item 2: fewer points than cells -> trailing cells stay NaN (silx
14657        // transparent pixels). 5 points on a row-major width-3 line guess
14658        // would extend the grid; use a clean 2x3 minus one point isn't a grid,
14659        // so test the explicit short-fill via a single monotonic line of 5.
14660        let x = [0.0, 1.0, 2.0, 3.0, 4.0];
14661        let y = [0.0, 0.0, 0.0, 0.0, 0.0];
14662        let v = [1.0, 2.0, 3.0, 4.0, 5.0];
14663        let img = scatter_grid_image(ScatterVisualization::RegularGrid, &x, &y, &v, (0, 0))
14664            .expect("line detected");
14665        // A monotonic X line -> shape (1, 5), fully filled.
14666        assert_eq!(img.shape, (1, 5));
14667        assert_eq!(img.get(0, 0), Some(1.0));
14668        assert_eq!(img.get(0, 4), Some(5.0));
14669    }
14670
14671    #[test]
14672    fn scatter_masked_selection_flags_nonzero_levels() {
14673        // Item 3: the boolean selection applied to the scatter is true exactly
14674        // where the per-point mask level is non-zero.
14675        let mask = [0u8, 1, 0, 3, 0];
14676        assert_eq!(
14677            scatter_masked_selection(&mask),
14678            vec![false, true, false, true, false]
14679        );
14680        // Empty mask -> empty selection.
14681        assert!(scatter_masked_selection(&[]).is_empty());
14682    }
14683
14684    #[test]
14685    fn scatter_mask_rectangle_selection_applies_to_points() {
14686        // Item 3: a rectangle selection on the scatter mask flags exactly the
14687        // points inside the rectangle (silx ScatterMask.updateRectangle).
14688        // Five points along X; select the box x in [0.5, 2.5], y in [-0.5, 0.5].
14689        let px: Vec<f32> = vec![0.0, 1.0, 2.0, 3.0, 4.0];
14690        let py: Vec<f32> = vec![0.0, 0.0, 0.0, 0.0, 0.0];
14691        let mut mask = crate::widget::scatter_mask::ScatterMaskWidget::new(px.len());
14692        // update_rectangle takes anchor=(y,x) bottom-left, size=(height,width).
14693        // Box: x in [0.5, 2.5], y in [-0.5, 0.5].
14694        mask.update_rectangle(1, (-0.5, 0.5), (1.0, 2.0), &px, &py, true);
14695        let selection = scatter_masked_selection(&mask.mask);
14696        // Only x=1 and x=2 fall inside the box; x=0, x=3, x=4 are outside.
14697        assert_eq!(selection, vec![false, true, true, false, false]);
14698    }
14699
14700    /// A colormap whose LUT entry `i` is `[i, 255 - i, 0, 255]`, over `[0, 4]`.
14701    /// Each LUT index maps to a unique, hand-computable color so a value's
14702    /// resulting [`Color32`] is fully predictable.
14703    fn ramp_colormap() -> Colormap {
14704        let mut lut = [[0u8; 4]; 256];
14705        for (i, entry) in lut.iter_mut().enumerate() {
14706            *entry = [i as u8, 255 - i as u8, 0, 255];
14707        }
14708        Colormap::viridis(0.0, 4.0).with_lut(lut)
14709    }
14710
14711    /// The exact color the [`point_colors`] LUT lookup produces for `v` under
14712    /// [`ramp_colormap`], reproducing the index math
14713    /// `idx = (normalize(v) * 255).clamp(0, 255)`.
14714    fn ramp_color_at(cmap: &Colormap, v: f64) -> Color32 {
14715        let idx = (cmap.normalize(v) * 255.0).clamp(0.0, 255.0) as usize;
14716        let [r, g, b, a] = cmap.lut[idx];
14717        Color32::from_rgba_unmultiplied(r, g, b, a)
14718    }
14719
14720    #[test]
14721    fn point_colors_maps_values_through_colormap_lut() {
14722        // Wave 8C: point_colors maps each value through the colormap LUT (silx
14723        // __applyColormapToData). Over [0, 4] with no per-point alpha, value 0
14724        // hits LUT index 0, value 4 hits index 255, value 2 hits the midpoint.
14725        let cmap = ramp_colormap();
14726        let values = [0.0, 2.0, 4.0];
14727        let colors = point_colors(&values, &cmap, None);
14728        assert_eq!(
14729            colors,
14730            vec![
14731                ramp_color_at(&cmap, 0.0),
14732                ramp_color_at(&cmap, 2.0),
14733                ramp_color_at(&cmap, 4.0),
14734            ]
14735        );
14736        // Concretely: index 0 -> [0,255,0,255]; index 255 -> [255,0,0,255].
14737        assert_eq!(colors[0], Color32::from_rgba_unmultiplied(0, 255, 0, 255));
14738        assert_eq!(colors[2], Color32::from_rgba_unmultiplied(255, 0, 0, 255));
14739    }
14740
14741    #[test]
14742    fn point_colors_composes_per_point_alpha() {
14743        // Wave 8C: with a per-point alpha array, point_colors scales each
14744        // color's straight alpha (silx rgbacolors[:, -1] *= __alpha), identical
14745        // to applying compose_per_point_alpha to the no-alpha colors.
14746        let cmap = ramp_colormap();
14747        let values = [0.0, 2.0, 4.0];
14748        let alpha = [0.5, 0.25, 1.0];
14749
14750        let with_alpha = point_colors(&values, &cmap, Some(&alpha));
14751
14752        let mut expected = point_colors(&values, &cmap, None);
14753        compose_per_point_alpha(&mut expected, &alpha);
14754        assert_eq!(with_alpha, expected);
14755
14756        // Concretely: LUT alpha 255 scaled by 0.5 -> round(255*0.5) = 128.
14757        assert_eq!(with_alpha[0].to_srgba_unmultiplied()[3], 128);
14758        // ...by 0.25 -> round(255*0.25) = 64.
14759        assert_eq!(with_alpha[1].to_srgba_unmultiplied()[3], 64);
14760        // ...by 1.0 -> unchanged 255.
14761        assert_eq!(with_alpha[2].to_srgba_unmultiplied()[3], 255);
14762    }
14763
14764    #[test]
14765    fn solid_path_colors_identical_to_points_path() {
14766        // Wave 8C: silx shares __applyColormapToData between POINTS and SOLID,
14767        // so the Solid arm must produce colors identical to the Points arm for
14768        // the same input. Both arms call point_colors, so the vectors match —
14769        // here against the prior inlined Points math to prove no drift.
14770        let cmap = ramp_colormap();
14771        let values = [0.0, 1.0, 3.0, 4.0];
14772        let alpha = [1.0, 0.5, 0.25, 0.75];
14773
14774        // The shared helper used by BOTH arms.
14775        let shared = point_colors(&values, &cmap, Some(&alpha));
14776
14777        // The pre-extraction inlined Points computation, reproduced here.
14778        let mut points_inline: Vec<Color32> =
14779            values.iter().map(|&v| ramp_color_at(&cmap, v)).collect();
14780        compose_per_point_alpha(&mut points_inline, &alpha);
14781
14782        assert_eq!(shared, points_inline);
14783    }
14784
14785    #[test]
14786    fn solid_path_builds_triangles_with_per_vertex_colors() {
14787        // Wave 8C: the Solid arm feeds point_colors into solid_triangles, the
14788        // exact composition rebuild_visualization performs. Four non-collinear
14789        // points triangulate, and every input color survives onto a vertex.
14790        let cmap = ramp_colormap();
14791        let x = [0.0, 4.0, 0.0, 4.0];
14792        let y = [0.0, 0.0, 4.0, 4.0];
14793        let values = [0.0, 2.0, 4.0, 1.0];
14794
14795        let colors = point_colors(&values, &cmap, None);
14796        let tri = crate::core::scatter_viz::solid_triangles(&x, &y, &colors)
14797            .expect("four non-collinear points triangulate");
14798
14799        // The mesh carries the per-vertex colors unchanged (Gourad input).
14800        assert_eq!(tri.colors, colors);
14801        assert_eq!(tri.x, x.to_vec());
14802        assert_eq!(tri.y, y.to_vec());
14803        // A square splits into at least two triangles.
14804        assert!(
14805            tri.indices.len() >= 2,
14806            "square triangulates into >= 2 triangles, got {}",
14807            tri.indices.len()
14808        );
14809    }
14810
14811    #[test]
14812    fn solid_path_degenerate_input_yields_no_triangles() {
14813        // Wave 8C: fewer than 3 finite points / all collinear cannot be
14814        // triangulated, so the Solid arm draws nothing (None, no panic),
14815        // matching silx's "Cannot display as solid surface" early-out.
14816        let cmap = ramp_colormap();
14817
14818        // Fewer than 3 points.
14819        let x2 = [0.0, 1.0];
14820        let y2 = [0.0, 1.0];
14821        let c2 = point_colors(&[0.0, 4.0], &cmap, None);
14822        assert!(crate::core::scatter_viz::solid_triangles(&x2, &y2, &c2).is_none());
14823
14824        // Three collinear points (no triangle has area).
14825        let xc = [0.0, 1.0, 2.0];
14826        let yc = [0.0, 1.0, 2.0];
14827        let cc = point_colors(&[0.0, 2.0, 4.0], &cmap, None);
14828        assert!(crate::core::scatter_viz::solid_triangles(&xc, &yc, &cc).is_none());
14829    }
14830
14831    #[test]
14832    fn live_curve_stats_match_core_stats_engine() {
14833        // Item 4: a retained curve fed into a StatsWidget yields a row equal to
14834        // a direct core::stats::Stats run on the same (xs, ys).
14835        use crate::widget::stats_widget::{StatsWidget, UpdateMode};
14836        let xs = vec![0.0, 1.0, 2.0, 3.0, 4.0];
14837        let ys = vec![2.0, -1.0, 5.0, f64::NAN, 0.5];
14838        let data = RetainedItemData::Curve {
14839            x: xs.clone(),
14840            y: ys.clone(),
14841        };
14842        let input = retained_data_to_stats_input(&data);
14843        let mut w = StatsWidget::new();
14844        w.set_update_mode(UpdateMode::Auto);
14845        w.recompute(&[("curve", input)], None);
14846        let rows = w.rows();
14847        assert_eq!(rows.len(), 1);
14848        assert_eq!(rows[0].0, "curve");
14849        let core =
14850            crate::core::stats::Stats::for_curve(&xs, &ys, crate::core::stats::StatScope::All);
14851        assert_eq!(rows[0].1, core);
14852    }
14853
14854    #[test]
14855    fn live_image_stats_match_core_stats_engine() {
14856        // Item 4: a retained scalar image fed into a StatsWidget yields a row
14857        // equal to a direct core::stats::Stats run on the same pixels+geometry.
14858        use crate::widget::stats_widget::{StatsWidget, UpdateMode};
14859        let pixels = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14860        let data = RetainedItemData::Image {
14861            data: pixels.clone(),
14862            width: 3,
14863            height: 2,
14864            origin: (0.0, 0.0),
14865            scale: (1.0, 1.0),
14866            colormap: Box::new(Colormap::viridis(0.0, 1.0)),
14867            alpha: 1.0,
14868            interpolation: InterpolationMode::Nearest,
14869            aggregation: AggregationMode::None,
14870            aggregation_block: (1, 1),
14871            alpha_map: None,
14872        };
14873        let input = retained_data_to_stats_input(&data);
14874        let mut w = StatsWidget::new();
14875        w.set_update_mode(UpdateMode::Auto);
14876        w.recompute(&[("image", input)], None);
14877        let rows = w.rows();
14878        assert_eq!(rows.len(), 1);
14879        let core = crate::core::stats::Stats::for_image(
14880            &pixels,
14881            3,
14882            2,
14883            (0.0, 0.0),
14884            (1.0, 1.0),
14885            crate::core::stats::StatScope::All,
14886        );
14887        assert_eq!(rows[0].1, core);
14888    }
14889
14890    #[test]
14891    fn image_display_attrs_round_trip_preserves_every_reupload_field() {
14892        // A scalar image with non-default opacity, interpolation, aggregation,
14893        // and a per-pixel alpha map: capturing it to RetainedItemData and
14894        // restoring onto a fresh ImageSpec::scalar (as every re-upload path does)
14895        // must carry *every* display field via the single-owner
14896        // ImageDisplayAttrs::apply — not just geometry. This is the structural
14897        // guard against a re-upload path silently resetting interpolation /
14898        // aggregation / the alpha map to scalar() defaults (the same defect
14899        // family the retained alpha closed).
14900        let pixels = [1.0f32, 2.0, 3.0, 4.0];
14901        let am = [0.0f32, 0.25, 0.75, 1.0];
14902        let mut spec = ImageSpec::scalar(2, 2, &pixels, Colormap::viridis(0.0, 4.0));
14903        spec.origin = (5.0, 6.0);
14904        spec.scale = (2.0, 3.0);
14905        spec.alpha = 0.4;
14906        spec.interpolation = InterpolationMode::Linear;
14907        spec.aggregation = AggregationMode::Max;
14908        spec.aggregation_block = (2, 2);
14909        spec.alpha_map = Some(&am);
14910
14911        let retained = image_spec_retained_data(&spec).expect("scalar image retains data");
14912        let attrs = retained
14913            .image_display_attrs()
14914            .expect("image has display attrs");
14915
14916        // A fresh spec starts at the scalar() defaults (Nearest / None / unit /
14917        // no alpha map).
14918        let mut rebuilt = ImageSpec::scalar(2, 2, &pixels, Colormap::viridis(0.0, 4.0));
14919        assert_eq!(rebuilt.interpolation, InterpolationMode::Nearest);
14920        assert_eq!(rebuilt.aggregation, AggregationMode::None);
14921        assert_eq!(rebuilt.alpha, 1.0);
14922        assert!(rebuilt.alpha_map.is_none());
14923        attrs.apply(&mut rebuilt);
14924
14925        assert_eq!(rebuilt.origin, (5.0, 6.0));
14926        assert_eq!(rebuilt.scale, (2.0, 3.0));
14927        assert_eq!(rebuilt.alpha, 0.4);
14928        assert_eq!(rebuilt.interpolation, InterpolationMode::Linear);
14929        assert_eq!(rebuilt.aggregation, AggregationMode::Max);
14930        assert_eq!(rebuilt.aggregation_block, (2, 2));
14931        assert_eq!(rebuilt.alpha_map, Some(am.as_slice()));
14932    }
14933
14934    #[test]
14935    fn roi_stats_rows_image_match_image_roi_stats_per_roi() {
14936        // Item 110: one row per ROI, each reduced over the active image's pixels
14937        // inside that ROI via image_roi_stats (identical geometry/cast). The
14938        // unnamed ROI gets the "ROI {index}" label; a named one keeps its name.
14939        use crate::widget::roi_stats::image_roi_stats;
14940        let pixels = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
14941        let data = RetainedItemData::Image {
14942            data: pixels.clone(),
14943            width: 3,
14944            height: 2,
14945            origin: (0.0, 0.0),
14946            scale: (1.0, 1.0),
14947            colormap: Box::new(Colormap::viridis(0.0, 1.0)),
14948            alpha: 1.0,
14949            interpolation: InterpolationMode::Nearest,
14950            aggregation: AggregationMode::None,
14951            aggregation_block: (1, 1),
14952            alpha_map: None,
14953        };
14954        let mut named = ManagedRoi::new(Roi::Rect {
14955            x: (0.0, 2.0),
14956            y: (0.0, 1.0),
14957        });
14958        named.name = "left".to_owned();
14959        let rois = vec![
14960            named,
14961            ManagedRoi::new(Roi::Rect {
14962                x: (1.0, 3.0),
14963                y: (0.0, 2.0),
14964            }),
14965        ];
14966
14967        let rows = roi_stats_rows(&rois, &data);
14968        assert_eq!(rows.len(), 2);
14969        assert_eq!(rows[0].label, "left");
14970        assert_eq!(rows[1].label, "ROI 1");
14971
14972        // Each row equals image_roi_stats over the same f32-cast pixels/geometry.
14973        let f32_pixels: Vec<f32> = pixels.iter().map(|&v| v as f32).collect();
14974        for (row, managed) in rows.iter().zip(rois.iter()) {
14975            let expected = image_roi_stats(&managed.roi, &f32_pixels, 3, 2, [0.0, 0.0], [1.0, 1.0]);
14976            assert_eq!(row.stats, expected);
14977        }
14978    }
14979
14980    #[test]
14981    fn roi_stats_rows_curve_match_curve_roi_stats_per_roi() {
14982        // Item 110: for a curve item each row reduces the curve's y inside the
14983        // ROI's x-span via curve_roi_stats.
14984        use crate::widget::roi_stats::curve_roi_stats;
14985        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
14986        let y = vec![10.0, 20.0, 30.0, 40.0, 50.0];
14987        let data = RetainedItemData::Curve {
14988            x: x.clone(),
14989            y: y.clone(),
14990        };
14991        let rois = vec![
14992            ManagedRoi::new(Roi::VRange { x: (1.0, 3.0) }),
14993            ManagedRoi::new(Roi::Rect {
14994                x: (0.0, 2.0),
14995                y: (0.0, 100.0),
14996            }),
14997        ];
14998
14999        let rows = roi_stats_rows(&rois, &data);
15000        assert_eq!(rows.len(), 2);
15001        for (row, managed) in rows.iter().zip(rois.iter()) {
15002            assert_eq!(row.stats, curve_roi_stats(&managed.roi, &x, &y));
15003        }
15004    }
15005
15006    #[test]
15007    fn roi_stats_rows_empty_without_rois() {
15008        // Item 110: no ROIs -> no rows (the table is empty even with data).
15009        let data = RetainedItemData::Curve {
15010            x: vec![0.0, 1.0],
15011            y: vec![1.0, 2.0],
15012        };
15013        assert!(roi_stats_rows(&[], &data).is_empty());
15014    }
15015
15016    #[test]
15017    fn curve_roi_rows_match_curve_roi_counts_and_skip_non_curve_rois() {
15018        // Item 110 (CurvesROIWidget): one row per curve ROI (x-span), reduced via
15019        // curve_roi_counts; ROIs with no x-span (HRange) are skipped, and the
15020        // surviving rows keep their original ROI index in the label.
15021        use crate::widget::roi_stats::{curve_roi_counts, roi_x_span};
15022        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
15023        let y = vec![0.0, 0.0, 10.0, 0.0, 0.0];
15024
15025        let mut named = ManagedRoi::new(Roi::VRange { x: (0.0, 4.0) });
15026        named.name = "peak".to_owned();
15027        let rois = vec![
15028            named,
15029            // index 1: HRange has no x-span -> not a curve ROI, skipped.
15030            ManagedRoi::new(Roi::HRange { y: (0.0, 5.0) }),
15031            // index 2: a Rect contributes its x-extent.
15032            ManagedRoi::new(Roi::Rect {
15033                x: (1.0, 3.0),
15034                y: (-100.0, 100.0),
15035            }),
15036        ];
15037
15038        let rows = curve_roi_rows(&rois, &x, &y);
15039        assert_eq!(rows.len(), 2); // the HRange row is skipped
15040
15041        // First surviving row is the named VRange (index 0).
15042        assert_eq!(rows[0].label, "peak");
15043        assert_eq!(
15044            (rows[0].from, rows[0].to),
15045            roi_x_span(&rois[0].roi).unwrap()
15046        );
15047        assert_eq!(
15048            rows[0].counts,
15049            curve_roi_counts(&rois[0].roi, &x, &y).unwrap()
15050        );
15051
15052        // Second surviving row keeps its original index (2) in the label.
15053        assert_eq!(rows[1].label, "ROI 2");
15054        assert_eq!(
15055            (rows[1].from, rows[1].to),
15056            roi_x_span(&rois[2].roi).unwrap()
15057        );
15058        assert_eq!(
15059            rows[1].counts,
15060            curve_roi_counts(&rois[2].roi, &x, &y).unwrap()
15061        );
15062    }
15063
15064    #[test]
15065    fn curve_roi_rows_empty_without_rois() {
15066        // Item 110: no ROIs -> no rows even with curve data.
15067        let x = vec![0.0, 1.0, 2.0];
15068        let y = vec![1.0, 2.0, 3.0];
15069        assert!(curve_roi_rows(&[], &x, &y).is_empty());
15070    }
15071
15072    #[test]
15073    fn fit_target_feeds_curve_xy_not_image() {
15074        // Item 5: the fit target feed extracts the live curve's (x, y) (the data
15075        // FitWidget.set_data receives), and refuses a non-curve item.
15076        let xs = vec![1.0, 2.0, 3.0, 4.0];
15077        let ys = vec![10.0, 20.0, 30.0, 40.0];
15078        let curve = RetainedItemData::Curve {
15079            x: xs.clone(),
15080            y: ys.clone(),
15081        };
15082        let (fx, fy) = retained_curve_xy(&curve).expect("curve feeds its xy");
15083        assert_eq!(fx, xs.as_slice());
15084        assert_eq!(fy, ys.as_slice());
15085
15086        // An image item is not a fit target.
15087        let image = RetainedItemData::Image {
15088            data: vec![1.0, 2.0, 3.0, 4.0],
15089            width: 2,
15090            height: 2,
15091            origin: (0.0, 0.0),
15092            scale: (1.0, 1.0),
15093            colormap: Box::new(Colormap::viridis(0.0, 1.0)),
15094            alpha: 1.0,
15095            interpolation: InterpolationMode::Nearest,
15096            aggregation: AggregationMode::None,
15097            aggregation_block: (1, 1),
15098            alpha_map: None,
15099        };
15100        assert!(retained_curve_xy(&image).is_none());
15101    }
15102
15103    #[test]
15104    fn raw_pixel_stddev3_autoscale_matches_mean_plus_minus_3std() {
15105        // Item 6: Stddev3 autoscale over a known pixel array (NaN ignored) sets
15106        // vmin/vmax to clamp(mean ± 3·std) and preserves the rest of the
15107        // colormap. Symmetric data [-1, 0, 1] has mean 0, population std
15108        // sqrt(2/3); mean ± 3·std = ±3·sqrt(2/3) ≈ ±2.449, both outside the
15109        // data range, so silx clamps to the data min/max (-1, 1).
15110        let pixels = [-1.0, 0.0, 1.0, f64::NAN];
15111        let base = Colormap::viridis(7.0, 9.0); // arbitrary prior limits
15112        let cm = autoscaled_colormap(&base, AutoscaleMode::Stddev3, &pixels);
15113        // Cross-check against the core primitive on the same data.
15114        let (evmin, evmax) =
15115            AutoscaleMode::Stddev3.range(&pixels, crate::core::colormap::DEFAULT_PERCENTILES);
15116        assert_eq!(cm.vmin, evmin);
15117        assert_eq!(cm.vmax, evmax);
15118        assert_eq!((cm.vmin, cm.vmax), (-1.0, 1.0));
15119        // The LUT (colormap identity) is preserved; only limits changed.
15120        assert_eq!(cm.lut, base.lut);
15121    }
15122
15123    #[test]
15124    fn raw_pixel_percentile_autoscale_matches_percentile_bounds() {
15125        // Item 6: Percentile autoscale over a known pixel array uses the
15126        // colormap's percentile pair, NaN-ignoring.
15127        let pixels = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, f64::NAN];
15128        let mut base = Colormap::viridis(0.0, 1.0);
15129        base.set_autoscale_percentiles(10.0, 90.0);
15130        let cm = autoscaled_colormap(&base, AutoscaleMode::Percentile, &pixels);
15131        // numpy linear-interpolation percentile over the 10 finite values
15132        // [0..=9]: rank = p/100 * (n-1) = p/100 * 9. p=10 -> rank 0.9 -> 0.9;
15133        // p=90 -> rank 8.1 -> 8.1.
15134        let (evmin, evmax) = AutoscaleMode::Percentile.range(&pixels, (10.0, 90.0));
15135        assert_eq!(cm.vmin, evmin);
15136        assert_eq!(cm.vmax, evmax);
15137        assert!((cm.vmin - 0.9).abs() < 1e-12, "vmin {}", cm.vmin);
15138        assert!((cm.vmax - 8.1).abs() < 1e-12, "vmax {}", cm.vmax);
15139    }
15140
15141    #[test]
15142    fn masked_image_yields_nan_at_masked_pixels_before_upload() {
15143        // Item 6: a ScalarMask applied to a 2x2 image turns masked pixels into
15144        // NaN BEFORE upload, leaving the rest unchanged.
15145        let data = [1.0_f32, 2.0, 3.0, 4.0];
15146        let mut mask = ScalarMask::new(2, 2);
15147        // Mask the top-right (col 1, row 0) and bottom-left (col 0, row 1).
15148        mask.set_mask_data(&[0, 1, 1, 0], 2);
15149        let out = apply_image_mask(2, 2, &data, &mask).unwrap();
15150        assert_eq!(out[0], 1.0);
15151        assert!(out[1].is_nan());
15152        assert!(out[2].is_nan());
15153        assert_eq!(out[3], 4.0);
15154    }
15155
15156    #[test]
15157    fn masked_image_validates_shape() {
15158        let mask = ScalarMask::new(2, 2);
15159        // data length mismatch.
15160        assert_eq!(
15161            apply_image_mask(2, 2, &[1.0, 2.0, 3.0], &mask).unwrap_err(),
15162            PlotDataError::ImageDataLength {
15163                expected: 4,
15164                actual: 3,
15165            }
15166        );
15167        // mask shape mismatch.
15168        let small = ScalarMask::new(1, 1);
15169        assert_eq!(
15170            apply_image_mask(2, 2, &[1.0, 2.0, 3.0, 4.0], &small).unwrap_err(),
15171            PlotDataError::ImageDataLength {
15172                expected: 4,
15173                actual: 1,
15174            }
15175        );
15176    }
15177
15178    #[test]
15179    fn image_view_paints_mask_only_in_mask_draw_mode() {
15180        // Item 6C-2 gate: ImageView paints the mask ONLY when the plot is in
15181        // MaskDraw mode AND the mask panel is enabled. Pan / Zoom / Select must
15182        // never paint, and MaskDraw with the panel disabled must not paint.
15183        for mode in [
15184            PlotInteractionMode::Pan,
15185            PlotInteractionMode::Zoom,
15186            PlotInteractionMode::Select,
15187        ] {
15188            assert!(
15189                !image_view_should_paint_mask(mode, true),
15190                "{mode:?} must not paint even with the mask panel enabled",
15191            );
15192        }
15193        assert!(
15194            !image_view_should_paint_mask(PlotInteractionMode::MaskDraw, false),
15195            "MaskDraw must not paint when the mask panel is disabled",
15196        );
15197        assert!(
15198            image_view_should_paint_mask(PlotInteractionMode::MaskDraw, true),
15199            "MaskDraw with the mask panel enabled is the only painting state",
15200        );
15201    }
15202
15203    #[test]
15204    fn painted_level_buffer_round_trips_to_reupload_mask() {
15205        // Item 6C-2 conversion: a painted MaskToolsWidget level buffer becomes a
15206        // ScalarMask where every non-zero level is a masked pixel, and that mask
15207        // re-uploaded (applied to the scalar field) NaNs exactly those pixels —
15208        // matching Plot2D::try_add_masked_image / apply_image_mask.
15209        //
15210        // 2x3 image (width 2, height 3), row-major. Paint a couple of pixels at
15211        // distinct non-zero levels (silx levels 1..=255 all mask).
15212        let levels = [0u8, 1, 0, 0, 7, 0]; // (col1,row0) and (col0,row2) masked
15213        let scalar_mask = scalar_mask_from_level_buffer(2, 3, &levels);
15214
15215        assert_eq!(scalar_mask.width(), 2);
15216        assert_eq!(scalar_mask.height(), 3);
15217        // The boolean masked view matches "level != 0".
15218        for (i, &lvl) in levels.iter().enumerate() {
15219            let col = i % 2;
15220            let row = i / 2;
15221            assert_eq!(
15222                scalar_mask.is_masked(col, row),
15223                lvl != 0,
15224                "pixel ({col},{row}) level {lvl}",
15225            );
15226        }
15227
15228        // Re-upload representation: applying the mask NaNs exactly the masked
15229        // pixels and passes the rest through unchanged.
15230        let pixels = [10.0_f32, 20.0, 30.0, 40.0, 50.0, 60.0];
15231        let masked = scalar_mask.apply(&pixels);
15232        for (i, (&lvl, &out)) in levels.iter().zip(masked.iter()).enumerate() {
15233            if lvl != 0 {
15234                assert!(out.is_nan(), "masked pixel {i} (level {lvl}) must be NaN");
15235            } else {
15236                assert_eq!(out, pixels[i], "unmasked pixel {i} unchanged");
15237            }
15238        }
15239
15240        // It is the same representation try_add_masked_image / apply_image_mask
15241        // produce for an equivalent ScalarMask built directly from the bool view.
15242        let mut direct = ScalarMask::new(2, 3);
15243        direct.set_mask_data(&levels, 2);
15244        assert_eq!(scalar_mask, direct);
15245    }
15246
15247    #[test]
15248    fn scalar_mask_from_level_buffer_clips_oversized_to_image_shape() {
15249        // A level buffer longer than width*height is clipped to the image shape
15250        // by ScalarMask::set_mask_data (silx lazy clip), so the result always has
15251        // the image dimensions.
15252        let oversized = [1u8; 10]; // image is 2x2 = 4 pixels
15253        let mask = scalar_mask_from_level_buffer(2, 2, &oversized);
15254        assert_eq!(mask.width(), 2);
15255        assert_eq!(mask.height(), 2);
15256        assert_eq!(mask.get_mask_data().len(), 4);
15257        assert!(mask.get_mask_data().iter().all(|&m| m != 0));
15258    }
15259
15260    #[test]
15261    fn value_stats_match_core_stats_engine() {
15262        // Item 8: ValueStats must equal a direct core::stats::Stats run on the
15263        // same data (single source of truth).
15264        let data = [3.0, -2.0, 7.5, f64::NAN, 0.0, -2.0];
15265        let vs = ValueStats::from_f64(&data);
15266        let core = crate::core::stats::Stats::for_image(
15267            &data,
15268            data.len(),
15269            1,
15270            (0.0, 0.0),
15271            (1.0, 1.0),
15272            crate::core::stats::StatScope::All,
15273        );
15274        assert_eq!(vs.count, core.count);
15275        assert_eq!(vs.finite_count, core.finite_count);
15276        assert_eq!(vs.min, core.min);
15277        assert_eq!(vs.max, core.max);
15278        assert_eq!(vs.mean, core.mean);
15279    }
15280
15281    #[test]
15282    fn mask_rgba_pixels_are_transparent_where_false() {
15283        let color = Color32::from_rgba_unmultiplied(10, 20, 30, 40);
15284        let pixels = mask_rgba_pixels(&[true, false, true], color);
15285        let rgba = color.to_srgba_unmultiplied();
15286        assert_eq!(pixels, vec![rgba, [0, 0, 0, 0], rgba]);
15287    }
15288
15289    #[test]
15290    fn legend_row_width_stays_within_parent_width() {
15291        assert_eq!(legend_row_width(180.0), 180.0);
15292        assert_eq!(legend_row_width(80.0), 80.0);
15293        assert_eq!(legend_row_width(0.0), LEGEND_ROW_MIN_WIDTH);
15294    }
15295
15296    #[test]
15297    fn scatter_visualization_catalog_is_silx_order_with_unique_labels() {
15298        // The toolbar picker lists all five modes in silx menu order.
15299        assert_eq!(
15300            ScatterVisualization::ALL,
15301            [
15302                ScatterVisualization::Points,
15303                ScatterVisualization::Solid,
15304                ScatterVisualization::RegularGrid,
15305                ScatterVisualization::IrregularGrid,
15306                ScatterVisualization::BinnedStatistic,
15307            ]
15308        );
15309        // Default is Points (silx `Visualization.POINTS`).
15310        assert_eq!(
15311            ScatterVisualization::default(),
15312            ScatterVisualization::Points
15313        );
15314        // Labels are unique so the ComboBox entries are distinguishable.
15315        let labels: Vec<&str> = ScatterVisualization::ALL
15316            .iter()
15317            .map(|m| m.label())
15318            .collect();
15319        let mut deduped = labels.clone();
15320        deduped.sort_unstable();
15321        deduped.dedup();
15322        assert_eq!(labels.len(), deduped.len(), "labels must be unique");
15323    }
15324
15325    #[test]
15326    fn compare_pixel_at_looks_up_origin_aligned_pixel() {
15327        // 3x2 row-major image (width=3, height=2): rows [0,1,2] then [3,4,5].
15328        let data = [0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0];
15329        // int(x), int(y): (0.0,0.0) -> data[0]; (2.x,1.x) -> row 1 col 2 = 5.
15330        assert_eq!(compare_pixel_at(3, 2, &data, 0.0, 0.0), Some(0.0));
15331        assert_eq!(compare_pixel_at(3, 2, &data, 2.9, 1.9), Some(5.0));
15332        // Truncation toward zero on the non-negative interior (silx int()).
15333        assert_eq!(compare_pixel_at(3, 2, &data, 1.7, 0.2), Some(1.0));
15334        // Out of range (>= width / >= height) -> None.
15335        assert_eq!(compare_pixel_at(3, 2, &data, 3.0, 0.0), None);
15336        assert_eq!(compare_pixel_at(3, 2, &data, 0.0, 2.0), None);
15337        // Negative coordinate -> None (silx checks `< 0`).
15338        assert_eq!(compare_pixel_at(3, 2, &data, -0.1, 0.0), None);
15339        // No data -> None.
15340        assert_eq!(compare_pixel_at(3, 2, &[], 0.0, 0.0), None);
15341    }
15342
15343    #[test]
15344    fn format_compare_value_matches_silx_status_bar() {
15345        // No image at all -> "no image" (silx `raw is None`).
15346        assert_eq!(format_compare_value(true, None), "no image");
15347        assert_eq!(format_compare_value(true, Some(1.0)), "no image");
15348        // Has data, outside the image -> "NA".
15349        assert_eq!(format_compare_value(false, None), "NA");
15350        // A value formats as silx "%f" (six decimals).
15351        assert_eq!(format_compare_value(false, Some(1.5)), "1.500000");
15352        assert_eq!(format_compare_value(false, Some(0.0)), "0.000000");
15353    }
15354
15355    #[test]
15356    fn margin_image_origin_anchors_top_left() {
15357        // 2x2 source into a 4x3 grid (dst_w=4, dst_h=3), top-left anchored:
15358        // the source occupies rows 0..2, cols 0..2; the rest is zero (silx
15359        // __createMarginImage, pos = (0, 0)).
15360        let src = [1.0_f32, 2.0, 3.0, 4.0]; // row0: 1 2 | row1: 3 4
15361        let out = margin_image(&src, 2, 2, 4, 3, false);
15362        assert_eq!(
15363            out,
15364            vec![
15365                1.0, 2.0, 0.0, 0.0, // row 0
15366                3.0, 4.0, 0.0, 0.0, // row 1
15367                0.0, 0.0, 0.0, 0.0, // row 2
15368            ]
15369        );
15370    }
15371
15372    #[test]
15373    fn margin_image_center_uses_silx_floor_offset() {
15374        // 2x2 source into a 4x4 grid, centered: silx offset = size//2 - shape//2
15375        // = 4//2 - 2//2 = 2 - 1 = 1 on each axis, so the source sits at rows/cols
15376        // 1..3.
15377        let src = [1.0_f32, 2.0, 3.0, 4.0];
15378        let out = margin_image(&src, 2, 2, 4, 4, true);
15379        assert_eq!(
15380            out,
15381            vec![
15382                0.0, 0.0, 0.0, 0.0, // row 0
15383                0.0, 1.0, 2.0, 0.0, // row 1
15384                0.0, 3.0, 4.0, 0.0, // row 2
15385                0.0, 0.0, 0.0, 0.0, // row 3
15386            ]
15387        );
15388    }
15389
15390    #[test]
15391    fn rescale_array_identity_returns_input() {
15392        // Resampling to the same shape samples each pixel exactly (integer
15393        // coordinates, zero fractional weight).
15394        let src = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]; // 3 wide, 2 tall
15395        let out = rescale_array(&src, 3, 2, 3, 2);
15396        assert_eq!(out, src.to_vec());
15397    }
15398
15399    #[test]
15400    fn rescale_array_upscales_bilinearly() {
15401        // 2x1 source [0, 10] (width 2, height 1) upscaled to width 3, height 1.
15402        // Corner-aligned: out col c samples src col c*(2-1)/(3-1) = c*0.5, so
15403        // c=0 -> 0.0, c=1 -> 5.0 (midpoint), c=2 -> 10.0.
15404        let src = [0.0_f32, 10.0];
15405        let out = rescale_array(&src, 2, 1, 3, 1);
15406        assert_eq!(out, vec![0.0, 5.0, 10.0]);
15407    }
15408
15409    #[test]
15410    fn align_compare_images_origin_pads_to_max_grid() {
15411        // A is 1x1, B is 2x2. ORIGIN -> common grid max(1,2) x max(1,2) = 2x2,
15412        // both top-left anchored.
15413        let a = [9.0_f32];
15414        let b = [1.0_f32, 2.0, 3.0, 4.0];
15415        let (d1, d2, cw, ch) = align_compare_images(CompareAlignment::Origin, &a, 1, 1, &b, 2, 2);
15416        assert_eq!((cw, ch), (2, 2));
15417        assert_eq!(d1, vec![9.0, 0.0, 0.0, 0.0]); // A at top-left, padded
15418        assert_eq!(d2, vec![1.0, 2.0, 3.0, 4.0]); // B fills the grid
15419    }
15420
15421    #[test]
15422    fn align_compare_images_center_centers_smaller() {
15423        // A is 1x1, B is 3x3. CENTER -> 3x3 grid; A centered at offset
15424        // 3//2 - 1//2 = 1, so it lands at (row 1, col 1).
15425        let a = [7.0_f32];
15426        let b: Vec<f32> = (1..=9).map(|v| v as f32).collect();
15427        let (d1, d2, cw, ch) = align_compare_images(CompareAlignment::Center, &a, 1, 1, &b, 3, 3);
15428        assert_eq!((cw, ch), (3, 3));
15429        assert_eq!(
15430            d1,
15431            vec![0.0, 0.0, 0.0, 0.0, 7.0, 0.0, 0.0, 0.0, 0.0] // A at center
15432        );
15433        assert_eq!(d2, b); // B fills the grid
15434    }
15435
15436    #[test]
15437    fn align_compare_images_stretch_resamples_b_to_a_shape() {
15438        // A is 3x1, B is 2x1 [0, 10]. STRETCH -> common grid = A's shape (3x1);
15439        // A verbatim, B bilinearly resampled to width 3 -> [0, 5, 10].
15440        let a = [1.0_f32, 2.0, 3.0];
15441        let b = [0.0_f32, 10.0];
15442        let (d1, d2, cw, ch) = align_compare_images(CompareAlignment::Stretch, &a, 3, 1, &b, 2, 1);
15443        assert_eq!((cw, ch), (3, 1));
15444        assert_eq!(d1, a.to_vec());
15445        assert_eq!(d2, vec![0.0, 5.0, 10.0]);
15446    }
15447
15448    #[test]
15449    fn compare_aligned_coords_remaps_per_mode() {
15450        // ORIGIN: identity for both.
15451        assert_eq!(
15452            compare_aligned_coords(CompareAlignment::Origin, 4.0, 5.0, 2, 2, 6, 6),
15453            ((4.0, 5.0), (4.0, 5.0))
15454        );
15455        // CENTER: A (2x2) in a 6x6 grid is offset by (6-2)*0.5 = 2; B (6x6) by 0.
15456        // So display (3,3) maps to A (1,1) and B (3,3).
15457        assert_eq!(
15458            compare_aligned_coords(CompareAlignment::Center, 3.0, 3.0, 2, 2, 6, 6),
15459            ((1.0, 1.0), (3.0, 3.0))
15460        );
15461        // STRETCH: A is identity; B scaled by w_b/w_a and h_b/h_a. A is 4x2,
15462        // B is 8x6, display (2, 1) -> B (2*8/4, 1*6/2) = (4, 3). (NOT silx's
15463        // typo y2 = x*w2/w1 = 2*8/4 = 4.)
15464        assert_eq!(
15465            compare_aligned_coords(CompareAlignment::Stretch, 2.0, 1.0, 4, 2, 8, 6),
15466            ((2.0, 1.0), (4.0, 3.0))
15467        );
15468    }
15469
15470    #[test]
15471    fn align_summary_classifies_the_sift_transform() {
15472        let id = AffineTransformation {
15473            tx: 0.0,
15474            ty: 0.0,
15475            sx: 1.0,
15476            sy: 1.0,
15477            rotation: 0.0,
15478        };
15479        assert_eq!(align_summary(&id), "No changes");
15480
15481        // A tiny (sub-0.01) translation is "No big changes".
15482        let tiny = AffineTransformation { tx: 0.001, ..id };
15483        assert_eq!(align_summary(&tiny), "No big changes");
15484
15485        // Notable translation only.
15486        let shift = AffineTransformation {
15487            tx: 4.0,
15488            ty: 3.0,
15489            ..id
15490        };
15491        assert_eq!(align_summary(&shift), "Translation");
15492
15493        // Translation + scale + rotation, joined in silx order.
15494        let all = AffineTransformation {
15495            tx: 4.0,
15496            ty: 0.0,
15497            sx: 1.2,
15498            sy: 1.0,
15499            rotation: 0.3,
15500        };
15501        assert_eq!(align_summary(&all), "Translation+Scale+Rotation");
15502    }
15503
15504    #[test]
15505    fn align_tooltip_lists_nonidentity_components() {
15506        let id = AffineTransformation {
15507            tx: 0.0,
15508            ty: 0.0,
15509            sx: 1.0,
15510            sy: 1.0,
15511            rotation: 0.0,
15512        };
15513        assert_eq!(align_tooltip(&id), "No transformation");
15514
15515        let t = AffineTransformation {
15516            tx: 4.0,
15517            ty: -2.0,
15518            sx: 1.0,
15519            sy: 1.0,
15520            rotation: std::f64::consts::FRAC_PI_2, // 90°
15521        };
15522        assert_eq!(
15523            align_tooltip(&t),
15524            "Translation x: 4.000px\nTranslation y: -2.000px\nRotation: 90.000deg"
15525        );
15526    }
15527}