Skip to main content

siplot/core/
plot.rs

1//! The plot model.
2//!
3//! Holds the identifier, data-area background, data limits, margins, and the
4//! optional colormap used to draw the colorbar. The item list, log/inverted
5//! axis flags, and dirty tracking are added in later steps
6//! (`doc/design.md` §1·§4·§11).
7
8use egui::{Color32, Rect};
9
10use crate::core::backend::ItemHandle;
11use crate::core::colormap::Colormap;
12use crate::core::dtime_ticks::TimeZone;
13use crate::core::marker::Marker;
14use crate::core::roi::{DEFAULT_ROI_COLOR, ManagedRoi};
15use crate::core::shape::{Line, Shape};
16use crate::core::transform::{Axis, AxisSide, Margins, Scale, Transform, keep_aspect_limits};
17use crate::core::triangles::Triangles;
18
19/// Per-axis pan/zoom range constraints mirroring silx
20/// `Axis.setRangeConstraints` / `Axis.setLimitsConstraints`.
21///
22/// All fields are optional; `None` means unconstrained. Applied by the
23/// interaction helpers after every pan/zoom so the display range always
24/// satisfies all set constraints.
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub struct AxisConstraints {
27    /// Minimum allowed span (display range). Prevents zooming in past this.
28    pub min_range: Option<f64>,
29    /// Maximum allowed span (display range). Prevents zooming out past this.
30    pub max_range: Option<f64>,
31    /// Minimum allowed lower bound. Prevents panning the view below this value.
32    pub min_pos: Option<f64>,
33    /// Maximum allowed upper bound. Prevents panning the view above this value.
34    pub max_pos: Option<f64>,
35}
36
37impl AxisConstraints {
38    /// Return `(lo, hi)` clamped so all set constraints are satisfied. The
39    /// span is corrected first (centered on the current midpoint), then the
40    /// position window is clamped.
41    ///
42    /// Mirrors silx `ViewConstraints` (`_utils/panzoom.py`) with
43    /// `allow_scaling=True` — the only mode siplot uses, since [`apply`]
44    /// runs after every pan/zoom. Two silx mechanisms are reproduced:
45    ///
46    /// - The `update` sanity check (panzoom.py:297-305): when `max_range`,
47    ///   `min_pos` and `max_pos` are all set, the maximum span can never
48    ///   exceed the `[min_pos, max_pos]` window width, so `max_range` is
49    ///   capped to it.
50    /// - The `normalize` position clamp (panzoom.py:337-363): when **both**
51    ///   ends fall outside `[min_pos, max_pos]` the view is wider than the
52    ///   window, so it snaps to exactly `[min_pos, max_pos]` (the span adapts
53    ///   to fit — silx's "adaptive expansion"). When only one end is out, the
54    ///   whole window shifts to pull that end back in bounds.
55    ///
56    /// [`apply`]: AxisConstraints::apply
57    pub fn apply(self, lo: f64, hi: f64) -> (f64, f64) {
58        let mut span = hi - lo;
59        if span <= 0.0 {
60            return (lo, hi);
61        }
62
63        // 1. Cap the max span to the position window when both bounds and a
64        //    max range are set (silx ViewConstraints.update, panzoom.py:297-305).
65        let mut effective_max_range = self.max_range;
66        if let (Some(max_range), Some(min_pos), Some(max_pos)) =
67            (self.max_range, self.min_pos, self.max_pos)
68        {
69            effective_max_range = Some(max_range.min(max_pos - min_pos));
70        }
71
72        // 2. Clamp the span.
73        if let Some(min) = self.min_range
74            && span < min
75        {
76            span = min;
77        }
78        if let Some(max) = effective_max_range
79            && span > max
80        {
81            span = max;
82        }
83
84        // 3. Re-center the clamped span on the original midpoint.
85        let mid = (lo + hi) * 0.5;
86        let mut new_lo = mid - span * 0.5;
87        let mut new_hi = mid + span * 0.5;
88
89        // 4. Clamp the position window (silx ViewConstraints.normalize,
90        //    panzoom.py:337-363). Both ends out -> snap to the window so the
91        //    span shrinks to fit; one end out -> shift the window to pull it in.
92        let below = self.min_pos.filter(|&min_pos| new_lo < min_pos);
93        let above = self.max_pos.filter(|&max_pos| new_hi > max_pos);
94        match (below, above) {
95            (Some(min_pos), Some(max_pos)) => {
96                new_lo = min_pos;
97                new_hi = max_pos;
98            }
99            (Some(min_pos), None) => {
100                let shift = min_pos - new_lo;
101                new_lo += shift;
102                new_hi += shift;
103            }
104            (None, Some(max_pos)) => {
105                let shift = max_pos - new_hi;
106                new_lo += shift;
107                new_hi += shift;
108            }
109            (None, None) => {}
110        }
111
112        // 5. Final sanity — keep lo < hi even if constraints are contradictory.
113        if new_lo >= new_hi {
114            return (lo, hi);
115        }
116
117        (new_lo, new_hi)
118    }
119
120    /// `true` when all fields are `None` (no constraints set).
121    pub fn is_unconstrained(self) -> bool {
122        self.min_range.is_none()
123            && self.max_range.is_none()
124            && self.min_pos.is_none()
125            && self.max_pos.is_none()
126    }
127}
128
129/// Identifier for a single `Plot` instance.
130///
131/// `egui_wgpu`'s `callback_resources` is a global type map, so multi-plot keeps
132/// per-plot GPU state separated by `PlotId` (`doc/design.md` §3.1·§12). The
133/// current steps handle a single plot, so no separation map exists yet.
134pub type PlotId = u64;
135
136/// Whether the X axis lays out regular numeric ticks or date-time ticks,
137/// mirroring silx `items.axis.TickMode` (`items/axis.py:43-47`). In silx only
138/// `XAxis` overrides `getTickMode` / `setTickMode` (`items/axis.py:391-403`),
139/// backed by `setXAxisTimeSeries`; `YAxis` inherits the base
140/// `Axis.setTickMode`, which raises `NotImplementedError` — there is no
141/// `setYAxisTimeSeries`. So the time-series tick mode is an X-axis-only concept.
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143pub enum TickMode {
144    /// Ticks are regular numbers (silx `TickMode.DEFAULT = 0`). Zero behavior
145    /// change from the pre-existing numeric tick layout.
146    #[default]
147    Numeric,
148    /// Ticks are date-times: the axis data values are epoch seconds (UTC) and
149    /// labels are formatted via [`crate::core::dtime_ticks`] (silx
150    /// `TickMode.TIME_SERIES = 1`).
151    TimeSeries,
152}
153
154/// Grid lines drawn in the plot data area.
155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
156pub enum GraphGrid {
157    /// No grid lines.
158    None,
159    /// Major tick grid lines only.
160    #[default]
161    Major,
162    /// Major and minor tick grid lines.
163    MajorAndMinor,
164}
165
166impl GraphGrid {
167    /// Whether major grid lines are drawn.
168    pub fn major(self) -> bool {
169        matches!(self, Self::Major | Self::MajorAndMinor)
170    }
171
172    /// Whether minor grid lines are drawn.
173    pub fn minor(self) -> bool {
174        matches!(self, Self::MajorAndMinor)
175    }
176}
177
178/// Resolve the label to display on an axis, mirroring silx
179/// `items/axis.py:187-218` (`Axis.getLabel` / `_setCurrentLabel`): the active
180/// item's per-axis label is shown when one is set, otherwise it falls back to
181/// the axis' own default label, otherwise an empty string.
182///
183/// `default_label` is the axis' own label (silx `_defaultLabel`, set via
184/// `setGraphXLabel`); `active_label` is the active curve/image's label for this
185/// axis (silx `getXLabel`/`getYLabel`). silx `_setActiveItem` calls
186/// `_setCurrentLabel(activeLabel)`, which displays `activeLabel` when non-empty
187/// and otherwise falls back to `_defaultLabel` — so the active curve's label
188/// *overrides* the graph default when present. A `Some("")` is treated the same
189/// as `None` (silx `_setCurrentLabel` treats an empty string as "no label").
190pub fn resolved_axis_label(default_label: Option<&str>, active_label: Option<&str>) -> String {
191    fn non_empty(s: Option<&str>) -> Option<&str> {
192        s.filter(|l| !l.is_empty())
193    }
194    non_empty(active_label)
195        .or(non_empty(default_label))
196        .unwrap_or("")
197        .to_string()
198}
199
200/// The plot's redraw-dirty state, mirroring silx `PlotWidget._dirty`
201/// (`_getDirtyPlot` returns `False | "overlay" | True`).
202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
203pub enum DirtyState {
204    /// Nothing changed since the last replot (silx `False`).
205    #[default]
206    Clean,
207    /// Only the overlay changed; just the overlay needs redrawing
208    /// (silx `"overlay"`).
209    Overlay,
210    /// The full plot needs redrawing (silx `True`).
211    Full,
212}
213
214/// The full data range of a plot, mirroring silx `_PlotDataRange`
215/// (`PlotWidget.getDataRange`). Each member is the `(min, max)` data bounds for
216/// that axis, or `None` when no data is associated with the axis.
217#[derive(Clone, Copy, Debug, Default, PartialEq)]
218pub struct DataRange {
219    /// X-axis data bounds, or `None` when no item drives the X axis.
220    pub x: Option<(f64, f64)>,
221    /// Left Y-axis data bounds.
222    pub y: Option<(f64, f64)>,
223    /// Right (y2) Y-axis data bounds.
224    pub y2: Option<(f64, f64)>,
225}
226
227/// One of the additional stacked Y axes beyond the built-in left axis
228/// (`Plot::limits`) and right axis (`Plot::y2`), backing [`crate::YAxis::Extra`]
229/// (`Plot::extra`). Each carries its own data range, scale, on-screen side,
230/// autoscale flag, and label — the per-axis state the left/right axes spread
231/// across dedicated `Plot` fields, generalized to N axes for silx-style
232/// multi-axis plots. Curves bound to an axis with no `range` fall back to the
233/// left transform (so they still draw), mirroring the right axis' `y2 == None`
234/// fallback.
235#[derive(Clone, Debug, PartialEq)]
236pub struct ExtraAxis {
237    /// Data range `(min, max)`, or `None` until set explicitly or autoscaled.
238    pub range: Option<(f64, f64)>,
239    /// Linear or log10 scale, independent of the left/right axes.
240    pub scale: Scale,
241    /// Which gutter the ticks and label are drawn in; same-side extra axes stack
242    /// outward in creation order.
243    pub side: AxisSide,
244    /// Whether this axis refits to its curves' data on reset-zoom (silx
245    /// per-axis `setAutoScale`). Defaults to `true`.
246    pub autoscale: bool,
247    /// Axis label drawn rotated in the gutter outside the ticks, or `None`.
248    pub label: Option<String>,
249}
250
251impl ExtraAxis {
252    /// A linear, autoscaling extra axis on `side` with no range or label yet.
253    pub fn new(side: AxisSide) -> Self {
254        Self {
255            range: None,
256            scale: Scale::Linear,
257            side,
258            autoscale: true,
259            label: None,
260        }
261    }
262}
263
264/// Per-side data-margin ratios added around the visible data on reset-zoom
265/// (silx `PlotWidget.setDataMargins` / `_utils.addMarginsToLimits`).
266///
267/// Each field is a ratio of the data range applied to one limit. silx names
268/// these `(xMinMargin, xMaxMargin, yMinMargin, yMaxMargin)`; the field names
269/// here keep that axis/side mapping explicit:
270///
271/// - `x_min` — the X lower (left) side,
272/// - `x_max` — the X upper (right) side,
273/// - `y_min` — the Y lower (bottom) side,
274/// - `y_max` — the Y upper (top) side.
275///
276/// The Y margins also apply to the y2 axis, matching silx (the y2 branch in
277/// `addMarginsToLimits` reuses `yMinMargin`/`yMaxMargin`). Zero by default.
278#[derive(Clone, Copy, Debug, Default, PartialEq)]
279pub struct DataMargins {
280    /// X lower (left) margin ratio.
281    pub x_min: f64,
282    /// X upper (right) margin ratio.
283    pub x_max: f64,
284    /// Y lower (bottom) margin ratio.
285    pub y_min: f64,
286    /// Y upper (top) margin ratio.
287    pub y_max: f64,
288}
289
290impl DataMargins {
291    /// Expand `(lo, hi)` on a single axis by the low/high ratios, in log space
292    /// when `is_log` (silx `addMarginsToLimits`). For a log axis with a
293    /// non-positive bound the margin is skipped (silx "Do not apply margins if
294    /// limits < 0").
295    fn expand_axis(lo: f64, hi: f64, low_ratio: f64, high_ratio: f64, is_log: bool) -> (f64, f64) {
296        if !is_log {
297            let range = hi - lo;
298            (lo - low_ratio * range, hi + high_ratio * range)
299        } else if lo > 0.0 && hi > 0.0 {
300            let lo_log = lo.log10();
301            let hi_log = hi.log10();
302            let range_log = hi_log - lo_log;
303            (
304                10f64.powf(lo_log - low_ratio * range_log),
305                10f64.powf(hi_log + high_ratio * range_log),
306            )
307        } else {
308            (lo, hi)
309        }
310    }
311}
312
313/// One plot.
314pub struct Plot {
315    /// Instance identifier.
316    pub id: PlotId,
317    /// Data-area background color (maps to silx `setBackgroundColors`' data background).
318    pub data_background: Color32,
319    /// Data-space limits `(x_min, x_max, y_min, y_max)`.
320    pub limits: (f64, f64, f64, f64),
321    /// Margins reserving extra space inside the chrome gutters. Zero by default.
322    pub margins: Margins,
323    /// Colormap drawn as the colorbar (mirrors the displayed image's colormap).
324    /// `None` hides the colorbar (`doc/design.md` §5·§8).
325    pub colormap: Option<Colormap>,
326    /// Whether the built-in colorbar is drawn when a [`colormap`](Self::colormap)
327    /// is present. Defaults to `true`. Composite views that render their own
328    /// dedicated colorbar (e.g. `ImageView`, whose internal image plot still
329    /// carries the active image's colormap) set this `false` so the colorbar is
330    /// not drawn twice.
331    pub show_colorbar: bool,
332    /// When `true` (and a [`colormap`](Self::colormap) is shown), the colorbar is
333    /// the interactive pyqtgraph-style histogram colorbar (drag the handles to set
334    /// the colormap's `vmin`/`vmax`) instead of a static strip. Defaults to
335    /// `false`. The drag is surfaced via `PlotResponse::colorbar_dragged_levels`
336    /// for the caller to apply (the colormap and any GPU image re-upload are the
337    /// caller's, mirroring the marker/ROI single-owner pattern).
338    pub colorbar_interactive: bool,
339    /// `(counts, edges)` value-distribution histogram drawn beside the gradient
340    /// when the colorbar is interactive (see
341    /// [`crate::core::histogram::compute_histogram`]); `None` draws just the
342    /// gradient + handles.
343    pub colorbar_histogram: Option<(Vec<u64>, Vec<f64>)>,
344    /// The image value range `(min, max)` the interactive colorbar's axis spans
345    /// (handles move within it). `None` falls back to the colormap's
346    /// `vmin`/`vmax`.
347    pub colorbar_value_range: Option<(f64, f64)>,
348    /// Reserve the title gutter even when [`title`](Self::title) is `None`. Lets a
349    /// side panel (e.g. an `ImageView` profile) keep its data area aligned with a
350    /// reference plot that *does* carry a title, so the two data areas coincide
351    /// by construction. Defaults to `false`.
352    pub reserve_title_gutter: bool,
353    /// Reserve the X-axis-label gutter even when [`x_label`](Self::x_label) is
354    /// `None` (see [`reserve_title_gutter`](Self::reserve_title_gutter)).
355    pub reserve_x_label_gutter: bool,
356    /// Reserve the (left) Y-axis-label gutter even when [`y_label`](Self::y_label)
357    /// is `None` (see [`reserve_title_gutter`](Self::reserve_title_gutter)).
358    pub reserve_y_label_gutter: bool,
359    /// Limits to restore via the Reset Zoom context-menu item. The widget
360    /// captures the first observed `limits` here so the home view survives
361    /// pan/zoom (`doc/design.md` §8·§11.6). `None` until the first frame.
362    pub home_limits: Option<(f64, f64, f64, f64)>,
363    /// X-axis scale (linear or log10) (`doc/design.md` §13 A3).
364    pub x_scale: Scale,
365    /// Y-axis scale (linear or log10).
366    pub y_scale: Scale,
367    /// Reverse the X-axis on-screen direction (`doc/design.md` §13 A2).
368    pub x_inverted: bool,
369    /// Reverse the Y-axis on-screen direction.
370    pub y_inverted: bool,
371    /// Keep data square on screen by expanding the tighter axis' display range
372    /// (silx `setKeepDataAspectRatio`). Only honored when both axes are linear
373    /// (`doc/design.md` §13 A4).
374    pub keep_aspect: bool,
375    /// Secondary right Y axis limits `(y2_min, y2_max)`, or `None` for no y2
376    /// axis. Curves bound to [`crate::YAxis::Right`] are plotted against it and
377    /// its ticks are drawn in the right gutter (linear, `doc/design.md` §13 A5).
378    pub y2: Option<(f64, f64)>,
379    /// Additional stacked Y axes beyond the built-in left/right pair (silx-style
380    /// multi-axis). `extra[i]` backs [`crate::YAxis::Extra(i)`](crate::YAxis::Extra);
381    /// curves bound there plot against its range/scale and its ticks/label are
382    /// drawn stacked outward in its [`AxisSide`] gutter. Empty by default.
383    pub extra: Vec<ExtraAxis>,
384    /// Draw a crosshair + coordinate readout following the pointer when it is
385    /// over the data area (silx `setGraphCursor`, `doc/design.md` §13 C1).
386    pub crosshair: bool,
387    /// Regions of interest drawn over the data area with draggable edge
388    /// handles, each carrying its own appearance (color, name, selection,
389    /// line width/style, fill — silx `RegionOfInterest`). Dragging an edge
390    /// updates that ROI's geometry in place and the widget reports the changed
391    /// index (`doc/design.md` §13 C3).
392    pub rois: Vec<ManagedRoi>,
393    /// Default ROI outline color applied to ROIs without an explicit color
394    /// override (silx `RegionOfInterestManager.getColor`/`setColor`, default
395    /// red). The render resolves each ROI's color as
396    /// `managed.color.unwrap_or(roi_color)`.
397    pub roi_color: Color32,
398    /// Index of the current/highlighted ROI, or `None` (silx
399    /// `RegionOfInterestManager.getCurrentRoi`). Private so
400    /// [`Self::set_current_roi`] is the sole writer of each ROI's `selected`
401    /// flag, keeping "exactly the current ROI is highlighted" true by
402    /// construction.
403    current_roi: Option<usize>,
404    /// Point / line markers drawn over the data area (silx `addMarker`). Each is
405    /// a static overlay; the widget draws the list every frame.
406    pub markers: Vec<Marker>,
407    /// Backend item handles parallel to [`Self::markers`]: `marker_handles[i]` is
408    /// the [`ItemHandle`] of `markers[i]`. Both vectors are rebuilt together by
409    /// the backend's `sync_plot_items` (same length and order), so a marker drag
410    /// can map the dragged mirror index back to the owning backend item for
411    /// persistence. Empty until the first sync.
412    ///
413    /// INVARIANT: `marker_handles.len() == markers.len()` and
414    /// `marker_handles[i]` identifies `markers[i]`.
415    pub marker_handles: Vec<ItemHandle>,
416    /// Polygon / rectangle / polyline / line shapes drawn over the data area
417    /// (silx `addShape`). Static overlays drawn every frame.
418    pub shapes: Vec<Shape>,
419    /// Per-vertex-colored filled triangle meshes drawn in the data layer (silx
420    /// `addTriangles`). Drawn every frame under the chrome.
421    pub triangles: Vec<Triangles>,
422    /// Graph title, drawn centered above the data area (silx `setGraphTitle`,
423    /// `BackendBase.setGraphTitle`). `None` reserves no top space for it.
424    pub title: Option<String>,
425    /// X-axis label, drawn centered below the X tick labels (silx
426    /// `setGraphXLabel`). `None` reserves no extra bottom space.
427    pub x_label: Option<String>,
428    /// Left Y-axis label, drawn rotated at the far left (silx `setGraphYLabel`).
429    /// `None` reserves no extra left space.
430    pub y_label: Option<String>,
431    /// Right (y2) Y-axis label, drawn rotated at the far right; only shown when
432    /// a [`Self::y2`] axis exists. `None` reserves no extra right space.
433    pub y2_label: Option<String>,
434    /// Active curve's X label, overriding [`Self::x_label`] while that curve is
435    /// active (silx `Axis._currentLabel`, set by `_setActiveItem` from the active
436    /// curve's `getXLabel`). The high-level widget repopulates this each frame
437    /// from the active curve; `None` falls back to the default. See
438    /// [`Self::displayed_x_label`].
439    pub active_x_label: Option<String>,
440    /// Active curve's left-Y label, overriding [`Self::y_label`] (silx
441    /// `Axis._currentLabel`). Set only when the active curve is bound to the left
442    /// Y axis. See [`Self::active_x_label`].
443    pub active_y_label: Option<String>,
444    /// Active curve's right (y2) label, overriding [`Self::y2_label`] (silx
445    /// `Axis._currentLabel`). Set only when the active curve is bound to the right
446    /// Y axis. See [`Self::active_x_label`].
447    pub active_y2_label: Option<String>,
448    /// Foreground color override for axes/frame/ticks/labels (silx
449    /// `setForegroundColor`). `None` follows the egui theme's text color.
450    pub foreground: Option<Color32>,
451    /// Grid-line color override (silx `setGridColor`). `None` uses a faint tint
452    /// of the foreground color.
453    pub grid_color: Option<Color32>,
454    /// Grid lines drawn in the data area (`setGraphGrid`).
455    pub grid: GraphGrid,
456    /// Pan/zoom constraints for the X axis (silx `getXAxis().setRangeConstraints`).
457    pub x_constraints: AxisConstraints,
458    /// Pan/zoom constraints for the left Y axis (silx `getYAxis().setRangeConstraints`).
459    pub y_constraints: AxisConstraints,
460    /// Maximum number of major ticks on the X axis.  `None` uses the default
461    /// (8).  The chrome calls `nice_ticks` with this value, so the actual count
462    /// may be slightly lower to keep round step sizes.
463    pub x_max_ticks: Option<usize>,
464    /// Maximum number of major ticks on the left Y axis.  `None` uses the
465    /// default (6).
466    pub y_max_ticks: Option<usize>,
467    /// Limits-history stack mirroring silx `LimitsHistory`. Each entry is a
468    /// full view snapshot `(x_min, x_max, y_min, y_max, y2)`. The widget pushes
469    /// before a zoom/box-zoom/pan; [`Self::zoom_back`] restores the most recent
470    /// entry. Like silx, the stack is unbounded (silx `LimitsHistory` is a plain
471    /// list with no depth cap).
472    limits_history: Vec<LimitsHistoryEntry>,
473    /// Whether the X axis refits to data on reset-zoom (silx
474    /// `Axis.setAutoScale` / `PlotWidget.setXAxisAutoScale`). Defaults to `true`.
475    x_autoscale: bool,
476    /// Whether the left Y axis refits to data on reset-zoom
477    /// (`setYAxisAutoScale`). Defaults to `true`.
478    y_autoscale: bool,
479    /// Whether the right (y2) Y axis refits to data on reset-zoom. Defaults to
480    /// `true`. silx ties y2 autoscale to the left Y axis flag; here it is
481    /// tracked separately so a caller can pin only the y2 range.
482    y2_autoscale: bool,
483    /// Cached per-axis data bounds, mirroring silx `PlotWidget._dataRange`
484    /// (returned by `getDataRange`). The high-level widget owns item data and
485    /// pushes the accumulated bounds here via [`Self::set_data_range`]; the model
486    /// layer holds no items, so this is `None` until populated.
487    data_range: Option<DataRange>,
488    /// Per-side data margins applied around the visible data on reset-zoom
489    /// (silx `setDataMargins`). Zero by default.
490    data_margins: DataMargins,
491    /// Whether the axes (frame, ticks, labels) are displayed (silx
492    /// `setAxesDisplayed` / `isAxesDisplayed`). Defaults to `true`. Wired: when
493    /// `false` the widget passes `axes_hidden` to `chrome::layout`, which
494    /// collapses the axis gutters to zero so the data area fills the rect
495    /// (silx `setAxesDisplayed(False)` -> `setAxesMargins(0,0,0,0)`).
496    axes_displayed: bool,
497    /// Redraw-dirty state (silx `_dirty`). Defaults to [`DirtyState::Clean`].
498    dirty: DirtyState,
499    /// Whether the plot is redrawn automatically on change (silx `_autoreplot`).
500    /// Defaults to `true`, matching silx after `_init`.
501    autoreplot: bool,
502    /// X-axis tick mode (silx `getXAxis().getTickMode`). Defaults to
503    /// [`TickMode::Numeric`] (zero behavior change). When [`TickMode::TimeSeries`]
504    /// the chrome formats the X tick labels as date-times. silx supports the
505    /// time-series mode on the X axis only (see [`TickMode`]), so there is no
506    /// Y-axis counterpart.
507    x_tick_mode: TickMode,
508    /// X-axis time zone used to lay out date-time ticks when
509    /// [`TickMode::TimeSeries`] is active (silx `getXAxis().setTimeZone`).
510    /// Defaults to [`TimeZone::Utc`], matching the previous UTC-only behavior.
511    x_time_zone: TimeZone,
512    /// Epoch offset added to X tick *values* before they are formatted as
513    /// date-times under [`TickMode::TimeSeries`] (siplot extension, no silx
514    /// counterpart). The stored X data values are interpreted as
515    /// `epoch - x_time_offset`, so a caller can upload small relative
516    /// coordinates — an `f32`-safe GPU vertex range — while the time ticks still
517    /// read absolute wall-clock. The curve vertex path packs positions as `f32`
518    /// (`render/gpu_curve.rs::pack`), so an absolute epoch (~1.7e9) would lose
519    /// ~128 s of precision and a few-second strip-chart window could not render.
520    /// Defaults to `0.0` (the X values *are* epoch seconds, so TimeSeries labels
521    /// are unchanged from the offset-free behavior).
522    x_time_offset: f64,
523    /// Infinite line items drawn over the data area (silx `Line`,
524    /// `items/shape.py:289`). Each is clipped to the current viewport and drawn
525    /// every frame.
526    lines: Vec<Line>,
527    /// Whether the arrow keys pan the data area when the plot is focused (silx
528    /// `setPanWithArrowKeys` / `isPanWithArrowKeys`). Defaults to `true`,
529    /// matching silx `PlotWidget._panWithArrowKeys = True`. When `false` the
530    /// widget ignores arrow-key presses (silx gates the same handler on
531    /// `if self._panWithArrowKeys` in `PlotWidget._handleArrowKey`).
532    pan_with_arrow_keys: bool,
533    /// Whether a box zoom changes the X axis (silx `ZoomEnabledAxesMenu` /
534    /// `Zoom.enabledAxes.xaxis`). Defaults to `true`. When `false` a box zoom
535    /// keeps the current X range, matching silx `Zoom._getAxesExtent`, which
536    /// replaces a disabled axis's selection extent with the full plot bounds so
537    /// that axis is left unchanged.
538    zoom_x_enabled: bool,
539    /// Whether a box zoom changes the (left) Y axis (silx
540    /// `Zoom.enabledAxes.yaxis`). Defaults to `true`. silx also tracks the right
541    /// (y2) axis here, but siplot's box zoom operates on the left axes only, so
542    /// there is no y2 counterpart.
543    zoom_y_enabled: bool,
544}
545
546/// One snapshot in [`Plot::limits_history`]: the left-axes limits plus the
547/// optional right (y2) axis range, mirroring silx `LimitsHistory`'s
548/// `(xmin, xmax, ymin, ymax, y2min, y2max)` tuple.
549type LimitsHistoryEntry = ((f64, f64, f64, f64), Option<(f64, f64)>);
550
551impl Plot {
552    /// Create a plot with the given id, a default dark background, unit limits,
553    /// no margins, and no colorbar.
554    pub fn new(id: PlotId) -> Self {
555        Self {
556            id,
557            data_background: Color32::from_rgb(16, 16, 24),
558            limits: (0.0, 1.0, 0.0, 1.0),
559            margins: Margins::ZERO,
560            colormap: None,
561            show_colorbar: true,
562            colorbar_interactive: false,
563            colorbar_histogram: None,
564            colorbar_value_range: None,
565            reserve_title_gutter: false,
566            reserve_x_label_gutter: false,
567            reserve_y_label_gutter: false,
568            home_limits: None,
569            x_scale: Scale::Linear,
570            y_scale: Scale::Linear,
571            x_inverted: false,
572            y_inverted: false,
573            keep_aspect: false,
574            y2: None,
575            extra: Vec::new(),
576            crosshair: false,
577            rois: Vec::new(),
578            roi_color: DEFAULT_ROI_COLOR,
579            current_roi: None,
580            markers: Vec::new(),
581            marker_handles: Vec::new(),
582            shapes: Vec::new(),
583            triangles: Vec::new(),
584            title: None,
585            x_label: None,
586            y_label: None,
587            y2_label: None,
588            active_x_label: None,
589            active_y_label: None,
590            active_y2_label: None,
591            foreground: None,
592            grid_color: None,
593            grid: GraphGrid::Major,
594            x_constraints: AxisConstraints::default(),
595            y_constraints: AxisConstraints::default(),
596            x_max_ticks: None,
597            y_max_ticks: None,
598            limits_history: Vec::new(),
599            x_autoscale: true,
600            y_autoscale: true,
601            y2_autoscale: true,
602            data_range: None,
603            data_margins: DataMargins::default(),
604            axes_displayed: true,
605            dirty: DirtyState::Clean,
606            autoreplot: true,
607            x_tick_mode: TickMode::Numeric,
608            x_time_zone: TimeZone::Utc,
609            x_time_offset: 0.0,
610            lines: Vec::new(),
611            pan_with_arrow_keys: true,
612            zoom_x_enabled: true,
613            zoom_y_enabled: true,
614        }
615    }
616
617    /// The index of the current/highlighted ROI, or `None` (silx
618    /// `RegionOfInterestManager.getCurrentRoi`).
619    pub fn current_roi(&self) -> Option<usize> {
620        self.current_roi
621    }
622
623    /// Set the current ROI by index, or `None` to clear it (silx
624    /// `RegionOfInterestManager.setCurrentRoi`): the previous current ROI loses
625    /// its highlight and the new one gains it. An out-of-range index clears the
626    /// selection. This is the sole writer of every ROI's `selected` flag, so the
627    /// invariant "exactly the current ROI is highlighted" holds by construction.
628    pub fn set_current_roi(&mut self, index: Option<usize>) {
629        self.current_roi = match index {
630            Some(i) if i < self.rois.len() => Some(i),
631            _ => None,
632        };
633        self.sync_roi_selection();
634    }
635
636    /// Mirror [`Self::current_roi`] onto each ROI's `selected` flag so exactly
637    /// the current ROI is highlighted (silx highlights only the current ROI).
638    fn sync_roi_selection(&mut self) {
639        let current = self.current_roi;
640        for (i, r) in self.rois.iter_mut().enumerate() {
641            r.selected = Some(i) == current;
642        }
643    }
644
645    /// Remove the ROI at `index`, adjusting [`Self::current_roi`] so it keeps
646    /// pointing at the same ROI (or clears when the current one is removed),
647    /// then re-syncing the `selected` flags (silx
648    /// `RegionOfInterestManager.removeRoi`). An out-of-range index is ignored.
649    /// This is the sole removal path, so the current-ROI invariant holds across
650    /// every removal (no caller pokes `rois`/`current_roi` directly).
651    pub fn remove_roi(&mut self, index: usize) {
652        if index >= self.rois.len() {
653            return;
654        }
655        self.rois.remove(index);
656        self.current_roi = match self.current_roi {
657            Some(c) if c == index => None,
658            Some(c) if c > index => Some(c - 1),
659            other => other,
660        };
661        self.sync_roi_selection();
662    }
663
664    /// Remove every ROI and clear the current selection (silx
665    /// `RegionOfInterestManager.clear`). Resetting `current_roi` to `None` keeps
666    /// the invariant: no current index dangles past the emptied collection.
667    pub fn clear_rois(&mut self) {
668        self.rois.clear();
669        self.current_roi = None;
670    }
671
672    /// Append the current view (left limits plus the y2 range) to the limits
673    /// history, mirroring silx `LimitsHistory.push`. The widget calls this
674    /// before applying a zoom/box-zoom/pan so [`Self::zoom_back`] can restore it.
675    pub fn push_limits(&mut self) {
676        self.limits_history.push((self.limits, self.y2));
677    }
678
679    /// Restore the most recently pushed view, mirroring silx
680    /// `LimitsHistory.pop`. Returns `true` if a stored view was restored, or
681    /// `false` if the history was empty (silx falls back to `resetZoom`; here
682    /// the caller decides, and `false` signals that nothing was restored).
683    pub fn zoom_back(&mut self) -> bool {
684        if let Some((limits, y2)) = self.limits_history.pop() {
685            self.limits = limits;
686            self.y2 = y2;
687            true
688        } else {
689            false
690        }
691    }
692
693    /// Clear the stored limits history, mirroring silx `LimitsHistory.clear`
694    /// (called on reset / zoom-mode change).
695    pub fn clear_limits_history(&mut self) {
696        self.limits_history.clear();
697    }
698
699    /// Number of stored history entries, mirroring `len(LimitsHistory)`.
700    pub fn limits_history_len(&self) -> usize {
701        self.limits_history.len()
702    }
703
704    /// Whether the X axis refits to data on reset-zoom (silx
705    /// `isXAxisAutoScale`).
706    pub fn x_autoscale(&self) -> bool {
707        self.x_autoscale
708    }
709
710    /// Set whether the X axis refits to data on reset-zoom
711    /// (silx `setXAxisAutoScale`).
712    pub fn set_x_autoscale(&mut self, on: bool) {
713        self.x_autoscale = on;
714    }
715
716    /// Whether the left Y axis refits to data on reset-zoom (silx
717    /// `isYAxisAutoScale`).
718    pub fn y_autoscale(&self) -> bool {
719        self.y_autoscale
720    }
721
722    /// Set whether the left Y axis refits to data on reset-zoom
723    /// (silx `setYAxisAutoScale`).
724    pub fn set_y_autoscale(&mut self, on: bool) {
725        self.y_autoscale = on;
726    }
727
728    /// Whether the right (y2) Y axis refits to data on reset-zoom.
729    pub fn y2_autoscale(&self) -> bool {
730        self.y2_autoscale
731    }
732
733    /// Set whether the right (y2) Y axis refits to data on reset-zoom.
734    pub fn set_y2_autoscale(&mut self, on: bool) {
735        self.y2_autoscale = on;
736    }
737
738    /// The cached per-axis data range, mirroring silx `getDataRange`. Returns a
739    /// [`DataRange`] with each member `None` until the high-level widget pushes
740    /// bounds via [`Self::set_data_range`]. silx lazily recomputes from items
741    /// here; this model layer holds no items, so an unset range reads as all
742    /// `None`.
743    pub fn data_range(&self) -> DataRange {
744        self.data_range.unwrap_or_default()
745    }
746
747    /// Store the accumulated per-axis data bounds (silx populates `_dataRange`
748    /// from its items in `_updateDataRange`). The high-level widget owns the
749    /// item data and calls this; [`Self::reset_zoom`] then refits from it.
750    pub fn set_data_range(&mut self, range: DataRange) {
751        self.data_range = Some(range);
752    }
753
754    /// Refit the view from the cached [`Self::data_range`], honoring the per-axis
755    /// autoscale flags (silx `PlotWidget.resetZoom` with `getDataRange()`).
756    /// Equivalent to `reset_zoom_to_data_range(self.data_range())`.
757    pub fn reset_zoom(&mut self) {
758        self.reset_zoom_to_data_range(self.data_range());
759    }
760
761    /// The per-side data margins applied around the data on reset-zoom (silx
762    /// `getDataMargins`).
763    pub fn data_margins(&self) -> DataMargins {
764        self.data_margins
765    }
766
767    /// Set the per-side data margins (silx `setDataMargins`). The ratios expand
768    /// each refit axis around its data range on the next reset-zoom; for log
769    /// axes they expand in log space.
770    pub fn set_data_margins(&mut self, margins: DataMargins) {
771        self.data_margins = margins;
772    }
773
774    /// Whether the axes (frame/ticks/labels) are displayed (silx
775    /// `isAxesDisplayed`).
776    pub fn axes_displayed(&self) -> bool {
777        self.axes_displayed
778    }
779
780    /// Show or hide the axes (silx `setAxesDisplayed`). When hidden, the widget
781    /// passes `axes_hidden` to `chrome::layout`, which drops the axis gutters to
782    /// zero (silx `setAxesMargins(0,0,0,0)`). Marks the plot dirty
783    /// (full redraw) when the value changes, mirroring silx
784    /// `setAxesDisplayed`'s `_setDirtyPlot()`.
785    pub fn set_axes_displayed(&mut self, displayed: bool) {
786        if displayed != self.axes_displayed {
787            self.axes_displayed = displayed;
788            self.set_dirty(false);
789        }
790    }
791
792    /// Whether the arrow keys pan the data area when the plot is focused (silx
793    /// `isPanWithArrowKeys`).
794    pub fn pan_with_arrow_keys(&self) -> bool {
795        self.pan_with_arrow_keys
796    }
797
798    /// Enable or disable arrow-key panning (silx `setPanWithArrowKeys`). Unlike
799    /// most setters this does not mark the plot dirty: it only changes how a
800    /// future key press is handled, never the current frame (silx
801    /// `setPanWithArrowKeys` sets the flag without `_setDirtyPlot`).
802    pub fn set_pan_with_arrow_keys(&mut self, pan: bool) {
803        self.pan_with_arrow_keys = pan;
804    }
805
806    /// Whether a box zoom changes the X axis (silx `Zoom.enabledAxes.xaxis`).
807    pub fn zoom_x_enabled(&self) -> bool {
808        self.zoom_x_enabled
809    }
810
811    /// Whether a box zoom changes the (left) Y axis (silx
812    /// `Zoom.enabledAxes.yaxis`).
813    pub fn zoom_y_enabled(&self) -> bool {
814        self.zoom_y_enabled
815    }
816
817    /// Choose which axes a box zoom affects (silx
818    /// `PlotInteraction.setZoomEnabledAxes`). A disabled axis keeps its current
819    /// range when a box zoom is applied. Like silx's setter this is a plain
820    /// flag set: it does not mark the plot dirty (it only changes how a future
821    /// box zoom is applied, not the current frame).
822    pub fn set_zoom_enabled_axes(&mut self, x_enabled: bool, y_enabled: bool) {
823        self.zoom_x_enabled = x_enabled;
824        self.zoom_y_enabled = y_enabled;
825    }
826
827    /// The current redraw-dirty state (silx `_getDirtyPlot`).
828    pub fn dirty(&self) -> DirtyState {
829        self.dirty
830    }
831
832    /// Mark the plot as needing redraw (silx `_setDirtyPlot`). `overlay_only`
833    /// requests an overlay-only redraw. The transition matches silx exactly:
834    /// from [`DirtyState::Clean`] an overlay-only mark becomes
835    /// [`DirtyState::Overlay`] and a full mark becomes [`DirtyState::Full`];
836    /// from any already-dirty state the mark escalates to [`DirtyState::Full`]
837    /// (an overlay-only mark cannot downgrade an already-pending full redraw).
838    pub fn set_dirty(&mut self, overlay_only: bool) {
839        self.dirty = if self.dirty == DirtyState::Clean && overlay_only {
840            DirtyState::Overlay
841        } else {
842            DirtyState::Full
843        };
844    }
845
846    /// Clear the dirty state to [`DirtyState::Clean`] (silx resets `_dirty` to
847    /// `False` in `_paintContext` after drawing). Call after a redraw has been
848    /// performed.
849    pub fn replot(&mut self) {
850        self.dirty = DirtyState::Clean;
851    }
852
853    /// Whether automatic replot is enabled (silx `getAutoReplot`).
854    pub fn autoreplot(&self) -> bool {
855        self.autoreplot
856    }
857
858    /// Enable or disable automatic replot (silx `setAutoReplot`). State only;
859    /// the render loop that would honor this is at the widget layer (deferred).
860    pub fn set_autoreplot(&mut self, autoreplot: bool) {
861        self.autoreplot = autoreplot;
862    }
863
864    /// The X-axis tick mode (silx `getXAxis().getTickMode`).
865    pub fn x_tick_mode(&self) -> TickMode {
866        self.x_tick_mode
867    }
868
869    /// Set the X-axis tick mode (silx `getXAxis().setTickMode`). With
870    /// [`TickMode::TimeSeries`] the chrome formats the X tick labels as
871    /// date-times (the data values are epoch seconds, UTC).
872    pub fn set_x_tick_mode(&mut self, mode: TickMode) {
873        self.x_tick_mode = mode;
874    }
875
876    /// The X-axis time zone for date-time ticks (silx
877    /// `getXAxis().getTimeZone`).
878    pub fn x_time_zone(&self) -> TimeZone {
879        self.x_time_zone
880    }
881
882    /// Set the X-axis time zone for date-time ticks (silx
883    /// `getXAxis().setTimeZone`). Only affects layout while the X tick mode is
884    /// [`TickMode::TimeSeries`]; the data values stay epoch seconds (UTC) and
885    /// the ticks are laid out in this zone's wall-clock calendar.
886    pub fn set_x_time_zone(&mut self, tz: TimeZone) {
887        self.x_time_zone = tz;
888    }
889
890    /// The X-axis epoch offset applied to date-time tick labels (siplot
891    /// extension; see the `x_time_offset` field docs).
892    pub fn x_time_offset(&self) -> f64 {
893        self.x_time_offset
894    }
895
896    /// Set the epoch offset added to X tick values before [`TickMode::TimeSeries`]
897    /// formatting, so a caller feeding relative (`f32`-safe) X coordinates still
898    /// gets absolute wall-clock tick labels. The stored X values are interpreted
899    /// as `epoch - offset`; `0.0` (the default) means the X values already are
900    /// epoch seconds. No effect outside the TimeSeries tick mode.
901    pub fn set_x_time_offset(&mut self, offset: f64) {
902        self.x_time_offset = offset;
903    }
904
905    /// Append an infinite line item (silx `addItem` of a `Line`). The widget
906    /// clips each line to the current viewport and draws it every frame.
907    pub fn add_line(&mut self, line: Line) {
908        self.lines.push(line);
909    }
910
911    /// The infinite line items (silx `Line` items).
912    pub fn lines(&self) -> &[Line] {
913        &self.lines
914    }
915
916    /// Mutable access to the infinite line items.
917    pub fn lines_mut(&mut self) -> &mut Vec<Line> {
918        &mut self.lines
919    }
920
921    /// The X-axis label to display, given the active curve's X label (silx
922    /// `Axis.getLabel`). The active curve's `active_label` overrides the default
923    /// [`Self::x_label`] when set, otherwise the default shows, otherwise empty.
924    /// See [`resolved_axis_label`].
925    pub fn x_axis_label(&self, active_label: Option<&str>) -> String {
926        resolved_axis_label(self.x_label.as_deref(), active_label)
927    }
928
929    /// The left-Y-axis label to display, given the active curve's Y label (silx
930    /// `Axis.getLabel`). See [`Self::x_axis_label`].
931    pub fn y_axis_label(&self, active_label: Option<&str>) -> String {
932        resolved_axis_label(self.y_label.as_deref(), active_label)
933    }
934
935    /// The right (y2) axis label to display, given the active curve's y2 label
936    /// (silx `Axis.getLabel`). See [`Self::x_axis_label`].
937    pub fn y2_axis_label(&self, active_label: Option<&str>) -> String {
938        resolved_axis_label(self.y2_label.as_deref(), active_label)
939    }
940
941    /// The X-axis label actually drawn this frame: the active curve's X label
942    /// ([`Self::active_x_label`], set by the widget from the active curve)
943    /// overriding the graph default [`Self::x_label`], or `None` when neither is
944    /// set (silx `Axis._currentLabel`). `None` means nothing is drawn.
945    pub fn displayed_x_label(&self) -> Option<String> {
946        let label = self.x_axis_label(self.active_x_label.as_deref());
947        (!label.is_empty()).then_some(label)
948    }
949
950    /// The left-Y-axis label actually drawn this frame (silx `Axis._currentLabel`).
951    /// See [`Self::displayed_x_label`].
952    pub fn displayed_y_label(&self) -> Option<String> {
953        let label = self.y_axis_label(self.active_y_label.as_deref());
954        (!label.is_empty()).then_some(label)
955    }
956
957    /// The right (y2) axis label actually drawn this frame (silx
958    /// `Axis._currentLabel`). See [`Self::displayed_x_label`].
959    pub fn displayed_y2_label(&self) -> Option<String> {
960        let label = self.y2_axis_label(self.active_y2_label.as_deref());
961        (!label.is_empty()).then_some(label)
962    }
963
964    /// Whether the title gutter must be reserved this frame: a title is present,
965    /// or [`reserve_title_gutter`](Self::reserve_title_gutter) forces it (e.g. an
966    /// `ImageView` profile aligning to a titled reference plot). Single source of
967    /// truth for the chrome request bool, so the three gutters stay parallel.
968    pub fn needs_title_gutter(&self) -> bool {
969        self.title.is_some() || self.reserve_title_gutter
970    }
971
972    /// Whether the X-axis-label gutter must be reserved this frame. See
973    /// [`needs_title_gutter`](Self::needs_title_gutter).
974    pub fn needs_x_label_gutter(&self) -> bool {
975        self.x_label.is_some() || self.reserve_x_label_gutter
976    }
977
978    /// Whether the (left) Y-axis-label gutter must be reserved this frame. See
979    /// [`needs_title_gutter`](Self::needs_title_gutter).
980    pub fn needs_y_label_gutter(&self) -> bool {
981        self.y_label.is_some() || self.reserve_y_label_gutter
982    }
983
984    /// The explicit grid-line color override (silx `getGridColor`). `None` means
985    /// the grid follows the foreground color; see [`Self::effective_grid_color`].
986    pub fn grid_color(&self) -> Option<Color32> {
987        self.grid_color
988    }
989
990    /// Set (or clear, with `None`) the grid-line color override (silx
991    /// `setGridColor`). Marks the plot dirty on change, mirroring silx's
992    /// `_foregroundColorsUpdated` -> `_setDirtyPlot()`. Wired: the widget passes
993    /// this through `chrome::Theme::with_overrides(foreground, grid_color)`
994    /// (`plot_widget.rs`), so the grid renders with this color independently of
995    /// the foreground (axis/frame) color; see [`Self::effective_grid_color`].
996    pub fn set_grid_color(&mut self, color: Option<Color32>) {
997        if self.grid_color != color {
998            self.grid_color = color;
999            self.set_dirty(false);
1000        }
1001    }
1002
1003    /// Resolve the color the grid lines should use given the resolved
1004    /// `foreground` color, mirroring silx `_foregroundColorsUpdated`: the
1005    /// explicit [`Self::grid_color`] when set, otherwise `foreground`.
1006    pub fn effective_grid_color(&self, foreground: Color32) -> Color32 {
1007        self.grid_color.unwrap_or(foreground)
1008    }
1009
1010    /// Refit the view to `data` honoring the per-axis autoscale flags, mirroring
1011    /// silx `PlotWidget.resetZoom`. An axis whose autoscale flag is off keeps its
1012    /// current display range; an axis whose flag is on is refit to its data
1013    /// bounds (when present).
1014    ///
1015    /// silx also forces autoscale on a log axis whose current lower limit is
1016    /// `<= 0` (so toggling to log re-fits to positive data); that rule is applied
1017    /// here per axis via the [`Scale::Log10`] check (matches
1018    /// `PlotWidget.resetZoom`:3377-3382). Axes with no data bounds and autoscale
1019    /// off are left untouched.
1020    ///
1021    /// This is the pure model operation; the high-level widget owns the actual
1022    /// `data` accumulation (its `DataBounds`) and calls this with the current
1023    /// range.
1024    pub fn reset_zoom_to_data_range(&mut self, data: DataRange) {
1025        let (mut x_min, mut x_max, mut y_min, mut y_max) = self.limits;
1026        let mut y2 = self.y2;
1027
1028        // Force autoscale on a log axis whose lower limit is <= 0 (silx
1029        // resetZoom:3377-3382).
1030        let x_auto = self.x_autoscale || (self.x_scale == Scale::Log10 && x_min <= 0.0);
1031        let y_log_force = self.y_scale == Scale::Log10
1032            && (y_min <= 0.0 || self.y2.map(|(lo, _)| lo <= 0.0).unwrap_or(false));
1033        let y_auto = self.y_autoscale || y_log_force;
1034        let y2_auto = self.y2_autoscale || y_log_force;
1035
1036        // Track which axes are refit; only those receive data margins, matching
1037        // silx (pinned axes are restored after _forceResetZoom without margins).
1038        let mut x_refit = false;
1039        let mut y_refit = false;
1040        let mut y2_refit = false;
1041
1042        if x_auto && let Some((dmin, dmax)) = data.x {
1043            x_min = dmin;
1044            x_max = dmax;
1045            x_refit = true;
1046        }
1047        if y_auto && let Some((dmin, dmax)) = data.y {
1048            y_min = dmin;
1049            y_max = dmax;
1050            y_refit = true;
1051        }
1052        if y2_auto && let Some((dmin, dmax)) = data.y2 {
1053            y2 = Some((dmin, dmax));
1054            y2_refit = true;
1055        }
1056
1057        // Expand refit axes by the data margins (silx applies margins=True in
1058        // setLimits during _forceResetZoom; addMarginsToLimits respects log
1059        // axes and the shared y/y2 margin ratios).
1060        let m = self.data_margins;
1061        let x_is_log = self.x_scale == Scale::Log10;
1062        let y_is_log = self.y_scale == Scale::Log10;
1063        if x_refit {
1064            (x_min, x_max) = DataMargins::expand_axis(x_min, x_max, m.x_min, m.x_max, x_is_log);
1065        }
1066        if y_refit {
1067            (y_min, y_max) = DataMargins::expand_axis(y_min, y_max, m.y_min, m.y_max, y_is_log);
1068        }
1069        if y2_refit && let Some((lo, hi)) = y2 {
1070            // y2 axis uses the Y margin ratios and the Y log flag (silx reuses
1071            // yMinMargin/yMaxMargin and isYLog for the y2 branch).
1072            y2 = Some(DataMargins::expand_axis(lo, hi, m.y_min, m.y_max, y_is_log));
1073        }
1074
1075        self.limits = (x_min, x_max, y_min, y_max);
1076        self.y2 = y2;
1077    }
1078
1079    /// Build the data↔screen transform for the given data-area rect, honoring
1080    /// the per-axis scale, inversion, and (linear-only) aspect-ratio lock.
1081    ///
1082    /// Aspect correction is derived here from the stable requested `limits`, so
1083    /// it is the same view used for rendering, chrome, and pointer mapping —
1084    /// and resizing never compounds the expansion (`doc/design.md` §13 A4).
1085    pub fn transform(&self, area: Rect) -> Transform {
1086        let linear = self.x_scale == Scale::Linear && self.y_scale == Scale::Linear;
1087        let (x_min, x_max, y_min, y_max) = if self.keep_aspect && linear {
1088            keep_aspect_limits(self.limits, area)
1089        } else {
1090            self.limits
1091        };
1092        let x = Axis {
1093            min: x_min,
1094            max: x_max,
1095            scale: self.x_scale,
1096            inverted: self.x_inverted,
1097        };
1098        let y = Axis {
1099            min: y_min,
1100            max: y_max,
1101            scale: self.y_scale,
1102            inverted: self.y_inverted,
1103        };
1104        Transform::with_axes(x, y, area)
1105    }
1106
1107    /// Build the transform for the secondary right (y2) axis, sharing the left
1108    /// transform's X axis exactly (including any aspect expansion) so curves on
1109    /// both axes stay aligned in X. `None` when the plot has no y2 axis. The y2
1110    /// axis is linear, non-inverted (`doc/design.md` §13 A5).
1111    pub fn transform_y2(&self, area: Rect) -> Option<Transform> {
1112        let (y2_min, y2_max) = self.y2?;
1113        let left = self.transform(area);
1114        let y2 = Axis::linear(y2_min, y2_max);
1115        Some(Transform::with_axes(left.x, y2, area))
1116    }
1117
1118    /// Append an extra Y axis on `side` (range/label unset, linear, autoscaling)
1119    /// and return its index, usable as [`crate::YAxis::Extra(index)`](crate::YAxis::Extra).
1120    pub fn add_extra_axis(&mut self, side: AxisSide) -> usize {
1121        self.extra.push(ExtraAxis::new(side));
1122        self.extra.len() - 1
1123    }
1124
1125    /// The extra axes in creation order (silx-style multi-axis).
1126    pub fn extra_axes(&self) -> &[ExtraAxis] {
1127        &self.extra
1128    }
1129
1130    /// Shared read access to extra axis `index`, or `None` when unknown.
1131    pub fn extra_axis(&self, index: usize) -> Option<&ExtraAxis> {
1132        self.extra.get(index)
1133    }
1134
1135    /// Mutable access to extra axis `index`, or `None` when unknown.
1136    pub fn extra_axis_mut(&mut self, index: usize) -> Option<&mut ExtraAxis> {
1137        self.extra.get_mut(index)
1138    }
1139
1140    /// Build the transform for extra axis `index`, sharing the left transform's
1141    /// X axis exactly (including any aspect expansion) so curves on every axis
1142    /// stay aligned in X, and using the extra axis' own range and scale as Y.
1143    /// `None` when the index is unknown or the axis has no range yet (the caller
1144    /// then falls back to the left transform, matching [`Self::transform_y2`]).
1145    pub fn transform_extra(&self, index: usize, area: Rect) -> Option<Transform> {
1146        let ax = self.extra.get(index)?;
1147        let (min, max) = ax.range?;
1148        let left = self.transform(area);
1149        let y = Axis {
1150            min,
1151            max,
1152            scale: ax.scale,
1153            inverted: false,
1154        };
1155        Some(Transform::with_axes(left.x, y, area))
1156    }
1157
1158    /// Refit each autoscale-on extra axis to its data bounds, the multi-axis
1159    /// sibling of the left/right refit in [`Self::reset_zoom_to_data_range`].
1160    /// `data[i]` is extra axis `i`'s data bounds; a missing or `None` entry
1161    /// leaves that axis' range unchanged. Refit axes are expanded by the Y data
1162    /// margins (reusing the y2 margin/log rules), and a log axis whose lower
1163    /// bound is non-positive is force-refit so toggling it to log re-fits to
1164    /// positive data (matching the y2 branch of `reset_zoom_to_data_range`).
1165    pub fn reset_extra_axes_to(&mut self, data: &[Option<(f64, f64)>]) {
1166        let m = self.data_margins;
1167        for (i, ax) in self.extra.iter_mut().enumerate() {
1168            let is_log = ax.scale == Scale::Log10;
1169            let log_force = is_log && ax.range.map(|(lo, _)| lo <= 0.0).unwrap_or(true);
1170            if !(ax.autoscale || log_force) {
1171                continue;
1172            }
1173            if let Some(Some((lo, hi))) = data.get(i).copied() {
1174                ax.range = Some(DataMargins::expand_axis(lo, hi, m.y_min, m.y_max, is_log));
1175            }
1176        }
1177    }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use egui::pos2;
1184
1185    fn area() -> Rect {
1186        Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 100.0))
1187    }
1188
1189    #[test]
1190    fn axis_constraints_unconstrained_is_passthrough() {
1191        let c = AxisConstraints::default();
1192        assert_eq!(c.apply(0.0, 10.0), (0.0, 10.0));
1193        assert!(c.is_unconstrained());
1194    }
1195
1196    #[test]
1197    fn axis_constraints_min_range_widens_span() {
1198        let c = AxisConstraints {
1199            min_range: Some(5.0),
1200            ..Default::default()
1201        };
1202        // Current span is 2.0, below min; should be widened to 5.0 centered on 1.0.
1203        let (lo, hi) = c.apply(0.0, 2.0);
1204        assert!((hi - lo - 5.0).abs() < 1e-10, "span={}", hi - lo);
1205        assert!(((lo + hi) / 2.0 - 1.0).abs() < 1e-10); // centered on original mid
1206    }
1207
1208    #[test]
1209    fn axis_constraints_max_range_narrows_span() {
1210        let c = AxisConstraints {
1211            max_range: Some(5.0),
1212            ..Default::default()
1213        };
1214        // Current span is 10.0, above max; should be narrowed to 5.0 centered on 5.0.
1215        let (lo, hi) = c.apply(0.0, 10.0);
1216        assert!((hi - lo - 5.0).abs() < 1e-10, "span={}", hi - lo);
1217        assert!(((lo + hi) / 2.0 - 5.0).abs() < 1e-10);
1218    }
1219
1220    #[test]
1221    fn axis_constraints_min_pos_shifts_window_right() {
1222        let c = AxisConstraints {
1223            min_pos: Some(2.0),
1224            ..Default::default()
1225        };
1226        // View [0, 4] would place lo below min_pos=2; shift right so lo=2.
1227        let (lo, hi) = c.apply(0.0, 4.0);
1228        assert!((lo - 2.0).abs() < 1e-10, "lo={lo}");
1229        assert!((hi - 6.0).abs() < 1e-10, "hi={hi}");
1230    }
1231
1232    #[test]
1233    fn axis_constraints_max_pos_shifts_window_left() {
1234        let c = AxisConstraints {
1235            max_pos: Some(8.0),
1236            ..Default::default()
1237        };
1238        // View [6, 12] places hi above max_pos=8; shift left so hi=8.
1239        let (lo, hi) = c.apply(6.0, 12.0);
1240        assert!((hi - 8.0).abs() < 1e-10, "hi={hi}");
1241        assert!((lo - 2.0).abs() < 1e-10, "lo={lo}");
1242    }
1243
1244    #[test]
1245    fn axis_constraints_view_wider_than_window_snaps_to_window() {
1246        let c = AxisConstraints {
1247            min_pos: Some(0.0),
1248            max_pos: Some(10.0),
1249            ..Default::default()
1250        };
1251        // View [-5, 15] (span 20) is wider than the [0, 10] window with both
1252        // ends out; silx normalize snaps it to exactly the window (span 10).
1253        let (lo, hi) = c.apply(-5.0, 15.0);
1254        assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
1255        assert!((hi - 10.0).abs() < 1e-10, "hi={hi}");
1256    }
1257
1258    #[test]
1259    fn axis_constraints_max_range_capped_to_window_keeps_offcenter_in_bounds() {
1260        let c = AxisConstraints {
1261            min_pos: Some(0.0),
1262            max_pos: Some(10.0),
1263            max_range: Some(100.0),
1264            ..Default::default()
1265        };
1266        // max_range=100 is capped to the 10-wide window (silx update sanity),
1267        // so the span-20 off-center view [2, 22] shrinks to the window and
1268        // shifts fully in bounds instead of overshooting the far edge.
1269        let (lo, hi) = c.apply(2.0, 22.0);
1270        assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
1271        assert!((hi - 10.0).abs() < 1e-10, "hi={hi}");
1272    }
1273
1274    #[test]
1275    fn axis_constraints_one_end_out_shifts_within_both_bounds() {
1276        let c = AxisConstraints {
1277            min_pos: Some(0.0),
1278            max_pos: Some(10.0),
1279            ..Default::default()
1280        };
1281        // View [-2, 3] (span 5) fits the window; only the low end is out, so
1282        // the window shifts right to [0, 5] without touching the span.
1283        let (lo, hi) = c.apply(-2.0, 3.0);
1284        assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
1285        assert!((hi - 5.0).abs() < 1e-10, "hi={hi}");
1286    }
1287
1288    #[test]
1289    fn axis_constraints_degenerate_span_is_passthrough() {
1290        let c = AxisConstraints {
1291            min_range: Some(1.0),
1292            ..Default::default()
1293        };
1294        // Already-invalid spans return unchanged (guard against further corruption).
1295        assert_eq!(c.apply(5.0, 3.0), (5.0, 3.0));
1296    }
1297
1298    #[test]
1299    fn transform_y2_is_none_without_y2_axis() {
1300        let plot = Plot::new(0);
1301        assert!(plot.transform_y2(area()).is_none());
1302    }
1303
1304    #[test]
1305    fn limits_history_starts_empty() {
1306        let plot = Plot::new(0);
1307        assert_eq!(plot.limits_history_len(), 0);
1308    }
1309
1310    #[test]
1311    fn limits_history_push_then_zoom_back_restores_previous() {
1312        let mut plot = Plot::new(0);
1313        plot.limits = (0.0, 1.0, 0.0, 1.0);
1314        plot.y2 = Some((0.0, 2.0));
1315        // Push the initial view, then change the view (as a zoom would).
1316        plot.push_limits();
1317        assert_eq!(plot.limits_history_len(), 1);
1318        plot.limits = (0.25, 0.75, 0.25, 0.75);
1319        plot.y2 = Some((0.5, 1.5));
1320        // zoom_back restores the pushed view (limits AND y2) and pops the entry.
1321        assert!(plot.zoom_back());
1322        assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
1323        assert_eq!(plot.y2, Some((0.0, 2.0)));
1324        assert_eq!(plot.limits_history_len(), 0);
1325    }
1326
1327    #[test]
1328    fn zoom_back_on_empty_history_returns_false_and_keeps_view() {
1329        // Boundary: empty stack -> zoom_back is a no-op returning false (silx
1330        // pop() returns False on empty history).
1331        let mut plot = Plot::new(0);
1332        plot.limits = (1.0, 2.0, 3.0, 4.0);
1333        assert!(!plot.zoom_back());
1334        assert_eq!(plot.limits, (1.0, 2.0, 3.0, 4.0));
1335    }
1336
1337    #[test]
1338    fn limits_history_is_lifo_and_unbounded() {
1339        // silx LimitsHistory is a plain list (no depth cap); pushes stack LIFO.
1340        let mut plot = Plot::new(0);
1341        for i in 0..1000 {
1342            plot.limits = (i as f64, i as f64 + 1.0, 0.0, 1.0);
1343            plot.push_limits();
1344        }
1345        assert_eq!(plot.limits_history_len(), 1000);
1346        // Pop order is last-in-first-out.
1347        assert!(plot.zoom_back());
1348        assert_eq!(plot.limits, (999.0, 1000.0, 0.0, 1.0));
1349        assert!(plot.zoom_back());
1350        assert_eq!(plot.limits, (998.0, 999.0, 0.0, 1.0));
1351        assert_eq!(plot.limits_history_len(), 998);
1352    }
1353
1354    #[test]
1355    fn clear_limits_history_empties_the_stack() {
1356        let mut plot = Plot::new(0);
1357        plot.push_limits();
1358        plot.push_limits();
1359        assert_eq!(plot.limits_history_len(), 2);
1360        plot.clear_limits_history();
1361        assert_eq!(plot.limits_history_len(), 0);
1362        assert!(!plot.zoom_back());
1363    }
1364
1365    #[test]
1366    fn transform_y2_shares_left_x_and_maps_its_own_y() {
1367        let mut plot = Plot::new(0);
1368        plot.limits = (0.0, 10.0, 0.0, 100.0);
1369        plot.y2 = Some((-1.0, 1.0));
1370        let left = plot.transform(area());
1371        let right = plot.transform_y2(area()).expect("y2 transform");
1372
1373        // X axis is shared exactly, so curves on both axes align in X.
1374        assert_eq!(left.x, right.x);
1375        // The right axis maps its own y2 range: y2_min at the bottom edge, y2_max
1376        // at the top edge of the same area.
1377        let bottom = right.data_to_pixel(0.0, -1.0).y;
1378        let top = right.data_to_pixel(0.0, 1.0).y;
1379        assert!((bottom - area().bottom()).abs() <= 1e-3, "{bottom}");
1380        assert!((top - area().top()).abs() <= 1e-3, "{top}");
1381    }
1382
1383    #[test]
1384    fn autoscale_defaults_on_for_all_axes() {
1385        let plot = Plot::new(0);
1386        assert!(plot.x_autoscale());
1387        assert!(plot.y_autoscale());
1388        assert!(plot.y2_autoscale());
1389    }
1390
1391    #[test]
1392    fn reset_zoom_refits_only_autoscale_on_axes() {
1393        // X autoscale off: X range preserved; Y autoscale on: Y refit to data.
1394        let mut plot = Plot::new(0);
1395        plot.limits = (0.0, 1.0, 0.0, 1.0);
1396        plot.set_x_autoscale(false);
1397        plot.set_y_autoscale(true);
1398        plot.reset_zoom_to_data_range(DataRange {
1399            x: Some((10.0, 20.0)),
1400            y: Some((-5.0, 5.0)),
1401            y2: None,
1402        });
1403        // X preserved (autoscale off), Y refit (autoscale on).
1404        assert_eq!(plot.limits, (0.0, 1.0, -5.0, 5.0));
1405    }
1406
1407    #[test]
1408    fn reset_zoom_refits_x_when_only_x_autoscale_on() {
1409        let mut plot = Plot::new(0);
1410        plot.limits = (0.0, 1.0, 0.0, 1.0);
1411        plot.set_x_autoscale(true);
1412        plot.set_y_autoscale(false);
1413        plot.reset_zoom_to_data_range(DataRange {
1414            x: Some((10.0, 20.0)),
1415            y: Some((-5.0, 5.0)),
1416            y2: None,
1417        });
1418        // X refit, Y preserved.
1419        assert_eq!(plot.limits, (10.0, 20.0, 0.0, 1.0));
1420    }
1421
1422    #[test]
1423    fn reset_zoom_with_all_autoscale_off_is_noop() {
1424        let mut plot = Plot::new(0);
1425        plot.limits = (0.0, 1.0, 0.0, 1.0);
1426        plot.y2 = Some((0.0, 2.0));
1427        plot.set_x_autoscale(false);
1428        plot.set_y_autoscale(false);
1429        plot.set_y2_autoscale(false);
1430        plot.reset_zoom_to_data_range(DataRange {
1431            x: Some((10.0, 20.0)),
1432            y: Some((-5.0, 5.0)),
1433            y2: Some((-1.0, 1.0)),
1434        });
1435        // Nothing changes: every axis pinned.
1436        assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
1437        assert_eq!(plot.y2, Some((0.0, 2.0)));
1438    }
1439
1440    #[test]
1441    fn reset_zoom_autoscale_on_axis_with_no_data_is_preserved() {
1442        // Boundary: autoscale on but no data bounds -> range left untouched.
1443        let mut plot = Plot::new(0);
1444        plot.limits = (3.0, 7.0, 2.0, 8.0);
1445        plot.reset_zoom_to_data_range(DataRange {
1446            x: None,
1447            y: Some((-1.0, 1.0)),
1448            y2: None,
1449        });
1450        // X has no data -> preserved; Y refit.
1451        assert_eq!(plot.limits, (3.0, 7.0, -1.0, 1.0));
1452    }
1453
1454    #[test]
1455    fn reset_zoom_log_axis_forces_autoscale_when_lower_limit_nonpositive() {
1456        // X is log with a <= 0 lower limit and autoscale OFF; silx forces it on.
1457        let mut plot = Plot::new(0);
1458        plot.x_scale = Scale::Log10;
1459        plot.limits = (-1.0, 100.0, 0.0, 1.0);
1460        plot.set_x_autoscale(false);
1461        plot.set_y_autoscale(false);
1462        plot.reset_zoom_to_data_range(DataRange {
1463            x: Some((1.0, 1000.0)),
1464            y: Some((-5.0, 5.0)),
1465            y2: None,
1466        });
1467        // X refit despite autoscale off (forced by log + nonpositive lower).
1468        assert_eq!(plot.limits.0, 1.0);
1469        assert_eq!(plot.limits.1, 1000.0);
1470        // Y stays pinned.
1471        assert_eq!((plot.limits.2, plot.limits.3), (0.0, 1.0));
1472    }
1473
1474    #[test]
1475    fn grid_color_defaults_none_and_follows_foreground() {
1476        let plot = Plot::new(0);
1477        assert_eq!(plot.grid_color(), None);
1478        let fg = Color32::from_rgb(200, 200, 200);
1479        // No explicit grid color -> effective is the foreground.
1480        assert_eq!(plot.effective_grid_color(fg), fg);
1481    }
1482
1483    #[test]
1484    fn grid_color_explicit_overrides_foreground() {
1485        let mut plot = Plot::new(0);
1486        let grid = Color32::from_rgb(64, 64, 64);
1487        let fg = Color32::from_rgb(200, 200, 200);
1488        plot.set_grid_color(Some(grid));
1489        assert_eq!(plot.grid_color(), Some(grid));
1490        // Explicit grid color wins over foreground.
1491        assert_eq!(plot.effective_grid_color(fg), grid);
1492    }
1493
1494    #[test]
1495    fn set_grid_color_change_marks_full_dirty() {
1496        let mut plot = Plot::new(0);
1497        // Setting to the same value (None) is a no-op for dirty.
1498        plot.set_grid_color(None);
1499        assert_eq!(plot.dirty(), DirtyState::Clean);
1500        // A real change marks dirty (silx _foregroundColorsUpdated).
1501        plot.set_grid_color(Some(Color32::RED));
1502        assert_eq!(plot.dirty(), DirtyState::Full);
1503    }
1504
1505    #[test]
1506    fn axis_label_active_curve_wins_over_default() {
1507        // silx _setActiveItem: the active curve's label overrides the graph
1508        // default (_setCurrentLabel displays the active label when non-empty).
1509        assert_eq!(
1510            resolved_axis_label(Some("Energy"), Some("curve X")),
1511            "curve X"
1512        );
1513    }
1514
1515    #[test]
1516    fn axis_label_falls_back_to_default_when_no_active() {
1517        // No active curve label -> the axis' own default label.
1518        assert_eq!(resolved_axis_label(Some("Energy"), None), "Energy");
1519        // Active label only -> active label.
1520        assert_eq!(resolved_axis_label(None, Some("curve X")), "curve X");
1521    }
1522
1523    #[test]
1524    fn axis_label_empty_when_neither_set() {
1525        assert_eq!(resolved_axis_label(None, None), "");
1526    }
1527
1528    #[test]
1529    fn axis_label_empty_active_falls_back_to_default() {
1530        // silx _setCurrentLabel treats "" as no label -> falls back to default.
1531        assert_eq!(resolved_axis_label(Some("Energy"), Some("")), "Energy");
1532        // Active label wins over a default even when the default is set.
1533        assert_eq!(resolved_axis_label(Some("Energy"), Some("Time")), "Time");
1534        // Both empty / unset -> empty.
1535        assert_eq!(resolved_axis_label(Some(""), Some("")), "");
1536        assert_eq!(resolved_axis_label(None, Some("")), "");
1537    }
1538
1539    #[test]
1540    fn plot_axis_label_active_overrides_default() {
1541        let mut plot = Plot::new(0);
1542        plot.x_label = Some("X axis".to_string());
1543        // Active curve label overrides the explicit default (silx semantics).
1544        assert_eq!(plot.x_axis_label(Some("curve")), "curve");
1545        // Default shows when there is no active label.
1546        assert_eq!(plot.x_axis_label(None), "X axis");
1547        // No default on y -> active curve label.
1548        assert_eq!(plot.y_axis_label(Some("intensity")), "intensity");
1549        // No default, no active -> empty.
1550        assert_eq!(plot.y2_axis_label(None), "");
1551    }
1552
1553    #[test]
1554    fn displayed_labels_resolve_active_override_against_default() {
1555        let mut plot = Plot::new(0);
1556        // Defaults set, no active override -> defaults are displayed.
1557        plot.x_label = Some("Energy".to_string());
1558        plot.y_label = Some("Counts".to_string());
1559        assert_eq!(plot.displayed_x_label().as_deref(), Some("Energy"));
1560        assert_eq!(plot.displayed_y_label().as_deref(), Some("Counts"));
1561        // y2 has neither default nor override -> nothing drawn.
1562        assert_eq!(plot.displayed_y2_label(), None);
1563
1564        // Active overrides win over the defaults (silx _setActiveItem).
1565        plot.active_x_label = Some("Time".to_string());
1566        plot.active_y_label = Some("Intensity".to_string());
1567        assert_eq!(plot.displayed_x_label().as_deref(), Some("Time"));
1568        assert_eq!(plot.displayed_y_label().as_deref(), Some("Intensity"));
1569
1570        // An empty override falls back to the default; an active y2 override with
1571        // no y2 default still drives the y2 label.
1572        plot.active_x_label = Some(String::new());
1573        plot.active_y2_label = Some("Right".to_string());
1574        assert_eq!(plot.displayed_x_label().as_deref(), Some("Energy"));
1575        assert_eq!(plot.displayed_y2_label().as_deref(), Some("Right"));
1576    }
1577
1578    #[test]
1579    fn dirty_defaults_clean_and_autoreplot_on_and_axes_displayed() {
1580        let plot = Plot::new(0);
1581        assert_eq!(plot.dirty(), DirtyState::Clean);
1582        assert!(plot.autoreplot());
1583        assert!(plot.axes_displayed());
1584    }
1585
1586    #[test]
1587    fn dirty_clean_overlay_only_becomes_overlay() {
1588        let mut plot = Plot::new(0);
1589        plot.set_dirty(true);
1590        assert_eq!(plot.dirty(), DirtyState::Overlay);
1591    }
1592
1593    #[test]
1594    fn dirty_clean_full_becomes_full() {
1595        let mut plot = Plot::new(0);
1596        plot.set_dirty(false);
1597        assert_eq!(plot.dirty(), DirtyState::Full);
1598    }
1599
1600    #[test]
1601    fn dirty_overlay_then_overlay_only_escalates_to_full() {
1602        // silx: once dirty, even an overlay-only mark sets _dirty = True.
1603        let mut plot = Plot::new(0);
1604        plot.set_dirty(true);
1605        assert_eq!(plot.dirty(), DirtyState::Overlay);
1606        plot.set_dirty(true);
1607        assert_eq!(plot.dirty(), DirtyState::Full);
1608    }
1609
1610    #[test]
1611    fn dirty_full_then_overlay_only_stays_full() {
1612        let mut plot = Plot::new(0);
1613        plot.set_dirty(false);
1614        plot.set_dirty(true);
1615        assert_eq!(plot.dirty(), DirtyState::Full);
1616    }
1617
1618    #[test]
1619    fn replot_clears_dirty_to_clean() {
1620        let mut plot = Plot::new(0);
1621        plot.set_dirty(false);
1622        assert_eq!(plot.dirty(), DirtyState::Full);
1623        plot.replot();
1624        assert_eq!(plot.dirty(), DirtyState::Clean);
1625    }
1626
1627    #[test]
1628    fn set_axes_displayed_change_marks_full_dirty() {
1629        let mut plot = Plot::new(0);
1630        // No change -> no dirty.
1631        plot.set_axes_displayed(true);
1632        assert_eq!(plot.dirty(), DirtyState::Clean);
1633        // Change -> full dirty.
1634        plot.set_axes_displayed(false);
1635        assert!(!plot.axes_displayed());
1636        assert_eq!(plot.dirty(), DirtyState::Full);
1637    }
1638
1639    #[test]
1640    fn pan_with_arrow_keys_defaults_true_and_set_does_not_dirty() {
1641        let mut plot = Plot::new(0);
1642        // silx PlotWidget._panWithArrowKeys = True.
1643        assert!(plot.pan_with_arrow_keys(), "default enabled");
1644
1645        // setPanWithArrowKeys is a plain flag set: it never marks the plot dirty
1646        // (it only changes how a future key press is handled, not the frame).
1647        plot.set_pan_with_arrow_keys(false);
1648        assert!(!plot.pan_with_arrow_keys());
1649        assert_eq!(plot.dirty(), DirtyState::Clean, "toggling must not dirty");
1650
1651        plot.set_pan_with_arrow_keys(true);
1652        assert!(plot.pan_with_arrow_keys());
1653        assert_eq!(plot.dirty(), DirtyState::Clean);
1654    }
1655
1656    #[test]
1657    fn zoom_enabled_axes_default_true_and_set_does_not_dirty() {
1658        let mut plot = Plot::new(0);
1659        // silx ZoomEnabledAxesMenu: all axes checked by default.
1660        assert!(plot.zoom_x_enabled());
1661        assert!(plot.zoom_y_enabled());
1662
1663        // setZoomEnabledAxes is a plain flag set (it only changes how a future
1664        // box zoom applies), so it must not mark the plot dirty.
1665        plot.set_zoom_enabled_axes(true, false);
1666        assert!(plot.zoom_x_enabled());
1667        assert!(!plot.zoom_y_enabled());
1668        assert_eq!(plot.dirty(), DirtyState::Clean);
1669
1670        plot.set_zoom_enabled_axes(false, true);
1671        assert!(!plot.zoom_x_enabled());
1672        assert!(plot.zoom_y_enabled());
1673        assert_eq!(plot.dirty(), DirtyState::Clean);
1674    }
1675
1676    #[test]
1677    fn lines_start_empty_and_append() {
1678        let mut plot = Plot::new(0);
1679        assert!(plot.lines().is_empty());
1680        plot.add_line(Line::new(f64::INFINITY, 3.0));
1681        plot.add_line(Line::new(0.0, 1.0));
1682        assert_eq!(plot.lines().len(), 2);
1683        // lines_mut allows in-place edits.
1684        plot.lines_mut()[1].intercept = 2.0;
1685        assert_eq!(plot.lines()[1].intercept, 2.0);
1686        assert!(!plot.lines()[0].slope.is_finite());
1687    }
1688
1689    #[test]
1690    fn tick_mode_defaults_numeric_and_sets_x_only() {
1691        let mut plot = Plot::new(0);
1692        assert_eq!(plot.x_tick_mode(), TickMode::Numeric);
1693        plot.set_x_tick_mode(TickMode::TimeSeries);
1694        assert_eq!(plot.x_tick_mode(), TickMode::TimeSeries);
1695        plot.set_x_tick_mode(TickMode::Numeric);
1696        assert_eq!(plot.x_tick_mode(), TickMode::Numeric);
1697    }
1698
1699    #[test]
1700    fn time_zone_defaults_utc_and_round_trips() {
1701        let mut plot = Plot::new(0);
1702        assert_eq!(plot.x_time_zone(), TimeZone::Utc);
1703        let jst = TimeZone::FixedOffset {
1704            seconds_east: 32400,
1705        };
1706        plot.set_x_time_zone(jst);
1707        assert_eq!(plot.x_time_zone(), jst);
1708        plot.set_x_time_zone(TimeZone::Utc);
1709        assert_eq!(plot.x_time_zone(), TimeZone::Utc);
1710    }
1711
1712    #[test]
1713    fn set_autoreplot_toggles() {
1714        let mut plot = Plot::new(0);
1715        plot.set_autoreplot(false);
1716        assert!(!plot.autoreplot());
1717        plot.set_autoreplot(true);
1718        assert!(plot.autoreplot());
1719    }
1720
1721    #[test]
1722    fn show_colorbar_defaults_true() {
1723        // Backward compat: every plot draws its colorbar when it has a colormap
1724        // unless a caller (e.g. ImageView's internal image plot) opts out.
1725        let mut plot = Plot::new(0);
1726        assert!(plot.show_colorbar);
1727        plot.show_colorbar = false;
1728        assert!(!plot.show_colorbar);
1729    }
1730
1731    #[test]
1732    fn data_margins_default_zero_and_noop() {
1733        let mut plot = Plot::new(0);
1734        assert_eq!(plot.data_margins(), DataMargins::default());
1735        plot.set_data_range(DataRange {
1736            x: Some((0.0, 10.0)),
1737            y: Some((0.0, 10.0)),
1738            y2: None,
1739        });
1740        plot.reset_zoom();
1741        // No margins -> exact data bounds.
1742        assert_eq!(plot.limits, (0.0, 10.0, 0.0, 10.0));
1743    }
1744
1745    #[test]
1746    fn data_margins_linear_left_expands_xmin_by_ratio_of_range() {
1747        // 0.1 left margin on a [0, 10] range expands xmin by 10% of 10 = 1.
1748        let mut plot = Plot::new(0);
1749        plot.set_data_margins(DataMargins {
1750            x_min: 0.1,
1751            ..Default::default()
1752        });
1753        plot.set_data_range(DataRange {
1754            x: Some((0.0, 10.0)),
1755            y: Some((0.0, 10.0)),
1756            y2: None,
1757        });
1758        plot.reset_zoom();
1759        assert!(
1760            (plot.limits.0 - (-1.0)).abs() < 1e-9,
1761            "xmin={}",
1762            plot.limits.0
1763        );
1764        // xmax untouched (no right margin), y untouched (no y margins).
1765        assert_eq!(plot.limits.1, 10.0);
1766        assert_eq!((plot.limits.2, plot.limits.3), (0.0, 10.0));
1767    }
1768
1769    #[test]
1770    fn data_margins_log_expands_in_log_space() {
1771        // Log X over [1, 100] (2 decades). A 0.1 left margin expands xmin by 10%
1772        // of the 2-decade range in log space: 10^(0 - 0.1*2) = 10^-0.2.
1773        let mut plot = Plot::new(0);
1774        plot.x_scale = Scale::Log10;
1775        plot.set_data_margins(DataMargins {
1776            x_min: 0.1,
1777            ..Default::default()
1778        });
1779        plot.set_data_range(DataRange {
1780            x: Some((1.0, 100.0)),
1781            y: Some((1.0, 100.0)),
1782            y2: None,
1783        });
1784        plot.reset_zoom();
1785        let expected = 10f64.powf(-0.2);
1786        assert!(
1787            (plot.limits.0 - expected).abs() < 1e-9,
1788            "xmin={} expected={expected}",
1789            plot.limits.0
1790        );
1791        assert_eq!(plot.limits.1, 100.0);
1792    }
1793
1794    #[test]
1795    fn data_margins_log_skips_nonpositive_bound() {
1796        // Boundary: log axis with a non-positive lower bound -> margin skipped
1797        // (silx "Do not apply margins if limits < 0"), but the bound itself is
1798        // still the refit value.
1799        let (lo, hi) = DataMargins::expand_axis(0.0, 100.0, 0.1, 0.1, true);
1800        assert_eq!((lo, hi), (0.0, 100.0));
1801    }
1802
1803    #[test]
1804    fn data_margins_only_applied_to_refit_axes() {
1805        // X autoscale off -> X keeps its range and gets NO margin even though a
1806        // left margin is set; Y refit and margined.
1807        let mut plot = Plot::new(0);
1808        plot.limits = (5.0, 6.0, 0.0, 0.0);
1809        plot.set_x_autoscale(false);
1810        plot.set_data_margins(DataMargins {
1811            x_min: 0.5,
1812            y_min: 0.1,
1813            ..Default::default()
1814        });
1815        plot.set_data_range(DataRange {
1816            x: Some((0.0, 10.0)),
1817            y: Some((0.0, 10.0)),
1818            y2: None,
1819        });
1820        plot.reset_zoom();
1821        // X pinned, no margin applied.
1822        assert_eq!((plot.limits.0, plot.limits.1), (5.0, 6.0));
1823        // Y refit with 0.1 bottom margin: ymin = 0 - 0.1*10 = -1.
1824        assert!(
1825            (plot.limits.2 - (-1.0)).abs() < 1e-9,
1826            "ymin={}",
1827            plot.limits.2
1828        );
1829    }
1830
1831    #[test]
1832    fn data_range_is_empty_until_set() {
1833        let plot = Plot::new(0);
1834        let r = plot.data_range();
1835        assert_eq!(r, DataRange::default());
1836        assert!(r.x.is_none() && r.y.is_none() && r.y2.is_none());
1837    }
1838
1839    #[test]
1840    fn reset_zoom_uses_cached_data_range() {
1841        let mut plot = Plot::new(0);
1842        plot.limits = (0.0, 1.0, 0.0, 1.0);
1843        plot.set_data_range(DataRange {
1844            x: Some((2.0, 4.0)),
1845            y: Some((6.0, 8.0)),
1846            y2: None,
1847        });
1848        plot.reset_zoom();
1849        assert_eq!(plot.limits, (2.0, 4.0, 6.0, 8.0));
1850    }
1851
1852    #[test]
1853    fn reset_zoom_refits_y2_independently() {
1854        let mut plot = Plot::new(0);
1855        plot.limits = (0.0, 1.0, 0.0, 1.0);
1856        plot.y2 = Some((0.0, 1.0));
1857        plot.set_x_autoscale(false);
1858        plot.set_y_autoscale(false);
1859        plot.set_y2_autoscale(true);
1860        plot.reset_zoom_to_data_range(DataRange {
1861            x: Some((10.0, 20.0)),
1862            y: Some((-5.0, 5.0)),
1863            y2: Some((100.0, 200.0)),
1864        });
1865        // Only y2 refit.
1866        assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
1867        assert_eq!(plot.y2, Some((100.0, 200.0)));
1868    }
1869
1870    #[test]
1871    fn transform_y2_shares_aspect_expanded_x() {
1872        // With the aspect lock on, the left transform's X is expanded; the y2
1873        // transform must inherit that same expanded X (not the raw limits).
1874        let mut plot = Plot::new(0);
1875        plot.limits = (0.0, 10.0, 0.0, 10.0);
1876        plot.keep_aspect = true;
1877        plot.y2 = Some((0.0, 5.0));
1878        let left = plot.transform(area());
1879        let right = plot.transform_y2(area()).expect("y2 transform");
1880        assert_eq!(left.x, right.x);
1881        // Sanity: the lock actually widened X beyond the raw [0, 10].
1882        assert!(left.x.min < 0.0 && left.x.max > 10.0, "{:?}", left.x);
1883    }
1884
1885    // --- extra (stacked) Y axes ---
1886
1887    #[test]
1888    fn add_extra_axis_returns_sequential_indices_and_keeps_side() {
1889        let mut plot = Plot::new(0);
1890        assert_eq!(plot.add_extra_axis(AxisSide::Right), 0);
1891        assert_eq!(plot.add_extra_axis(AxisSide::Left), 1);
1892        assert_eq!(plot.extra_axes().len(), 2);
1893        assert_eq!(plot.extra_axis(0).unwrap().side, AxisSide::Right);
1894        assert_eq!(plot.extra_axis(1).unwrap().side, AxisSide::Left);
1895        // A fresh extra axis has no range, is linear, and autoscales.
1896        let ax = plot.extra_axis(0).unwrap();
1897        assert_eq!(ax.range, None);
1898        assert_eq!(ax.scale, Scale::Linear);
1899        assert!(ax.autoscale);
1900        assert!(plot.extra_axis(2).is_none());
1901    }
1902
1903    #[test]
1904    fn transform_extra_shares_left_x_and_maps_its_own_y() {
1905        let mut plot = Plot::new(0);
1906        plot.limits = (0.0, 10.0, 0.0, 1.0);
1907        let i = plot.add_extra_axis(AxisSide::Right);
1908        // No range yet -> no transform (caller falls back to the left axis).
1909        assert!(plot.transform_extra(i, area()).is_none());
1910        plot.extra_axis_mut(i).unwrap().range = Some((-1.0, 1.0));
1911        let left = plot.transform(area());
1912        let extra = plot.transform_extra(i, area()).expect("extra transform");
1913        // Shares the left X axis exactly; maps its own Y range with the low value
1914        // at the bottom edge and the high value at the top edge.
1915        assert_eq!(left.x, extra.x);
1916        let bottom = extra.data_to_pixel(0.0, -1.0);
1917        let top = extra.data_to_pixel(0.0, 1.0);
1918        assert!((bottom.y - area().bottom()).abs() < 1e-3, "{bottom:?}");
1919        assert!((top.y - area().top()).abs() < 1e-3, "{top:?}");
1920        // An unknown index yields no transform.
1921        assert!(plot.transform_extra(99, area()).is_none());
1922    }
1923
1924    #[test]
1925    fn transform_extra_honors_its_own_log_scale() {
1926        let mut plot = Plot::new(0);
1927        plot.limits = (0.0, 10.0, 0.0, 1.0);
1928        let i = plot.add_extra_axis(AxisSide::Right);
1929        {
1930            let ax = plot.extra_axis_mut(i).unwrap();
1931            ax.range = Some((1.0, 1000.0));
1932            ax.scale = Scale::Log10;
1933        }
1934        let t = plot.transform_extra(i, area()).expect("extra transform");
1935        // Three decades: 10 lands one-third up from the bottom edge.
1936        let mid = t.data_to_pixel(0.0, 10.0).y;
1937        let expected = area().bottom() + (area().top() - area().bottom()) / 3.0;
1938        assert!((mid - expected).abs() < 1e-2, "{mid} vs {expected}");
1939    }
1940
1941    #[test]
1942    fn reset_extra_axes_to_refits_only_autoscale_on_axes() {
1943        let mut plot = Plot::new(0);
1944        let a = plot.add_extra_axis(AxisSide::Right); // autoscale on (default)
1945        let b = plot.add_extra_axis(AxisSide::Left);
1946        plot.extra_axis_mut(a).unwrap().range = Some((0.0, 1.0));
1947        plot.extra_axis_mut(b).unwrap().range = Some((0.0, 1.0));
1948        plot.extra_axis_mut(b).unwrap().autoscale = false;
1949        plot.reset_extra_axes_to(&[Some((100.0, 200.0)), Some((-9.0, 9.0))]);
1950        // a refit to its data; b pinned (autoscale off).
1951        assert_eq!(plot.extra_axis(a).unwrap().range, Some((100.0, 200.0)));
1952        assert_eq!(plot.extra_axis(b).unwrap().range, Some((0.0, 1.0)));
1953    }
1954
1955    #[test]
1956    fn reset_extra_axes_to_leaves_axis_with_no_data_unchanged() {
1957        let mut plot = Plot::new(0);
1958        let a = plot.add_extra_axis(AxisSide::Right);
1959        plot.extra_axis_mut(a).unwrap().range = Some((0.0, 1.0));
1960        // No data for this axis (empty slice / None entry) -> unchanged.
1961        plot.reset_extra_axes_to(&[None]);
1962        assert_eq!(plot.extra_axis(a).unwrap().range, Some((0.0, 1.0)));
1963        plot.reset_extra_axes_to(&[]);
1964        assert_eq!(plot.extra_axis(a).unwrap().range, Some((0.0, 1.0)));
1965    }
1966
1967    #[test]
1968    fn reset_extra_axes_to_force_refits_nonpositive_log_axis() {
1969        let mut plot = Plot::new(0);
1970        let a = plot.add_extra_axis(AxisSide::Right);
1971        {
1972            let ax = plot.extra_axis_mut(a).unwrap();
1973            ax.scale = Scale::Log10;
1974            ax.autoscale = false; // even pinned, a log axis with lo<=0 refits
1975            ax.range = Some((-5.0, 5.0));
1976        }
1977        plot.reset_extra_axes_to(&[Some((1.0, 1000.0))]);
1978        assert_eq!(plot.extra_axis(a).unwrap().range, Some((1.0, 1000.0)));
1979    }
1980
1981    // --- current-ROI selection invariant (Plot is the single owner) ---
1982
1983    fn point_roi(i: usize) -> ManagedRoi {
1984        ManagedRoi::new(crate::core::roi::Roi::Point {
1985            x: i as f64,
1986            y: 0.0,
1987        })
1988    }
1989
1990    #[test]
1991    fn roi_color_defaults_to_silx_red() {
1992        assert_eq!(Plot::new(0).roi_color, Color32::RED);
1993    }
1994
1995    #[test]
1996    fn set_current_roi_highlights_exactly_one() {
1997        let mut plot = Plot::new(0);
1998        plot.rois = (0..3).map(point_roi).collect();
1999
2000        plot.set_current_roi(Some(1));
2001        assert_eq!(plot.current_roi(), Some(1));
2002        assert!(!plot.rois[0].selected);
2003        assert!(plot.rois[1].selected);
2004        assert!(!plot.rois[2].selected);
2005
2006        // Switching the current ROI moves the single highlight.
2007        plot.set_current_roi(Some(2));
2008        assert!(!plot.rois[1].selected);
2009        assert!(plot.rois[2].selected);
2010
2011        // Clearing removes every highlight.
2012        plot.set_current_roi(None);
2013        assert_eq!(plot.current_roi(), None);
2014        assert!(plot.rois.iter().all(|r| !r.selected));
2015    }
2016
2017    #[test]
2018    fn set_current_roi_out_of_range_clears_selection() {
2019        let mut plot = Plot::new(0);
2020        plot.rois = vec![point_roi(0)];
2021        plot.set_current_roi(Some(1));
2022        assert_eq!(plot.current_roi(), None);
2023        assert!(!plot.rois[0].selected);
2024    }
2025
2026    #[test]
2027    fn remove_roi_adjusts_current_index() {
2028        let mut plot = Plot::new(0);
2029        plot.rois = (0..3).map(point_roi).collect();
2030
2031        // Current after the removed index shifts down by one.
2032        plot.set_current_roi(Some(2));
2033        plot.remove_roi(0);
2034        assert_eq!(plot.current_roi(), Some(1));
2035        assert!(plot.rois[1].selected);
2036
2037        // Removing the current ROI clears the selection.
2038        plot.set_current_roi(Some(1));
2039        plot.remove_roi(1);
2040        assert_eq!(plot.current_roi(), None);
2041        assert!(plot.rois.iter().all(|r| !r.selected));
2042
2043        // Current before the removed index is unaffected.
2044        plot.rois = (0..3).map(point_roi).collect();
2045        plot.set_current_roi(Some(0));
2046        plot.remove_roi(2);
2047        assert_eq!(plot.current_roi(), Some(0));
2048        assert!(plot.rois[0].selected);
2049    }
2050
2051    #[test]
2052    fn clear_rois_resets_current() {
2053        let mut plot = Plot::new(0);
2054        plot.rois = (0..3).map(point_roi).collect();
2055        plot.set_current_roi(Some(1));
2056        plot.clear_rois();
2057        assert_eq!(plot.current_roi(), None);
2058        assert!(plot.rois.is_empty());
2059    }
2060
2061    #[test]
2062    fn needs_gutter_defaults_false_without_label_or_reserve() {
2063        let plot = Plot::new(0);
2064        assert!(!plot.needs_title_gutter());
2065        assert!(!plot.needs_x_label_gutter());
2066        assert!(!plot.needs_y_label_gutter());
2067    }
2068
2069    #[test]
2070    fn needs_gutter_true_when_label_present() {
2071        let mut plot = Plot::new(0);
2072        plot.title = Some("T".into());
2073        plot.x_label = Some("X".into());
2074        plot.y_label = Some("Y".into());
2075        assert!(plot.needs_title_gutter());
2076        assert!(plot.needs_x_label_gutter());
2077        assert!(plot.needs_y_label_gutter());
2078    }
2079
2080    #[test]
2081    fn needs_gutter_true_when_reserve_flag_set_without_label() {
2082        // The ImageView-profile path: labels cleared (no text drawn), gutter
2083        // still reserved so the data area aligns with the reference plot.
2084        let mut plot = Plot::new(0);
2085        assert!(plot.title.is_none() && plot.x_label.is_none() && plot.y_label.is_none());
2086        plot.reserve_title_gutter = true;
2087        plot.reserve_x_label_gutter = true;
2088        plot.reserve_y_label_gutter = true;
2089        assert!(plot.needs_title_gutter());
2090        assert!(plot.needs_x_label_gutter());
2091        assert!(plot.needs_y_label_gutter());
2092        // Reservation must not synthesise label text.
2093        assert!(plot.displayed_x_label().is_none());
2094        assert!(plot.displayed_y_label().is_none());
2095    }
2096}