siplot 0.2.0

silx-style scientific plotting for egui, rendered with wgpu
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
# siplot → silx parity roadmap

Generated from an 11-agent parity sweep of `silx.gui.plot` against the
siplot implementation. Scope (per project decision): **silx.gui.plot +
adjacent silx.gui** (colors, data-adjacent GUI widgets).

**Totals (as-of-sweep):** 397 features — 165 Done, 49 Partial, 183 Missing.
**Re-baselined 2026-06-04 (main @ `d04232a`):** ≈191 Done · 130 open (10 H / 48 M /
34 L) — see [Remaining work (re-baselined 2026-06-04)](#remaining-work-re-baselined-2026-06-04-main--d04232a).
**After Wave 13 (ROI styling, main @ `7495590`, not pushed):** ≈199 Done · 122 open (7 H / 44 M / 33 L) — 8 ROI
rows closed (per-instance color/name/selection, `sigCurrentRoiChanged`, line style/width, manager default-color, fill).

**After Wave 14 (item-click + hover + marker-drag triad, main, not pushed):** ≈206 Done · 115 open (6 H / 42 M / 29 L)
— 7 rows closed: curveClicked (H), curveClicked/imageClicked (L), hover-with-metadata (M), ItemsInteraction picker/
state-machine (M), marker drag-start/end triad (L), markerMoving/markerMoved split (L). `ItemHovered` carries the full
silx `prepareHoverSignal` payload (`selectable` omitted as structurally constant); the marker drag now emits
`MarkerDragStarted`→`MarkerMoved`×N→`MarkerDragFinished` (silx `beginDrag`/`drag`/`endDrag`).

**After Wave 15 (PlotWidget interaction-events + panzoom + draw feedback, main, not pushed):** ≈212 Done · ~109 open
— 6 rows closed: `LimitsChanged` carries the new `{x,y,y2}` ranges (row 61), RoiCreate draw events surface as
`DrawingProgress`/`DrawingFinished` (row 57), axis-constraint adaptive normalization mirroring silx `ViewConstraints`
allow_scaling=True — max-range capped to the position window + a wider-than-window view snaps to it (row 62),
box-zoom and draw previews unified onto one selection-overlay renderer with silx Zoom's `fill="none"` dashed outline
(row 60), the polygon first-point close target box (row 66), and the pencil brush-footprint preview circle (row 65).
Rows 49 / 63 (overlay-only replot, StateMachine base) and their re-baseline duplicates resolved as **N/A** — functional
parity achieved differently (immediate-mode + persistent GPU buffers make redraw always-on and uploads event-driven;
imperative `apply_interaction` dispatch replaces the silx state-machine hierarchy). This closes the enumerated
interaction-events-panzoom open rows.

**Mask drawing tools + colormap autoscale (main, not pushed):** ≈216 Done · ~105 open — 3 H rows closed: on-plot
Rectangle (row 126), Ellipse (row 128), and Polygon (row 127) mask draws; plus the ColormapDialog Stddev3/Percentile
autoscale wired to raw pixels (row 133, ◐→✅). The pure fill primitives (`update_rectangle`/`update_ellipse`/
`update_polygon`) already existed; the gap was the on-plot draw interaction. Wired high-level like the pencil
(reusing `DrawState`/`feed_draw_state`/the extracted `paint_draw_preview`, no new interaction mode or `PlotEvent`):
`MaskTool::draw_mode` is the single source of truth for which tools are shape draws; `MaskToolsWidget::handle_shape_draw`
feeds the draw machine and, on finish, `fill_from_draw` converts data→cells (silx `int()`/`astype(int64)` truncation,
`_plotDrawEvent`) and masks the level; `ImageView::handle_mask_shape_draw` paints the rubber-band preview.

**ROIStatsWidget display (main, not pushed):** ≈217 Done · ~104 open — 1 H row closed: the per-ROI statistics table
(row 111). `RoiStatsWidget` renders one row per ROI (`ROI | N | min | max | mean | sum | integral`) over the active
item; `PlotWidget::feed_roi_stats`/`show_roi_stats_widget` reduce each ROI via the pre-existing
`image_roi_stats`/`curve_roi_stats` and follow the active item + live ROI list. Pure row-building (`roi_stats_rows`)
is headlessly tested (image/curve per-ROI, empty); the active-item feed + render stay GPU-unverified. The
`high_level_roi_stats` example now uses the widget instead of a hand-rolled bbox reduction.

**CurvesROIWidget integration (main, not pushed):** ≈218 Done · ~103 open — the last open H row closed: per-curve ROI
stats (row 117). `CurvesRoiWidget` shows one row per x-span ROI over the active curve
(`ROI | From | To | Raw Counts | Net Counts | Raw Area | Net Area`); `PlotWidget::feed_curves_roi_stats`/
`show_curves_roi_widget` reduce each ROI via the new pure `curve_roi_counts`, faithful to silx
`computeRawAndNetCounts`/`computeRawAndNetArea` (array-order selection — numpy does not sort; linear-endpoint
background for net counts; numpy-trapezoid for area; NaN-y summed as-is, unlike `curve_roi_stats`). Pure core
(`curve_roi_counts`, 7 tests) + row-building (`curve_roi_rows`, 2 tests) are headlessly tested; the active-curve feed
+ egui render stay GPU-unverified. New example `high_level_curves_roi`. No open H rows remain in the primary table.

**Histogram bin alignment (main, not pushed):** ≈219 Done · ~102 open — 1 M row closed (row 108, ◐→✅). `HistogramAlign`
{Left,Center,Right} + the pure `histogram_edges(positions, align)` mirror silx `items.histogram._computeEdges`
(`Histogram.setData(align=)`): Left treats positions as left edges (append `x[-1]+last_gap`), Right as right edges
(prepend `x[0]-first_gap`), Center right-aligns then shifts each edge left by half its following gap so positions land
at bin centres; a lone position uses silx's unit-gap fallback. Public `PlotWidget::add_histogram_aligned`/`_with_legend`
take N positions + N counts + an alignment. The pure edge derivation is headlessly tested per alignment (incl.
single-position, non-uniform centre, empty, and the edges→step-values composition); the GPU add stays unverified.

**StackView perspective + 3D transposition (main, not pushed):** ≈221 Done · ~100 open — 2 M rows closed (182, 183;
duplicates 1233, 1237). The silx `StackView.py` port (`high_level.rs`, frames as `Vec<Vec<f32>>`) gains volume
browsing: `StackPerspective` {Axis0,Axis1,Axis2}, the pure `stack_frame`/`stack_frame_count` slice a 2D frame out of a
row-major `[d0,d1,d2]` volume per perspective (matching silx `__createTransposedView` transpose (1,0,2)/(2,0,1)), and
`dimension_axis_labels` picks X=width-axis / Y=height-axis labels (silx `__updatePlotLabels`). `StackView::set_volume`
keeps the volume and re-slices on `set_perspective`; `perspective_ui` is the browse-dimension combo. Pure core
headlessly tested (per-perspective slicing, frame counts, display-axis mapping, length/range rejection); the
volume-load + perspective-switch GPU path stays unverified. Default dimension labels only — the `setLabels` API (row
184) is the next commit.

**StackView dimension labels (main, not pushed):** ≈222 Done · ~99 open — 1 M row closed (184; duplicate 1267).
`StackView::set_dimension_labels([&str;3])`/`dimension_labels()` mirror silx `setLabels`/`getLabels`: each volume
dimension gets a label (empty → default `"Dimension N"`, like silx's `label or default`), and `apply_axis_labels`
chooses the X (width-axis) and Y (height-axis) label from these as the perspective rotates. `perspective_ui` names the
browse-dimension combo entries from the same labels. The pure default-label → axis-label mapping is headlessly tested;
the `set_dimension_labels` GPU label-application path stays unverified.

**ScatterView selection-mask accessors (main, not pushed):** ≈223 Done · ~98 open — 1 M row closed (198, ◐→✅;
duplicate 1273). `ScatterView::selection_mask() -> &[u8]` and `set_selection_mask(&[u8]) -> Result<usize, _>` mirror
silx `ScatterView.getSelectionMask`/`setSelectionMask`: per-point u8 levels (0 unmasked, 1..=255 a level). `set`
validates the length equals the current point count (silx raises ValueError on shape mismatch), copies the levels, and
commits to the mask's undo history. The mask is a selection query (not a render overlay), so no re-render is needed.
The accessors are thin wrappers over the already-tested `ScatterMaskWidget`; constructing a `ScatterView` needs a wgpu
`RenderState`, so the wrapper's length-check path itself stays GPU-unverified.

**ScatterView position-info panel (main, not pushed):** ≈224 Done · ~97 open — 1 M row closed (205, ◐→✅; duplicate
1261). `ScatterView::show_position_info(ui, &PlotResponse)` embeds the existing `PositionInfo` widget with the silx
X/Y/Data/Index columns (ScatterView.py:90-101). The pure `scatter_pick_pixels(cursor, points_px, radius)` picks the
nearest scatter point in **pixel** space (so the snap radius is constant on screen at any zoom; ties → highest/top-most
index, silx `_pickScatterData`), and `scatter_position_info(Option<ScatterPick>)` builds the bar: X/Y/value/index snap
to the pick, or X/Y show the bare cursor and Data/Index show "-" when nothing is within `SCATTER_PICK_RADIUS_PX`
(= default marker size). The cursor is tracked from the plot's pointer event (silx `sigMouseMoved`). Pure pick + column
builder headlessly tested (nearest/none/tie, snap/fallback/placeholder); the transform-projection + render path stays
GPU-unverified.

Status legend: ✅ Done · ◐ Partial · ☐ Missing · ✅ N/A (resolved: parity achieved differently). Effort S/M/L. Priority H/M/L.

> This file tracks the port. The per-area tables below are the **as-of-sweep
> baseline**; landed work is recorded in the Progress log so the baseline stays
> a stable reference. Follow-ups that a wave deliberately deferred are listed too.

## Remaining work (re-baselined 2026-06-04, main @ d04232a)

These figures supersede the older **"Remaining work (live, code-verified
2026-06-02)"** view, which was frozen before Waves 10–12 and the ~18 commits that
landed after the Wave-12 progress-log entry (detached windows; Arc/Band/Circle/
Ellipse/Rect handle-drag editing; the `apply_curve_alpha` double-premultiply fix;
the R2.1 mid-drag mode-switch fix; legend icon styling; active-curve axis labels;
right-click zoom menu; vertical-colorbar/rotated-Y-label centering fixes). An
8-slice read-only recon (workflow `wf_b2b09dc2-620`) re-derived **every** status
from the code on `main` rather than trusting the stale status column:
**191 features Done** (counts-based, approximate) vs **130 still open** (Partial +
Missing, deduped; four stale "Missing" interaction rows dropped because their own
evidence shows them implemented — log-aware pan/zoom, float32-safe limits, overflow
protection). The tables below enumerate the **92 explicitly-tracked open rows** with
a priority (10 H / 48 M / 34 L), each traced to a silx ref; the climb to 130 comes
from per-slice tallies that include un-enumerated lower-detail gaps. P = priority,
E = effort. The frozen per-area baseline tables further down are kept only as the
as-of-sweep reference.

### core-axes-ticks

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | M | Per-axis timezone support | items/axis.py:278-294 (get/setTimeZone) | Full silx surface (`a61a944`,`0c78553`,`895cee3`): `TimeZone{Utc, FixedOffset{seconds_east}, Named(tz::TimeZoneRef)}` + `TimeZone::named(name)`/`local()`; `dtime_ticks` `_tz` layout (`calc_ticks_tz`/`format_tick(s)_tz`, `from`/`to_epoch_seconds_tz`) over the single instant-aware `offset_at`, `Plot::x_time_zone`/`set_x_time_zone` threaded through chrome. `Utc`=`"UTC"`, `FixedOffset`=`datetime.timezone(timedelta)`, `Named`/`local`=arbitrary `dateutil.tz` zone / `None` via the bundled IANA tz DB (`tzdb`/`tz-rs`, DST-aware). Nuances: `local()` resolves the system zone once at construction (not re-resolved per render); the `to_epoch` local→UTC inversion uses the standard two-step refine (DST fold ≈ default). Tested incl. EST/EDT + a spring-forward window |
| ✅ N/A | L | M | Overlay-only replot optimization | PlotWidget.py:719-730 (`_setDirtyPlot`, overlay vs True) | Resolved W15 — not a gap. silx's `_dirty` defers redraws for a *retained* backend; siplot is immediate-mode with persistent GPU buffers: curve/image data uploads only on add/update (event-driven), the per-frame `prepare` only re-decimates a *changed* view + writes cheap uniforms, `paint` draws from persistent buffers. There is no per-frame full upload to skip; a `dirty()` gate would be speculative + can't skip egui's frame paint. `DirtyState`/`dirty()` remain as a faithful silx-concept port (set, not consumed in production) |

### interaction-events-panzoom

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✔ Done (W14) | H | M | Specific item-click signals (curveClicked/markerClicked/imageClicked) | PlotInteraction.py:1223-1261, PlotEvents.py:88-173 | `PlotEvent::{CurveClicked,ImageClicked,ItemClicked}` carry handle + position + button via the `pick_topmost`/`click_event_for_pick` owner path |
| ✔ Done (W14) | M | M | Hover event signals with item metadata | PlotInteraction.py:1135-1154, PlotEvents.py:73-85 | `PlotEvent::ItemHovered{handle,kind,label,x,y,xpixel,ypixel,draggable}` mirrors silx `prepareHoverSignal`. silx `selectable` omitted by design: every pickable item is set-active on click in siplot, so the flag is structurally constant `true` (uninformative) |
| ✔ Done (W15) | M | S | DrawingProgress / DrawingFinished event wiring | PlotInteraction.py:529-532, PlotEvents.py:34-55 | `apply_interaction` surfaces the RoiCreate `DrawEvent` on `PlotResponse.draw_event`; `high_level.rs` emits `PlotEvent::DrawingProgress{mode,points}` / `DrawingFinished{mode,params}` (the latter alongside `RoiCreated`) |
| ✔ Done (W14) | M | M | ItemsInteraction state machine (item picking/dragging) | PlotInteraction.py:1115-1350 | The pickers are wired through one owner `pick_topmost`: click → `Curve/Image/ItemClicked` (silx `_handleClick`), bare hover → `ItemHovered` (silx Idle), and a draggable-marker drag runs the `MarkerDragStarted`→`MarkerMoved`→`MarkerDragFinished` lifecycle (silx `beginDrag`/`drag`/`endDrag`) |
| ✔ Done (W14) | L | S | Marker drag-finished vs drag-moving signal split | PlotInteraction.py:1276-1299, 1350 | Split: `MarkerMoved` per frame (silx `markerMoving`) + distinct on-release `MarkerDragFinished` (silx `markerMoved`), bracketed by `MarkerDragStarted` |
| ✅ Done | L | S | Selection-area color/fill-mode styling | PlotInteraction.py:98-141 | W15: box-zoom rubber band and draw preview share one `draw_selection_polygon` renderer honoring `FillMode` (Hatch/Solid/None) + color. Box-zoom now renders silx Zoom's `fill="none"` dashed outline instead of a hardcoded solid rect (`plot_widget.rs`). Public per-mode color setter not exposed (no consumer) |
| ✔ Done (W15) | L | S | Limits-changed event with actual range values | PlotEvents.py:176-184 | `PlotEvent::LimitsChanged{x,y,y2}` now carries the new left-axis `(min,max)` ranges and the optional right axis, built from the post-change snapshot in `push_limits_changed_if` |
| ✅ Done | L | M | Axis constraints (min/maxXRange, min/maxYRange) | panzoom.py:222-366 | `AxisConstraints::apply` mirrors silx `ViewConstraints` (allow_scaling=True): max-range capped to the position window (`update` sanity), and a view wider than the window snaps to it (`normalize` adaptive expansion) — W15, `core/plot.rs` |
| ✅ N/A | L | M | StateMachine base (ClickOrDrag hierarchy) | Interaction.py:87-198 | Resolved — not a gap. silx's `StateMachine`/`ClickOrDrag` class hierarchy is an implementation structure, not a user-facing feature; siplot achieves the same interaction behavior with `DrawState` + the imperative `apply_interaction` dispatch (functional parity achieved differently) |
| ✔ Done (W14) | L | S | Marker drag-start/end callbacks (clicked/moving/moved triad) | PlotInteraction.py:1223-1350 | Full lifecycle: `MarkerDragStarted` (begin) → `MarkerMoved`×N (moving) → `MarkerDragFinished` (on-release moved); marker click arrives via `ItemClicked` from the unified picker |
| ✅ Done | L | S | Pencil preview circle during freehand draw | PlotInteraction.py:955-1110 (`updatePencilShape`) | W15: `ImageView::draw_brush_preview` paints the silx pencil footprint — an unfilled circle of radius `brush_size/2` (13 pts, `pencil_preview_circle`) at the cursor in MaskDraw mode, idle + painting (`high_level.rs`, `mask_tools.rs`) |
| ✅ Done | L | S | Cursor snap indicator in polygon mode | PlotInteraction.py:485-621 | W15: `draw_polygon_first_point` renders silx's first-point close target — an unfilled box of half-size `close_threshold_px` around the first vertex — throughout the polygon draw (RoiCreate + `show_with_draw`); `plot_widget.rs`, `interaction.rs::close_threshold_px()` |

### items

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | M | Histogram bin alignment (left/center/right) | histogram.py:53-85 (`_computeEdges`, `setData(align=)`) | W15: `HistogramAlign` {Left,Center,Right} + pure `histogram_edges(positions, align)` mirror silx `_computeEdges` (Left=positions are left edges, append `x[-1]+last_gap`; Right=right edges, prepend `x[0]-first_gap`; Center right-aligns then shifts each edge left by half its following gap; lone position uses unit gap). Public `PlotWidget::add_histogram_aligned`/`_with_legend` take N positions + N counts + align. 7 headless tests (each align, single-position unit gap, non-uniform center, empty, step-value composition). The GPU add itself stays unverified |
| ✅ Done | M | L | Scatter mode-specific picking | scatter.py:804-860 | All modes wired into the live pick (`ScatterView::show_position_info` dispatches by mode: REGULAR_GRID cell→index, BINNED_STATISTIC bin→nearest-member via `nearest_candidate_in_data`, POINTS/SOLID nearest-pixel). IRREGULAR_GRID now closed by the triangle-mesh render redesign: `irregular_grid_triangles` builds a flat-shaded quadrilateral cell per point (silx `_quadrilateral_grid_as_triangles`), and `irregular_grid_pick` maps the cell under the cursor back to its point (silx picked-vertex `// 4`). See row 1086. |
| ✅ Done | M | M | Image per-pixel validity mask before upload | items/image.py:209-251 | `ScalarMask` + Plot2D wiring (`8ef46c2`) exist; `ScalarMask::apply` sets masked pixels to `f32::NAN`, now rendered transparent by the shader's non-finite → nan_color branch (`0356d5c`, with row 1140). Composite GPU render verified structurally (WGSL validates against naga); on-screen pixels GPU-unverified |
| ✅ Done | M | L | Marker custom-callback constraint | items/marker.py:208-235 | Arbitrary `setConstraint(fn)` form ported as `Marker::drag_with(to, constraint)` — the closure receives the cursor's data target `(x,y)` and returns the filtered position, exactly like silx's callable (`setPosition` does `x,y = getConstraint()(x,y)`). `drag` now delegates to `drag_with` via `apply_constraint`, so `drag_with` is the single owner of "filter target → update kind coords". The closure stays call-time state (not stored) so `Marker` keeps `Clone`/`Debug`/`PartialEq`. H/V presets still on the marker as `MarkerConstraint`. Headless tests: point filtered, vline/hline single-axis, non-draggable no-op, preset delegation |
| ✅ Done | M | M | Histogram filled-region picking | histogram.py:244-290 (`__pickFilledHistogram`) | Wave 7: pure `pick_histogram(edges, values, baseline, x, y) -> Option<usize>` — strict bbox gate (x over edge span, y over `[min(0,vmin), max(0,vmax)]`), `searchsorted(edges, x, side="left")-1` (clamped) bin index via `partition_point`, inclusive y test against the bar `[baseline, value]`/`[value, baseline]`. None on malformed/all-NaN/out-of-box/no-hit |
| ☐ Missing | M | L | Image per-pixel alpha map | items/image.py:462-500 (get/setAlphaData) | `ImageData` has only global `alpha:f32`; no per-pixel alpha array (needs shader) |
| ✅ Done | L | L | Marker text anchor/alignment | items/marker.py | `TextAnchor` now wired into `chrome::draw_markers`: label placement goes through the pure, tested core — `TextAnchor::pixel_offset` ports silx `_TextWithOffset.__get_xy` (left→+x/right→−x, top→+y-down/bottom→−y), then `rect_offset` positions the galley so the named anchor lands on the marker point. Per-kind defaults match silx `addMarker`: point & vline `ha="left"`→`TopLeft`, hline `ha="right",va="top"`→`TopRight` (text at the right edge). Point/vline padding `(5,3)`/`(size/2+3,3)`, hline `(5,3)`. Anchor/offset core headlessly tested; the painter draw stays GPU-unverified |
| ✅ Done | L | M | Shape overlay-flag separate data layer | items/shape.py:54-73 (`isOverlay`) | `draw_shapes`/`draw_lines` take an `overlay: bool` filter; `PlotWidget` runs two passes — non-overlay shapes/lines render in the base data layer (above grid, UNDER the overlay items: ROIs, markers, crosshair), overlay shapes/lines (default) render in the overlay layer on top of the chrome (silx base-render vs `_drawOverlays`). `Line` shares the `_OverlayItem` base so it gets the same split. Field/setter model tested; the render-layer split is GPU/painter-unverified |
| ☐ Missing | L | L | ImageStack lazy URL/HDF5 loading | items/image.py:593-669 | Pre-loaded in-memory only; no URL/HDF5 lazy load or prefetch (out of declared scope) |
| ☐ Missing | L | L | CompareImages SIFT keypoint alignment | CompareImages.py | Basic modes only; no SIFT/affine alignment (out of scope) |

### roi

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | H | S | Per-instance ROI color on the **live** plot | items/_roi_base.py:389-405 | Wave 13: unified `plot.rois` to `Vec<ManagedRoi>` (single source of truth); the live render `plot_widget.rs → chrome::draw_rois` resolves each ROI's color as `managed.color.unwrap_or(plot.roi_color)` via pure `chrome::roi_appearance`. On-screen pixels GPU/PlotWidget-UNVERIFIED; appearance resolution headlessly tested |
| ✅ Done | H | M | ROI label/name text on the live plot | items/_roi_base.py:492-511 (`_updateText`) | Wave 13: `ManagedRoi.name` plumbed through `chrome::roi_appearance.name` into the live `draw_rois` path (empty name → no label). Same single-collection fix as the color row |
| ✅ Done | H | S | ROI selection/highlight on the live plot | tools/roi.py:528-590 (`setHighlighted`) | Wave 13: `Plot::set_current_roi` (sole owner of `ManagedRoi.selected`) drives the live highlight; `draw_roi` thickens the current ROI's stroke. Invariant (exactly one selected; out-of-range clears; remove adjusts index) headlessly tested |
| ✅ Done | H | L | CurvesROIWidget integration | CurvesROIWidget.py:62-400 | W15: `CurvesRoiWidget` shows a per-ROI table (`ROI \| From \| To \| Raw Counts \| Net Counts \| Raw Area \| Net Area`) over the active curve; `PlotWidget::feed_curves_roi_stats`/`show_curves_roi_widget` reduce each x-span ROI via the pure `curve_roi_counts` (silx `computeRawAndNetCounts`/`computeRawAndNetArea`: array-order selection, linear-endpoint background, numpy-trapezoid). Pure core (`curve_roi_counts`) + row-building (`curve_roi_rows`) headlessly tested; the active-curve feed + render stay GPU-unverified. Example `high_level_curves_roi` |
| ✅ Done | H | L | ROIStatsWidget display | ROIStatsWidget.py | W15: `RoiStatsWidget` renders one row per ROI (`ROI \| N \| min \| max \| mean \| sum \| integral`) over the active item; `PlotWidget::feed_roi_stats`/`show_roi_stats_widget` reduce each ROI via `image_roi_stats`/`curve_roi_stats` and follow the active item + live ROI list. Row-building (`roi_stats_rows`) headlessly tested (image/curve per-ROI, empty); the active-item feed + render stay GPU-unverified. Example `high_level_roi_stats` uses it |
| ✅ Done | M | M | EllipseROI orientation/rotation | items/roi.py:875-1177 | `Roi::Ellipse` gained an `orientation` field (radii.0 along θ, radii.1 perpendicular; θ=0 ≡ the prior axis-aligned shape). All geometry is orientation-aware: vertex/handle positions (axis0 at θ, axis1 at θ+π/2), `contains` (project into the ellipse frame), `screen_rect` (rotated AABB), and the 27-point outline (silx rotated parametric form, mapped data→pixel). Dragging an axis handle off-axis sets that semi-axis AND rotates, mirroring silx `EllipseROI.handleDragUpdated`. Rotation geometry, contains-under-rotation, handle-follow, and AABB are headlessly tested |
| ✅ Done | M | M | CircleROI dedicated handle UI | items/roi.py:727-950 | Dedicated edit handles exist: `edges()` → `[Vertex(0) center, Vertex(1) perimeter]`, `move_edge` (V0 translate, V1 sets radius), rendered + hit-tested via `draw_roi_handles`/`roi_grab_at`. Tests `circle_handles_drag_center_and_radius`, `circle_perimeter_resize_works_under_inverted_y`. Matches frozen row 1171 ✅ (stale ◐ here from before Wave 12 handle-editing landed). |
| ✅ Done | M | M | CrossROI marker symbols | items/roi.py:133-185 | `1cbda70`: the drag handle is a square (silx `addHandle()` default symbol "s"); the full-span cross-hairs honor the ROI appearance's color/width/line-style. (silx's transient label-handle management is the manager-path concern, not the geometry) |
| ◐ Partial | M | L | ArcROI interaction sub-modes | items/_arc_roi.py | Both sub-modes' geometry now exist as pure cores: PolarMode editing is wired into the live handle drag (`Roi::move_edge`), and ThreePointMode is ported headlessly — `arc_from_three_points` (faithful `_createGeometryFromControlPoints`: closed-circle / collinear / general branches), `arc_control_points` (derive start/mid/end on the central radius, silx `_updateHandles`/`_updateMidHandle`), and `arc_three_point_drag` (move a control point → re-fit center+radius+angles, weight preserved). `arc_from_two_points` now delegates to `arc_from_three_points` (one geometry owner). Remaining ◐: the live canvas toggle between Polar and ThreePoint editing (per-ROI sub-mode state + button + `move_edge` dispatch) is GPU/UI and unwired — the live drag is PolarMode only. 4 headless tests |
| ✅ Done | M | S | Manager `sigCurrentRoiChanged` signal | tools/roi.py:346-347 | Wave 13: `PlotEvent::CurrentRoiChanged { previous, current }` emitted by `set_current_roi` only when the selection actually changes (mirrors silx `sigCurrentRoiChanged`) |
| ✅ Done | M | M | ROI handle-symbol customization | items/_roi_base.py:735-759 | `draw_roi_handles` renders the silx `addHandle` role symbols: `+` for translate/center handles (role "translate") and a 6px square `s` for vertex/edge handles (role "default"). The `o` glyph is silx's `PolygonROI._handleClose` (role "user", transient creation marker, items/roi.py:1244) from the `RegionOfInterestManager` creation path; siplot creates polygons via the `PlotInteraction` draw path and renders its faithful close target — the `SelectPolygon.updateFirstPoint` fill=None box (`draw_polygon_first_point`) |
| ✅ Done | M | M | ROI line style (dash/dot) on canvas | items/_roi_base.py (LineMixIn) | Wave 13: `ManagedRoi.line_style` (`RoiLineStyle::{Solid,Dashed,Dotted}`) resolved into `RoiAppearance.line_style` and drawn by the live path; manager combo edits it |
| ✅ Done | M | S | ROI line width on canvas | items/_roi_base.py:245 | Wave 13: `ManagedRoi.line_width` resolved into `RoiAppearance.line_width` on the live `draw_rois` path (was hardcoded 1.0pt); manager drag-value edits it |
| ✅ Done | M | S | Manager default-color application | tools/roi.py:782-797 | Wave 13: added `Plot::roi_color` (silx-default red) applied as the per-ROI fallback; manager color buttons now write `managed.color` and apply on the live plot |
| ✅ Done | M | M | ROI edge-position constraints | items/roi.py:666-718 + per-class | All silx-real edge constraints are enforced: Rect/Range handles flip around the fixed opposite (silx `RectangleROI._setBound` min/max) instead of collapsing; Arc keeps `inner<=outer` and `inner>=0` (`.max(0.0)` in `move_edge`); Band keeps `width>=0` and the orthogonal width-handle constraint (normal projection `.max(0.0)`, silx `__handleWidthUp/DownConstraint`). silx's ROI classes have no ratio-lock, grid-snapping, or inter-ROI collision behavior to port |
| ◐ Partial | L | L | BandROI sub-modes (Bounded/Unbounded) | items/_band_roi.py:172-250 | **Mis-attribution corrected:** silx BandROI has no "rotation sub-mode" — band orientation is already free via the begin/end handles (siplot `move_edge` Vertex 0/1). silx's actual sub-modes are `BoundedMode` (drawn as the 4-corner polygon, siplot's default via `band_corners`) and `UnboundedMode` (drawn as three parallel infinite lines). The Unbounded geometry core is now ported: `Roi::band_lines() -> BandLines` (faithful `BandGeometry.slope`/`intercept`/`edgesIntercept`), modeled as a `Sloped`/`Vertical` sum type so no field carries silx's `inf`-slope dual meaning. Remaining ◐: the live Bounded↔Unbounded render toggle (drawing the three view-clipped lines + a mode button) is GPU/UI and unwired. 4 headless tests |
| ✅ Done | L | M | ROI creation preview polish | tools/roi.py:493-510, 1101-1175 | Live overlay + polygon close target (`draw_polygon_first_point`, silx `SelectPolygon.updateFirstPoint`) present, and the mode indicator now lands: `PlotInteractionMode::roi_creation_message`/`PlotWidget::roi_creation_message` produce silx's `InteractiveRegionOfInterestManager.getMessage` string ("Select {SHORT_NAME}s ({n} selected)") for a host status bar (headlessly tested). Closes frozen row 1175. |
| ✅ Done | L | S | ROI fill enable/disable toggle | items/roi.py:531-539 | Wave 13: `ManagedRoi.fill` plumbed to `RoiAppearance.fill`; `set_roi_fill` API + manager checkbox toggle the interior fill on the live plot (silx `setFill`) |
| ✅ Done | L | S | Distinct Horizontal/VerticalLineROI kinds | items/roi.py:366-510 | `Roi::HLine { y }`/`Roi::VLine { x }`: single-Y/single-X full-span line ROIs distinct from HRange/VRange bands (one edge, one handle, exact-coord `contains`). `RoiDrawKind::HLine`/`VLine`→`DrawMode::HLine`/`VLine` (silx `SelectHLine`/`SelectVLine`), chrome draws a styled full-span line. Geometry/grab/draw-build tested; GPU draw unverified |
| ✅ Done | L | M | ROI right-click context menu | tools/roi.py:625-642 (`_feedContextMenu`) | Per-ROI canvas menu (silx `_createMenuForRoi`): ROI-name title + "Make current" (`set_current_roi`) + "Remove" (`remove_roi`); the target ROI is captured via `roi_grab_at` and held in `ui.data` for the menu's lifetime, and the high-level owner applies the signalled intent so CurrentRoiChanged/RoisCleared fire. No interaction-mode submenu (siplot ROIs lack silx `InteractionModeMixIn`). Closes frozen row 1179. |
| ◐ Partial | L | L | ROI save/load from file | CurvesROIWidget.py:194-210 (dictdump) | Data-layer save/load ported: `core::roi_io` `encode_rois`/`decode_rois` (hand-written line-oriented text format, no serde — same manual approach as the `.npy` mask path) round-trip every `Roi` variant plus name/color/line width+style/gap color/fill; the transient `selected` flag is omitted (silx `ROI.toDict` likewise). `save_rois(path,…)`/`load_rois(path)` are the file wrappers (silx `CurvesROIWidget.save`/`load(filename)`). 9 headless tests (round-trip all 13 variants + full appearance, header/unknown-type/arity/color error cases, unknown-key tolerance). Remaining ◐: the in-app Save/Load buttons + native file-picker dialog in the manager are GPU/UI-unwired |
| ✅ Done | L | M | ROI keyboard shortcuts + naming UI | tools/roi.py:1072-1098 | Faithful port of silx `InteractiveRegionOfInterestManager.eventFilter`: pure `roi_key_action(key, command) -> RoiKeyAction` maps Enter→Validate and Delete/Backspace/Ctrl+Z(⌘Z)→UndoLast. **Mis-attribution corrected:** silx binds *no* per-shape keys ("R for Rect" was speculative) and has no named-creation dialog — ROI naming is via the manager (`ManagedRoi.name`, already present). Wired in `plot_widget` RoiCreate: the key dispatch runs through `roi_key_action`; UndoLast signals `roi_removed = last` to the single removal owner (`high_level::remove_roi`, like the context-menu Remove). Mapping headlessly tested; the live key/event wiring is GPU/UI-unverified |

### colormap-colorbar-mask

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done (W15) | H | M | ColormapDialog Stddev3/Percentile from raw pixels | ColormapDialog.py:240-280 | `ColormapDialog::apply` now autoscales from the active image's raw pixels (`Plot2D::get_image_pixels_raw`) via the pure `autoscale_range` (delegates to `AutoscaleMode::range` with the dialog's mode + percentiles) — Stddev3 (mean ± 3·std clamped to data range) and Percentile compute the real distribution range instead of falling back to MinMax; falls back to aggregated stats min/max only when no raw scalar pixels are retained |
| ✅ Done (W15) | H | M | Mask drawing tool: Rectangle | MaskToolsWidget.py:805-826 | On-plot rectangle draw: `MaskTool::draw_mode` arms a `DrawState` driven by `handle_shape_draw`; `rect_params_to_cells` (silx `int()` truncation) → `update_rectangle` on finish; `ImageView::handle_mask_shape_draw` paints the rubber-band preview (reuses `paint_draw_preview`/`feed_draw_state`) |
| ✅ Done (W15) | H | M | Mask drawing tool: Polygon | MaskToolsWidget.py:840-847 | On-plot polygon draw (click-to-add, snap-close) via the shared shape-draw wiring: `MaskTool::draw_mode` arm + `polygon_vertices_to_cells` (cast int64, swap `(x,y)`→`(row,col)` per silx `[:, (1,0)]`) → `update_polygon`/`polygon_fill_mask` on finish |
| ✅ Done (W15) | H | L | Mask drawing tool: Ellipse | MaskToolsWidget.py:828-838 | On-plot ellipse draw via the shared shape-draw wiring: `MaskTool::draw_mode` arm + `ellipse_params_to_cells` (center cast int64, y-semi→`radius_r`, x-semi→`radius_c`; radii stay float per silx) → `update_ellipse` on finish |
| ◐ Partial | M | M | Colormap catalog: matplotlib-dynamic loading | colors.py:938-955 | `6b0b2f9`: catalog grown 15 → 45 by shipping the full `colorous` gradient set (d3-scale-chromatic / ColorBrewer overlap with matplotlib) statically. True runtime matplotlib-dynamic loading is impossible without a Python/matplotlib dependency (out of scope); the static catalog is the faithful substitute |
| ✅ Done | M | M | ColormapDialog histogram display | ColormapDialog.py:1227-1295 (`computeHistogram`), :831-848 (display) | Wave 6: pure `compute_histogram` (silx `nbins=clamp(2,min(256,⌊√N⌋))`, finite-min/max or supplied range, log-space binning with edges → linear) + `set_histogram`/`histogram`/`clear_histogram` (silx `setHistogram`/`getHistogram`). Dialog auto-derives from the active image's raw pixels (recompute only on (re)open / normalization change); `draw_histogram_panel` renders normalized gray bars + a colormap gradient strip across `[vmin,vmax]` + vmin/vmax markers. Pure compute + API headlessly tested; on-screen panel GPU-unverified |
| ✅ Done | M | M | Mask threshold UI (below/between/above) | _BaseMaskToolsWidget.py:265-294, :848-937, :1182-1225 | Wave 7: threshold row in the ImageView mask-draw toolbar — `ThresholdMode` selector + min/max fields shown per mode (min for below/between, max for between/above, silx minLineEdit/maxLineEdit visibility) + `Mask below/between/above` Apply button calling the tested `update_threshold` (below→min, above→max, between→both) at the current level, commit + re-upload. `MaskToolsWidget` holds `threshold_mode`/`threshold_min`/`threshold_max` (silx group defaults: below checked, edits at 0; unit-tested). Toolbar row GPU-unverified |
| ◐ Partial | M | L | Mask file save (npy/edf/tif/h5/msk) | MaskToolsWidget.py:104-141 | `.npy` AND `.edf` encode now ported: `render::save::encode_mask_edf` (hand-written fabio-style ASCII `{…}` header + raw `uint8` C-order body, 512-aligned, no new dep) with `MaskToolsWidget::write_edf`/`save_edf`/`save_mask_edf` mirroring the npy path. Remaining: no file dialog; TIFF/HDF5/msk formats need external crates (`tiff`/`hdf5`/fabio), deferred under the no-new-dep pattern |
| ◐ Partial | M | L | Mask file load (npy/edf/tif/h5/msk) | MaskToolsWidget.py:589-629 | `read_npy` AND `read_edf` decode+apply a 2D uint8 mask through the shared `apply_loaded_mask` crop/pad+commit owner (`render::save::decode_mask_edf` reads the fabio `{…}` header for Dim_1/Dim_2/DataType, body taken right after `}`); `load_edf`/`load_mask_edf` mirror the npy file wrappers. Missing: file dialog, TIFF/HDF5/msk formats (external-crate-bound, deferred), HDF5 dataset selection |
| ◐ Partial | M | S | Mask transparency/alpha slider UI | _BaseMaskToolsWidget.py:554-577 | Wave 7: Transparency slider in `MaskToolsWidget::show_toolbar` (the coloured-overlay toolbar) routes through the tested `set_transparency` (silx `transparencySlider` → `_setMaskColors`: selected level at this alpha, others at half) and re-renders the overlay LUT. Slider GPU-unverified. N/A for ImageView's mask (rendered as NaN holes, no overlay alpha) |
| ✅ Done | M | S | Mask Mask/Unmask toggle + Ctrl modifier | _BaseMaskToolsWidget.py:790-810 | `d85c751`: `mask_state` Mask/Unmask radio for shape draws + `effective_do_mask = base ^ ctrl` (silx `_isMasking()`); Ctrl (egui `command`, = Cmd on macOS per Qt) inverts all draws, captured once per pencil stroke. Pencil/eraser keep their brush direction. Truth-table tested; toolbar GPU-unverified |
| ✅ Done | M | S | Mask invert per-level UI | _BaseMaskToolsWidget.py:207-218 | Wave 7: "invert" button in the ImageView mask-draw toolbar calls the tested `MaskToolsWidget::invert`. `d85c751` adds the silx Ctrl+I shortcut (egui `COMMAND`+I) in `MaskToolsWidget::show_toolbar`. Button/shortcut GPU-unverified |
| ✅ Done | M | S | Mask not-finite (NaN/Inf) button | _BaseMaskToolsWidget.py:296-304 | `MaskToolsWidget::mask_not_finite` (silx `updateNotFinite`: stencil `!isfinite`, tested) + Wave 7 "mask non-finite" button in the ImageView mask-draw toolbar (masks the active image's NaN/Inf pixels at the current level, commits, re-uploads; enabled when mask geometry matches the image). Button/overlay GPU-unverified |
| ✅ Done | L | S | Mask per-level color override UI | _BaseMaskToolsWidget.py:394-398, 973-1042 | API done earlier (`set_mask_colors`/`reset_mask_colors`, faithful `setMaskColors`/`resetMaskColors`). This adds the getter `current_mask_color` (silx `getCurrentMaskColor`: per-level override if set, else base color, tested) and the `MaskToolsWidget::show_toolbar` UI: a color swatch reading/writing the current level's override + a "Reset color" button. Getter tested; toolbar swatch GPU-unverified |

### composites

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | S | ImageView `getHistogram()` public API | ImageView.py:699-725 | Wave 7: `histogram(axis) -> Option<ImageProfileHistogram { data, extent }>` (silx `getHistogram` `{data, extent}` dict). `ImageHistogramAxis::{X,Y}` → per-column / per-row sums (shared `image_column_sums`/`image_row_sums` helpers, also feeding `rebuild_histograms`); extent `(0, width)`/`(0, height)`, end-exclusive |
| ✅ Done | M | S | ImageView side-histogram show/hide API | ImageView.py:552-566 | Wave 7: `is_side_histogram_displayed`/`set_side_histogram_displayed` (silx `is/setSideHistogramDisplayed`). `show()` gates the top histogram, the radar viewport sync, and the bottom vertical-histogram+radar column on the flag — silx hides `_histoHPlot`/`_histoVPlot`/`_radarView` as one cluster; strip extents collapse to 0 via `side_histogram_extent` so the image reclaims the space |
| ✅ Done | M | S | ImageView `valueChanged` signal | ImageView.py:381,585-601 | Wave 7: `value_changed() -> Option<(col, row, value)>` (silx `valueChanged` emitted at `_imagePlotCB`). Pure `image_value_at` maps cursor data coords to a pixel index via silx `int((x-origin)/scale)` (identity geometry), guarded against the origin and the pixel grid; `None` when off-image (silx emits nothing) |
| ✅ Done | M | M | ScatterView position-info panel API | ScatterView.py:90-101 | `show_position_info(ui, &PlotResponse)` embeds `PositionInfo` with X/Y/Data/Index, snapping to the picked point (`scatter_pick_pixels`, pixel-space, silx `_pickScatterData`) |
| ✅ Done | M | S | ScatterView get/setSelectionMask API | ScatterView.py:412-418 | `selection_mask()`/`set_selection_mask(&[u8])` (silx getSelectionMask/setSelectionMask): per-point u8 levels; set validates length == point count, commits to undo history |
| ✅ Done | M | M | StackView perspective selection | StackView.py:364-397 | `StackPerspective` {Axis0,Axis1,Axis2} + `set_perspective`/`perspective`/`perspective_ui` combo pick the browse dimension |
| ✅ Done | M | M | StackView 3D transposition | StackView.py:409-441 | `set_volume([d0,d1,d2])` + `stack_frame` re-slice per perspective (silx transpose (1,0,2)/(2,0,1)); axis labels via `dimension_axis_labels` |
| ✅ Done | M | S | StackView dimension labels | StackView.py:799-827 | `set_dimension_labels([&str;3])`/`dimension_labels()` (silx setLabels/getLabels); empty→default `"Dimension N"`; axis labels rotate with perspective |
| ◐ Partial | M | M | CompareImages vline/hline separator modes | CompareImages.py:124-133, 422-445 | Wave 7: `HalfHalf` is the silx VERTICAL_LINE composite (A left / B right); added `CompareMode::SplitHorizontal` = HORIZONTAL_LINE (A top / B bottom) via the pure `split_composite` helper + toolbar button. Split position is a slider (`split` fraction). Remaining: an on-plot *draggable* separator line (silx `__separator` markers + `__separatorConstraint`) instead of the slider |
| ◐ Partial | M | M | ComplexImageView amplitude-range dialog | ComplexImageView.py:50-155, items/complex.py:62-82, :199-212 | Wave 7: `ComplexMode::Log10AmplitudePhase` + `amplitude_phase_log_rgba(data, max, delta)` port silx `_complex2rgbalog` (clamp to displayed max `smax`, `log10(\|z\|+1e-20)`, shift by `a.max()-delta`, normalize over `delta` decades) — amplitude → HSV value (siplot convention). `set_amplitude_range_info(max, delta)` / `amplitude_range_info()` = silx `_setAmplitudeRangeInfo`/`_getAmplitudeRangeInfo`, defaults max=None autoscale + delta=2 (`DEFAULT_AMPLITUDE_DELTA`). Composite math unit-tested. `87cd1d4` adds `show_amplitude_range_controls` (autoscale checkbox + Displayed Max + delta, silx `_AmplitudeRangeDialog`) with the tested `data_max_amplitude` seed. UI/GPU render dispatch GPU-unverified |
| ◐ Partial | L | L | StackView 3D-profile toolbar | StackView.py:948-951 | Data layer wired: `StackView::stack_aligned_profile`/`stack_line_profile` extract a profile from every frame along the browsed dimension over the loaded volume + current perspective (silx `Profile3DToolBar`'s `ProfileImageStack{HorizontalLine,VerticalLine,Line}ROI`), returning a `StackProfile`. Reuses the headlessly-tested row-253 cores. Remaining ◐: the `Profile3DToolBar` buttons + a side plot showing the stacked-profile image (GPU/UI; StackView needs a wgpu RenderState no test builds, so the methods are GPU-unverified wrappers) |
| ✅ Done | L | M | StackView calibration (per-axis scale/origin) | StackView.py:551-566 | `core::Calibration` {None, Linear} + `StackView::set_calibrations`/`calibrations`/`calibrations_axes`/`image_z` mirror silx `getCalibrations(array/axes)` + `_getImageOrigin/Scale/Z`; displayed image placed via the calibrated origin/scale (`add_image_with_geometry`). Pure helpers tested; GPU placement unverified |

### actions-toolbars

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ◐ Partial | M | M | Print: native dialog + printer submission | actions/io.py:747-845 | `print_graph()` GPU-readback shim exists; no native printer dialog or print-to-device backend |
| ✅ Done | M | M | ColormapAction floating-dialog toggle | actions/control.py:352-448 | `f3a9c54`: `ColormapDialog::toggle_button` is a checkable toolbar action that reflects/flips `dialog.open` (silx `ColormapAction`); wired into the `high_level_colormap_dialog` example toolbar row. Toolbar GPU-unverified |
| ✅ Done | M | M | ScatterToolBar viz-mode selector button | tools/toolbars.py:273-362 | `4477bc2`: `ScatterView::show_toolbar` adds a visualization-mode ComboBox (`ScatterVisualization::ALL`, silx order) that calls `set_visualization` on the picked mode. Toolbar GPU-unverified |
| ✅ Done | M | S | ProfileOption + line-width/method (sum vs mean) | PlotToolButtons.py:227-301 | `340b80c`: `ProfileWindow` shows a Width DragValue + Mean/Sum ComboBox that drive `profile_for_roi` through the line-width/method extractors (no longer mean-only). Window UI GPU-unverified |
| ✅ Done | M | S | ProfileToolButton (1D vs 2D selector) | PlotToolButtons.py:304-391 | Standalone `widget::tool_buttons::ProfileToolButton`: a reusable dropdown button with the silx 1D/2D dimension state (`set_dimension` rejects out-of-range, `dimension`), the silx STATE action/tooltip strings, and an egui popup `ui() -> Option<u8>` (silx `sigDimensionChanged`). State core headlessly tested; the popup render is GPU/UI-unverified |
| ✅ Done | M | M | SymbolToolButton (marker symbol + size) | PlotToolButtons.py:394-477 | Standalone `widget::tool_buttons::SymbolToolButton`: a reusable dropdown with a size slider (silx range `1..=20`, default 6.0) over `Symbol::ALL`, emitting `SymbolToolChange::{Symbol,Size}` (silx `_markerChanged`/`_sizeChanged`). `set_size` clamps to the slider range; state core headlessly tested; the popup render is GPU/UI-unverified |
| ✅ Done | M | M | ScatterVisualizationToolButton | PlotToolButtons.py:550+ | `4477bc2`: the visualization-mode selector now lives in the `ScatterView` toolbar (`ScatterVisualization::ALL` ComboBox → `set_visualization`). Toolbar GPU-unverified |
| ✅ Done | L | L | SaveAction PDF/EPS/JPEG | PlotWidget.py:3232-3242 | All three exported, hand-written with no new dep. EPS/PDF are SVG-style raster-embedding substitutes for silx's matplotlib vector output: `render::save::encode_eps` (PostScript `colorimage`, Y-flip matrix, ASCII-hex body) and `encode_pdf` (single-page `/DeviceRGB` `/ASCIIHexDecode` image XObject with a byte-offset xref). JPEG is a hand-written baseline JFIF encoder (`render::jpeg::encode_jpeg` — T.81 Annex K tables IJG-scaled to quality 90, 4:4:4, canonical Huffman, 0xFF stuffing), the same on-disk container PIL writes for silx. `SaveFormat::Eps`/`Pdf`/`Jpeg` + `from_extension` + dispatch + the native save-dialog filters wired. PNG/PPM/SVG/TIFF/JPEG/EPS/PDF supported; only `.ps` remains unmapped |
| ✅ N/A | L | S | Y2AxisAutoScaleAction | actions/control.py:172-230 | Not a silx feature: silx ships only `XAxisAutoScaleAction` + `YAxisAutoScaleAction` (no Y2 variant). The capability already exists (`set_y2_autoscale`, honored by the autoscale path); a Y2 toolbar button would be a non-silx UI extension, deliberately deferred (Wave 7 note: a third autoscale button bloats the row). Marked N/A rather than fabricate a silx counterpart. |
| ✅ Done | L | S | ClosePolygonInteractionAction | actions/control.py:653-683 | `DrawState::validate` ports silx `SelectPolygon._validate`: closes the in-progress polygon at its committed vertices regardless of cursor position (same `len > 2` gate as the snap-close path; no-op when idle / too few vertices / non-polygon mode). Wired in `apply_interaction` to the Enter key while a `RoiCreate(Polygon)` draw is active, so a polygon finishes without snapping back to the first point. `validate` unit-tested; the key wiring is GPU-unverified |
| ☐ — | L | S | OpenGLAction (backend selector) | actions/control.py | N/A: siplot is wgpu-only (intentional) |
| ✅ Done | L | M | LimitsToolBar (editable X/Y min/max fields) | tools/toolbars.py | `PlotWidget::show_limits_toolbar` renders the silx X/Y min/max FloatEdit row; edits apply via `set_graph_x_limits`/`set_graph_y_limits` with `ordered_limits` swapping reversed bounds (silx `_xFloatEditChanged`). Closes frozen row 1241. |

### stats-profile-fit-positioninfo

| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | M | Stats context (viewport/ROI masking, live binding) | stats/stats.py:143-600 | Live binding done (stale "pure engine only" note corrected). Viewport masking: `StatScope::OnLimits{x_range,y_range}` (the on-visible-data toggle) clips to the visible rectangle each recompute. Auto-recompute on viewport change: `StatsWidget::recompute` takes the current viewport every frame and (in `UpdateMode::Auto`) recomputes — the immediate-mode equivalent of silx's `_updateStats` on `sigVisibleDataChanged`. ROI masking is the separate `ROIStatsWidget` path (`image_roi_stats`/`curve_roi_stats`, `roi.contains` mask, rows above) — faithful to silx `_StatsContext`, which itself carries only the onlimits clip, no per-pixel user mask. Qt item-change signals are N/A (immediate mode rebuilds the rows from `item_records` each frame). |
| ✅ Done | M | M | StatsWidget live item binding | StatsWidget.py:200-700 | Bound to the live plot both ways (stale "not docked / no live binding" note corrected): `feed_active_stats`/`show_active_stats_widget` track the **active** item, and `feed_all_stats`/`show_all_stats_widget` track **all** items with retained scalar data (one row each, legend-labelled) — silx `setDisplayOnlyActiveItem`'s two modes, chosen by which method the host calls. Each recomputes from the live retained data every frame (Auto) honoring the on-visible-data viewport clip; items without retained scalar data (RGBA images, triangles, shapes, markers) are skipped. Composed pure cores (`retained_data_to_stats_input`, `Stats::for_curve`/`for_image`, `recompute` scope) headlessly tested; the `PlotWidget` feed + table render need a wgpu RenderState no test builds, so they are GPU/PlotWidget-unverified. |
| ✅ Done | M | M | Profile line width / method (mean vs sum) | tools/profile/rois.py:220-260, core.py:204-270, image/bilinear.pyx:391-466 | Wave 7: `ProfileMethod{Mean,Sum}` + `aligned_profile_values(.., position, roi_width, horizontal, method)` ports silx `_alignedFullProfile` (band of `roi_width` pixels, silx start/end placement, mean÷band or sum) for H/V; `rect_profile_values(.., method)` reduces the rectangle band by mean or sum; `line_profile_band(.., linewidth, method)` + `bilinear_sample` port silx `BilinearImage.profile_line`/`c_funct` for free lines (perpendicular bilinear band of `linewidth` px, mean/sum, edge-clamp interpolation, strict band bounds). All three extractors data-layer complete + tested against silx expectations. `340b80c` wires the UI width/method selectors (ProfileWindow Width DragValue + Mean/Sum ComboBox) to the extractors (row 232). Window UI GPU-unverified |
| ✅ Done | M | M | Fit range-selection UI (xmin/xmax) | FitWidget.py:336-361 | `ac45cd8`: a "Fit range" checkbox seeds `fit_range` from the finite x-extent (`default_fit_range_of`) and exposes xmin/xmax DragValues. Deferred: interactive on-plot range-drag (panel numeric UI only). Panel GPU-unverified |
| ◐ Partial | M | M | PositionInfo snapping to nearest item | tools/PositionInfo.py:179-292 | Wave 7: `snap_to_nearest(cursor_px, candidates, threshold_px) -> Option<Snap>` ports the silx picking kernel (:236-292) — global-nearest point within `threshold²` (silx `<=`, ties to later, non-finite skipped), `SNAP_THRESHOLD_DIST = 5` logical px. Pure pixel-space math, unit-tested. The snapping-mode candidate selection is now also a pure core: `SnappingMode` (the silx `SNAPPING_*` bitfield, `|`-combinable) + `snapping_candidates(mode, &[SnapItem]) -> Vec<usize>` port `_updateStatusBar`'s item selection (:196-244) — `CURVE`/`SCATTER` engage gate, `ACTIVE_ONLY` vs all-items (with silx's real asymmetry: histograms join CURVE only in the all-items path, and active-only ignores the visible filter), and `SYMBOLS_ONLY`. 6 headless tests. Remaining ◐ (live plot/GPU state): wiring `snapping_candidates`→data→pixel projection (via `Transform`)→`snap_to_nearest` into the live `PositionInfo`, plus the red/normal label styling on the snap/no-snap result |
| ✅ Done | M | M | FitWidget UI extras (editable params/constraints/peak-search/bg) | silx.gui.fit.FitWidget | Results table renders. **Background-model selector now wired**: `fit_widget.rs` `BACKGROUND_CHOICES` combo (silx `bgtheories.THEORY` order) → `core::fitting::fit_peak_with_background` (estimate the silx background theory, subtract, fit the peak on the residual, redraw the `background + peak` total; composes rows 254/255/256). The `Background::None` path is byte-identical to the prior fit. DEVIATION from silx: silx refines the analytic-background coefficients (Constant/Linear/Polynomial) *simultaneously* with the peak in one `leastsq` (`is_background=True` theories concatenated into one parameter vector); here the background is estimated once and held fixed — a free analytic-background constant would be collinear with siplot's single-`PeakModel` baked-in trailing bg parameter (singular covariance). Removing that baked-in constant for a faithful simultaneous fit is a `PeakModel` redesign tracked separately. 6 headless tests (`None`≡plain fit byte-identical, constant/linear peak recovery, `BACKGROUND_CHOICES` silx order, `background_label` round-trip+fallback, bad input). Combo GPU-unverified. **Multi-peak UI now wired**: `FitModelChoice::MultiGaussian` (silx `sum_gauss` theory) dispatches to `core::fitting::fit_multi_gaussian_full` — auto peak search seeded by `guess_fwhm` (row 256 cores), fit all peaks at once, packaged as an `IterativeFitResult` with per-peak `Height i`/`Center i`/`FWHM i` names + covariance so the generic results table renders N×3 params/errors with no special-casing. The background combo does not apply to it (the multi-gaussian model has no per-peak constant; silx `StripBackgroundFlag` off by default). 1 headless test (names/errors/curve align + both peaks recovered); combo GPU-unverified. **Per-parameter constraints UI now wired**: `core::fitting::fit_peak_constrained` fits a single `PeakModel` under a `Constraint` vector via `leastsq_constrained` (row 255); FitWidget keeps a per-model `constraints` vector (`ensure_constraints_len` clears it to all-`Free` on model change, silx-faithful) + a collapsing per-parameter constraint editor. `perform_fit_choice`'s no-background single-peak path has a 3-way precedence: all-`Free` → original `IterativeFit` (byte-identical), else → `fit_peak_constrained`; a selected background still takes precedence and ignores constraints. The constraint combo now exposes the **full** silx per-parameter code set `FREE`/`POSITIVE`/`QUOTED`/`FIXED`/`FACTOR`/`DELTA`/`SUM` (`UI_CONSTRAINT_KINDS` = silx `Parameters.code_options` minus the group-only `ADD`/`SHOW`/`IGNORE`, which a single-model fit has no analogue for): picking `QUOTED` reveals min/max `DragValue`s, and `FACTOR`/`DELTA`/`SUM` reveal a reference-parameter picker + the multiplier/offset/sum field. The selection logic is a pure, headlessly-tested seam — `constraint_kind` (code of an existing `Constraint`), `make_constraint` (silx default-seeding: `QUOTED [0,1]` placeholder, `FACTOR` ×1 / `DELTA` +0 / `SUM` 0 to the best reference), and `default_related_reference` (silx `getRelatedCandidates`: first other parameter not itself tied/ignored; `None`→selection rejected like silx `setCodeValue`). All seven feed the existing `fit_peak_constrained`/`fit_peak_from` → `leastsq_constrained` path (row 255). 5 fit-widget headless tests (`code_options` labels, payload-drop, default-seeding, no-candidate rejection, reference-skips-tied) on top of the core constraint tests (all-free recovers, FIXED holds bit-identical, count-mismatch rejects); editor widgets GPU-unverified. **Editable initial-param input now wired**: `core::fitting::fit_peak_from` is the single owner of the constrained-fit-from-explicit-`p0` path (`fit_peak_constrained` estimates `p0` then delegates); FitWidget keeps an editable `initial_params: Option<Vec<f64>>` (populated from each fit, reset on data/model change via `ensure_constraints_len` + `set_data`) shown as an `Initial` value column in the now-3-column `Parameters` table. The no-background single-peak precedence is now `(initial_params, all_free)`: `(None, true)` → original `IterativeFit` (byte-identical), `(Some p0, _)` → `fit_peak_from` (start from edited values), `(None, false)` → `fit_peak_constrained`. 1 headless test (`fit_peak_from` recovers from an off `p0`; estimate-seeded ≡ `fit_peak_constrained`); table GPU-unverified. The prior residual (`QUOTED` min/max + `FACTOR`/`DELTA`/`SUM` editor fields) is now closed — the full constraint editor is wired (see the constraints paragraph above) |
| ✅ Done | L | S | Profile cross-profile dual-curve display | tools/profile/rois.py:663-700 | `profiles_for_roi` returns a `Vec<ProfileCurve>`; a `Roi::Cross` yields BOTH the horizontal (row-through-cy) and vertical (col-through-cx) profiles, drawn together as two legended curves ("h profile"/"v profile") in one `ProfileWindow` (silx `ProfileImageCrossROI`'s hline+vline sub-ROIs). `curve_handles: Vec` rebuilds when the curve count changes. Extraction tested; window GPU-unverified |
| ✅ Done | L | L | Profile over stack (slice across 3D dim) | tools/profile/rois.py:1058-1165 | New `high_level`: `StackProfile{frame_count,profile_len,values}` (row-major `[frame, position]`) + private `stack_profile_with` core (profiles every frame via `stack_frame`, requires a uniform per-frame `profile_len`, `None` on mismatch/empty) behind pub `stack_aligned_profile` (band profile per frame via `aligned_profile_values`; silx `ProfileImageStackHorizontalLineROI`/`...VerticalLineROI`) and `stack_line_profile` (line-segment profile per frame via `line_profile_values`; silx `ProfileImageStackLineROI`). 4 headless tests; profile-window display GPU-unverified |
| ✅ Done | L | L | Fit background subtraction (const/linear/poly/strip/snip) | silx.math.fit.bgtheories.py + filters strip.c/snip1d.c | New `core::background`: `strip_background` (Jacobi peak-stripping, anchors + border-preserve, faithful to `strip.c`), `snip_background` (descending-window min clip, `snip1d.c`; silx applies no LLS), `polyfit`/`poly_eval` (numpy.polyfit/poly1d order via normal equations). `Background` enum {None, Constant=min(y), Linear, Strip{w,niter,factor}, Snip{w}, Polynomial{deg}} mirrors silx `bgtheories.THEORY` + defaults (StripWidth 2 / Iter 5000 / factor 1.0; SnipWidth 16; Linear/Poly fit on the strip background per `EstimatePolyOnStrip`). `compute`/`subtract` return the background curve / peak residual. 13 headless tests; wired into FitWidget via `fit_peak_with_background` + the background combo (row 251) |
| ✅ Done | L | L | Fit parameter constraints (POSITIVE/FIXED/QUOTED/…) | fitmanager.py:421-430 + leastsq.py (CFREE..CIGNORED) | `core::fitting::leastsq_constrained` + `Constraint` enum {Free, Positive, Quoted{min,max}, Fixed, Factor{ref,factor}, Delta{ref,delta}, Sum{ref,sum}, Ignored} port silx's full constraint-code path: free-set classification with `derivfactor` (QUOTED `B·cos` Jacobian scaling), per-step CPOSITIVE `abs` / CQUOTED `A+B·sin(arcsin+Δ)` reparametrisation, `get_parameters` dependent-relation expansion (Factor/Delta/Sum/Ignored), `noigno` model-input reduction, and the silx two-pass covariance (FIXED/IGNORED → zero row/col + value² diagonal) + `get_sigma_parameters` uncertainties (new `LeastSqResult.uncertainties`). The shared `chisq_alpha_beta_constrained` is reused by the fit and covariance passes. Unconstrained `leastsq` left byte-identical (sibling, shares `invert_matrix`). FitWidget wiring landed via row 251 (`fit_peak_constrained` + the per-parameter constraint editor, FREE/POSITIVE/FIXED subset exposed). 9 headless tests (all-free≡unconstrained, Fixed hold + 100% uncertainty, Positive enforce/recover, Quoted clamp/recover, Factor/Delta/Sum ties, bad-spec rejects). One deliberate deviation: a single constraint-expanded base evaluation for the forward difference (silx uses raw `model(*parameters)`), equal for clean in-domain inputs |
| ✅ Done | L | L | Fit multi-peak search | silx.math.fit.peaks (`seek`/`peak_search`) + fittheories `estimate_height_position_fwhm` + funs.c `sum_gauss` | Discovery primitive (`core::peaks`): `peak_search`/`peak_search_range` port silx C `seek` (2nd-derivative-of-Gaussian smoothing, significance state machine incl. doublet split; faithful incl. the init/loop gfactor off-by-one and the `data[0]=data[1]` quirk); `guess_fwhm` ports the strip-then-half-max width (floor 4). **Simultaneous fit now wired** (`core::fitting`): `multi_gaussian_model` ports `sum_gauss` (flat `(h,c,fwhm)` triples, no bg, `dhelp≤20` guard); `estimate_multi_gaussian` ports `estimate_height_position_fwhm` (peak_search seeds + `5·xspan/n` FWHM seed, ForcePeakPresence global-max fallback, a 4-iter preliminary constrained refine with centre quoted to ±½·search-fwhm width, then default-config final constraints height/FWHM Positive + centre Free); `fit_multi_gaussian` runs the full `leastsq_constrained` over all peaks at once. 12 headless tests (7 peaks + 5 multi-fit: model=Σ-singles, 2-peak/1-peak recovery, seed/constraint shape, empty/mismatch). FitWidget multi-peak UI is row 251 |
| ✅ Done | L | L | Fit non-peak theories (step up/down, slit, arctan) | fittheories.py THEORY + funs.c (`sum_stepdown`/`sum_stepup`/`sum_slit`), functions.pyx `atan_stepup` | `core::fitting` gains `stepdown_model`/`stepup_model`/`slit_model`/`atan_stepup_model` (erfc/erf edges byte-faithful to `funs.c`; `erf` via Abramowitz-Stegun 7.1.26, ≤1.5e-7, exact at 0) + estimators `estimate_step{up,down}`/`estimate_slit`/`estimate_atan_stepup` (silx edge-kernel convolution; slit centre from half-max crossings on a `min(y)` baseline). `PeakModel::{StepDown,StepUp,Slit,AtanStepUp}` flow through `IterativeFit`; `FitModelChoice` gains 4 iterative choices. silx has no exp/poly *fit* theory (poly is a background — row 254). 12 headless tests; FitWidget UI GPU-unverified |
| ✅ Done | L | L | Standalone RadarView overview widget | tools/RadarView.py:139-300 | `RadarView` is a public, exported, standalone-usable widget (`RadarView::new`/`ui`, geometry unit-tested) AND embedded in `ImageView` with the full silx binding (data-extent + viewport sync, drag → `set_limits`). The prior "no standalone widget" note was backwards: the widget exists; what had been deferred (the live-plot binding) has since landed. Paint GPU-unverified |
| ☐ Missing | L | L | Print preview dialog | PrintPreviewToolButton.py | No print-preview page with movable/resizable rect |
| ✅ Done | L | L | ItemsSelectionDialog reuse | ItemsSelectionDialog.py; actions/fit.py:237-241 | Dialog gained the silx fit-tool capabilities it lacked: `set_available_kinds` (silx `setAvailableKinds`, offers only the requested kinds) + `SelectionMode::Single` (silx `setItemsSelectionMode(SingleSelection)`, ≤1 selected enforced by one owner). `examples/high_level_fit_widget.rs` now reuses it as a single-select curve+histogram picker that feeds `FitWidget::set_data` (silx `_initFit`→`_setFittedItem`). Capabilities/invariant unit-tested; example wiring GPU-unverified |

### Top candidate next waves (re-baselined 2026-06-04)

1. ~~**ROI on-canvas styling + selection feedback**~~ — **DONE (Wave 13, main, not
   pushed).** Closed the top H cluster via the structural fix: `plot.rois` is now
   `Vec<ManagedRoi>` (geometry + appearance) = single source of truth, the one live
   `chrome::draw_rois` path resolves each ROI's color/name/selection/width/style/fill,
   and `RoiManagerWidget` was reworked to a stateless editor over that one collection
   (its second `Vec<ManagedRoi>` removed). Closed rows: per-instance color, name label,
   selection highlight, `sigCurrentRoiChanged`, line style, line width, manager
   default-color, fill toggle. *Still open (separate rows, not this wave):* ROI
   right-click context menu. (Handle-symbol customization, edge-position
   constraints, and EllipseROI orientation/rotation are since done.)
2. **Mask drawing tools + threshold/finite ops** (L, ~8 rows) — three H-priority
   Missing draw tools (Rectangle/Polygon/Ellipse) + threshold UI, invert/transparency/
   not-finite controls, Mask/Unmask toggle. Enum variants + `ImageMask` buffer +
   `MaskHistory` already exist (`mask_tools.rs`); this wave wires interaction + UI on
   done primitives. Biggest functional hole in the image workflow.
3. ~~**Structured item-click + hover signals**~~ — **DONE (Wave 14, main, not
   pushed) — click, hover, AND marker-drag triad.** Added item-identified
   `PlotEvent` variants `CurveClicked{handle,index,x,y,button}` /
   `ImageClicked{handle,col,row,button}` / `ItemClicked{handle,button}` (marker/
   scatter/shape) / `ItemHovered{handle,kind}`. `PlotWidget::show` routes this
   frame's `PlotPointerEvent` through one owner `pick_topmost(pos) ->
   (ItemHandle, PickResult)` (extends the prior `pick_topmost_item`), and the pure
   `click_event_for_pick(handle, &PickResult, button)` maps each `PickResult`
   variant to its event (unit-tested per boundary). `ItemHovered` was then
   expanded to the full silx `prepareHoverSignal` payload
   `{handle,kind,label,x,y,xpixel,ypixel,draggable}` (silx `selectable` omitted as
   structurally constant). Finally the marker-drag triad was added: the single
   owner `apply_interaction` (plot_widget.rs) surfaces `marker_drag_started`/
   `marker_drag_finished` on `PlotResponse` at the grab and release sites, and
   `show` emits `MarkerDragStarted`→`MarkerMoved`×N→`MarkerDragFinished` (silx
   `beginDrag`/`drag`/`endDrag`), covered by a headless press→move→release test.
   *Closed rows:* curveClicked/markerClicked/imageClicked (row 49) +
   hover-with-metadata (row 50) + ItemsInteraction picker/state-machine + marker
   drag-start/end triad + markerMoving/markerMoved split. **GPU
   boundary:** the end-to-end `pick_item` and the hover metadata assembly
   (`backend.marker`/`item_legend`) need a wgpu `RenderState` no test builds, so
   on-hardware picking is GPU-unverified; the pure `click_event_for_pick`, the
   headless marker-drag state-machine test, + the
   existing pure pickers are the headlessly-tested seam.
4. **ROI stats dock widgets** (L, 2 H rows) — `CurvesROIWidget` + `ROIStatsWidget`;
   the compute (`image_roi_stats`/`curve_roi_stats`) already exists, so it's widget
   construction + binding. Consumes the selection feedback from wave 3.
5. **StackView 3D depth** (L, ~5 rows) — perspective selection, 3D transposition,
   dimension labels, calibration, 3D-profile toolbar. Turns the 1D frame browser into
   a true volume browser. Self-contained in `image_stack.rs`.
6. **ColormapDialog completeness** (M, ~3 rows, 1 H) — Stddev3/Percentile from raw
   pixels (wire `Plot2D::get_image_pixels_raw` → `setHistogram`), histogram-distribution
   overlay, matplotlib-dynamic catalog. Localized to `colormap_dialog.rs` + a small
   Plot2D accessor.

The frozen as-of-sweep per-area tables below are kept only as the original reference;
the re-baselined view above supersedes their status columns.

## Progress log

### Wave 0 — cited detail
- ProfileWindow opens *beside* the main window (right → left → roomier edge,
  vertically centred), restores previous position on reopen. silx
  `ProfileManager.initProfileWindow`. Commit `3010677` (+4 boundary tests).

### Wave 1 — Colormap / Symbols / Mask model (parallel, worktree-isolated)
- **Colormap** (`c8b3794`..`1ab00de`): catalog → gray/reversed-gray/red/green/blue/
  temperature/jet/hsv; `Normalization::Arcsinh`; `AutoscaleMode` MinMax/Stddev3/
  Percentile(1,99); `Colormap::nan_color`; ColormapDialog wiring.
- **Symbols** (`22347ea`,`361a519`): silx symbol set — diamond, point, pixel,
  vertical/horizontal line, tick{left,right,up,down}, caret{left,right,up,down};
  char-code parsing matching silx.
- **Mask model** (`58e1416`..`f513506`): multi-level u8 buffer + level selector;
  rectangle/polygon/disk/ellipse fills; threshold below/between/above; invert;
  bounded undo/redo.
- Gate: clippy `--workspace` clean, **185 tests pass** (+41), doctests ok.
- **Deferred follow-ups** (cross-cluster wiring): arcsinh branch in `image.wgsl`;
  ColormapDialog Stddev3/Percentile need Plot2D raw-pixel access; Heart glyph SDF;
  mask on-plot drawing (Plot2D wiring) + Bresenham `draw_line` + mask file I/O.

### Wave 2 — Image render / ROI / Interaction / Complex view (parallel, worktree-isolated)
- **Image render** (`0494dc8`,`c6344d1`,`595c68e`): arcsinh image normalization
  (shader code-4 branch + `Normalization::Arcsinh`); `InterpolationMode`
  Nearest(default)/Linear (manual bilinear on scalar data before colormap, since
  R32Float is non-filterable); `AggregationMode` None/Max/Mean/Min + `aggregate_blocks()`
  (NaN-ignoring, remainder dropped, scale scaled by block — silx ImageDataAggregated).
- **ROI** (`bbd28ed`,`b909dbe`,`26e3fdc`): `Roi::contains()` for every variant; new
  Cross/Circle/Ellipse variants; chrome draws ROI color/name label/selected highlight;
  `RoiManagerWidget` per-ROI color/name, current-ROI tracking, add buttons.
- **Interaction** (`3030edd`,`4efac2d`,`122f7f1`): log-aware pan/zoom (silx `panzoom`);
  limits-history undo/redo stack (silx `LimitsHistory`); arrow-key pan (silx
  `PanWithArrowKeysAction`).
- **Complex view** (`ee3d82e`): `ComplexImageView` composite — ComplexMode
  Absolute/Phase/Real/Imaginary/SquareAmplitude/Log10Amplitude/AmplitudePhase, phase
  hsv LUT, amplitude×phase compositing.
- Gate: clippy `--workspace` clean, **239 tests pass** (+54), doctests ok.
- **Integration fix** (`3430ef5`): the complex-view cluster predated Wave 1's
  `Colormap::nan_color`, so its raw `Colormap {}` literal stopped compiling once both
  waves landed. Rebuilt `phase_colormap()` through `Colormap::new` + functional-update
  so future field additions no longer break the call site. Root cause: worktrees were
  cut from session-start HEAD, not current `main` — later waves must
  `git checkout -b waveN/<cluster> main` to inherit prior waves.
- **Deferred follow-ups** (cross-cluster wiring): interpolation/aggregation toolbar
  toggles; ROI creation-mode toolbar + on-plot draw; limits-history undo/redo buttons;
  ComplexImageView mode selector wired into a toolbar/menu (widget is self-contained).

### Wave 3 — ROI stats/styling / ColorBar+Alpha / Stats engine / ImageStack (parallel, worktree-isolated)
- **ROI** (`79822ac`,`9f17d0d`,`e112b63`,`b48175f`): handle geometry (`Roi::handles()` →
  RoiHandle{pos,kind}, `Roi::translate`, pure — no mouse wiring); ArcROI (annular sector,
  `rem_euclid` angle wrap) + BandROI (rotatable band) variants with `contains()`/handles/
  chrome draw; per-ROI line width/style(Solid/Dashed/Dotted, manual dash segments)/fill on
  ManagedRoi+chrome; ROI statistics module `roi_stats.rs` (`image_roi_stats`/`curve_roi_stats`
  — min/max/mean/sum/integral, NaN-skipping; silx ROIStats + CurvesROIWidget raw-count).
  Adding the two enum variants required 2 match arms in `high_level.rs` roi_description and
  `examples/roi.rs` (non-`#[non_exhaustive]` enum obligation, not new hub logic).
- **ColorBar/Alpha** (`c25a273`,`16c9c59`,`88877a9`): standalone `ColorBarWidget`
  (256-step gradient, vertical+horizontal, nice/decade ticks via silx `ticklayout.py`,
  `%g` end labels, rotated legend); `AlphaSlider` (0..=255 ↔ f32 alpha); Colormap utilities
  — custom-LUT (`with_lut` + Nx3/Nx4 resample to 256), `set_autoscale_percentiles`,
  `editable` flag guarding mutators (public field — silx `_editable` is public; required so
  FRU `..Colormap::new()` in complex_image_view still compiles), copy/set_from.
- **Stats** (`429f45b`,`4ad46dc`,`28a1574`): pure engine `core/stats.rs` — min/max/delta/
  mean/sum/COM/argmin-argmax-coords/integral, `StatScope::{All,OnLimits}` viewport clipping,
  curve-ROI x-range scope (silx `stats.py`); `StatsWidget` table (per-item rows, stat columns,
  auto/manual update, `%.7g` formatter); `PositionInfo` readout bar with pluggable converters
  (default polar). Built standalone — `high_level.rs`'s minimal ValueStats not yet delegated.
- **ImageStack** (`7ce78b3`): self-contained composite (mirrors ComplexImageView) browsing an
  in-memory `Vec<Option<Frame>>` — slider + first/prev/next/last, frame table with visibility
  toggles, waiting overlay for empty/hidden/size-mismatched slots; pure `FrameNav` core (clamp/
  step/visibility/labels) drives 22 tests. Lazy URL/HDF5 + prefetch out of scope (silx.io).
- Gate: clippy `--workspace` clean, **381 tests pass** (+142), doctests ok.
- **Deferred follow-ups** (need `high_level.rs`/`interaction.rs`, an actions wave): on-plot
  ROI editing/creation incl. Arc/Band per-handle drag + `Roi::move_edge` for the new kinds
  (no-op now); ROITable rich-stats columns wiring `image_roi_stats`; ColorBarWidget into
  ImageView/ScatterView/chrome; NamedItem/ActiveImage AlphaSlider (needs plot model);
  StatsWidget/PositionInfo bound to live items+cursor + PositionInfo snapping; delegating
  `high_level.rs` ValueStats to `core::stats`; ItemsSelectionDialog; ImageStack toolbar entry;
  promoting `RoiLineStyle` to the lib.rs convenience re-export.

### Wave 4 — Interaction draw-modes / Axis+datetime / Image-Marker-Shape items / Mask IO (parallel, worktree-isolated)
- **Interaction** (`c6e6fe3`,`60fe2af`,`9bacd18`,`2a059ce`,`5383c1b`): float32-safe
  pan/zoom clamping (silx `FLOAT32_SAFE_MIN/MAX`); new `PlotPointerEvent` (click/double-click/
  hover with button + data+pixel pos + limits tuples — kept distinct from `high_level`'s
  `PlotEvent`); `CursorShape` from draggable-edge detection; `DrawState` state machine
  (Rectangle/Ellipse/Line/H-V-Line/Polygon-with-snap-close/FreeHand → `DrawEvent::{InProgress,
  Finished}`, silx PlotInteraction Select*); selection-area fill modes (hatch/solid/none) +
  draw-mode rubber-band overlay painted in `plot_widget.rs` (not chrome). NaN bound clamps to
  lower (documented; keeps range finite/ordered, unlike numpy.clip).
- **Axis** (`9961a07`,`5052b4e`,`3957d98`,`da81279`,`7480498`,`ba4ef49`): `core/dtime_ticks.rs`
  (UTC-only DtUnit/bestUnit/calcTicks via days-from-civil, no chrono dep — silx
  `dtime_ticklayout.py`); per-axis autoscale + `DataRange` reset-zoom; `DataMargins` per-side
  ratios (log-aware); `axes_displayed` flag + `DirtyState{Clean,Overlay,Full}` lifecycle
  (state only); axis-label fallback to active-curve label; `grid_color` split from foreground.
  All additive — Plot is built only via `Plot::new`, so existing construction is untouched.
- **Items** (`dc18c84`,`3b53a6a`,`5d2836c`,`cf8c4fe`,`21c077d`): `ScalarMask` (per-pixel
  validity → NaN via the existing `nan_color` path; standalone since `ImageData` lives in
  `render/gpu_image.rs`); marker `is_draggable` + `MarkerConstraint`(H/V/custom, pure
  `apply_constraint`) + `drag`; `TextAnchor` alignment offset; `Shape::is_overlay`; infinite
  `Line` item with `clipped_segment(bounds)` (silx shape.py `Line.__updatePoints`).
- **Mask** (`b062e32`,`bc19767`,`233ccac`,`94f799a`): Bresenham pencil `draw_line` (width +
  degenerate, silx `shapes.draw_line`); hand-written NumPy `.npy` save/load with crop/pad +
  resize flag (no external crate); `mask_not_finite`; new `scatter_mask.rs` `ScatterMaskWidget`
  (per-point disk/polygon/rect masking, point-in-polygon from `Polygon.is_inside`, shared
  multi-level + undo/redo).
- Gate: clippy `--workspace` clean, **510 tests pass** (+129), doctests ok.
- **Deferred follow-ups** (actions/render wave): chrome wiring of `axes_displayed`/`grid_color`/
  datetime tick labels; render of `Line`/marker-drag/`Shape::is_overlay` data layer; `ScalarMask`
  applied before image upload; `DrawEvent::Finished` → ROI/mask creation; overlay-only replot
  short-circuit; per-pixel scalar alpha (needs shader); timezone for datetime axis; on-plot mask
  draw (plot→data coords) + mask colormap overlay + active-item sync; EDF/TIFF/HDF5/msk codecs.

### Wave 5 — Iterative fit / Scatter-viz algorithms / Export formats / RadarView (parallel, worktree-isolated)
- **Fit** (`f293ca1`,`5b84bff`): unconstrained Levenberg-Marquardt `leastsq` (forward numerical
  Jacobian, flambda damping, covariance via Gauss-Jordan inverse, reduced-χ² — silx
  `math/fit/leastsq.py`); peak models Gaussian/GaussianArea/Lorentzian/PseudoVoigt with analytical
  seeds (`fittheories.py`/`funs.c`); `fit_in_range(xmin,xmax)`; FitWidget `FitModelChoice` +
  results table (name·value·±error·reduced-χ²). Legacy estimate/linear fit kept (additive).
- **Scatter-viz** (`c8f0544`): pure `core/scatter_viz.rs` — Bowyer-Watson Delaunay; SOLID
  (per-vertex-colored `Triangles`); IRREGULAR_GRID (barycentric raster to image); regular-grid
  auto-detection + `GridMajorOrder`; `BinnedStatistic` mean/count/sum; per-point alpha. Render/
  picking wiring deferred. Note: IRREGULAR here = Delaunay-raster, not silx's quadrilateral mesh.
- **Export** (`5499da2`,`7abefe6`,`b225b68`,`0e9a258`): PPM (P6); SVG (base64-PNG `<image>` wrap);
  hand-written uncompressed baseline TIFF with DPI XResolution/YResolution tags; `SaveFormat` enum
  + extension auto-detect + `save_graph_with_format` dispatch (silx `saveGraph`/`PlotImageFile`).
  All encoders pure `encode_*(rgba,w,h,..)`; existing `save_graph`/PNG signature unchanged.
- **RadarView** (`5d8bede`): self-contained `widget/radar_view.rs` overview — full-extent box +
  draggable viewport rect, aspect-fit data↔widget mapping + inverse, clamp-to-extent, hit-test,
  emits new limits on drag (silx `tools/RadarView.py`). Live-plot pan wiring deferred.
- Gate: clippy `--workspace` clean, **584 tests pass** (+74), doctests ok.
- **Deferred follow-ups** (actions/render wave): wire scatter-viz outputs into the GPU triangle/
  image path + per-mode picking; FitWidget→live curve; RadarView→Plot2D pan + auto-extent; true
  vector SVG (record draw ops); JPEG/EPS/PDF; LM constraints + strip background + multi-peak search.

### Wave 6A — Chrome / input plumbing (model + primitives the HL wiring consumes)
First sub-wave of the chrome/actions effort. Single file-disjoint cluster owning `chrome.rs` +
`plot_widget.rs` + `interaction.rs` + `core/plot.rs` (the others would collide on `plot_widget.rs`,
which orchestrates chrome render + interaction together). Implemented by a worktree agent, then
each item adversarially verified (silx-fidelity + additive-only + gate); 2 fixes applied at source.
- **Datetime ticks** (`5ee3630`,`5477f69`): X-axis `TickMode` (Numeric/TimeSeries) on `Plot`; chrome
  `axis_ticks_with_mode` routes a TimeSeries linear X axis through `dtime_ticks::calc_ticks`/
  `format_ticks` (silx `XAxis.setTickMode`+`NiceDateLocator`). **Fidelity fix:** silx implements
  `setTickMode` on `XAxis` only (`YAxis` raises `NotImplementedError`, no `setYAxisTimeSeries`), so
  the time-series mode is X-only by API shape — dropped the symmetric `y_tick_mode` the first pass added.
- **Axes-hidden gutters** (`ac84ca0`): `ChromeRequest.axes_hidden` collapses every axis gutter to
  zero and skips frame/ticks/labels when `!axes_displayed()` (silx `setAxesDisplayed(False)`); colorbar
  strip still reserved (separate widget). Default false = no behavior change.
- **Infinite Line render** (`41eebb1`): `Plot.lines: Vec<Line>` + `add_line`; chrome `draw_lines`
  clips each via the tested `Line::clipped_segment` and paints the visible segment, called after
  `draw_shapes` (silx `items/shape.py` Line `__updatePoints`).
- **PlotPointerEvent / DrawEvent / mode surfacing** (`7d23f37`,`84289c7`): `apply_interaction` emits
  `PlotPointerEvent` (Clicked/DoubleClicked/Moved, pixel→data via transform; silx `prepareMouseSignal`)
  via additive `PlotResponse.pointer_event`; `draw_event` mirrors the latest draw event onto the plain
  show path; read-only `interaction_mode`. **Test fix:** added double-click (explicit headless
  timestamps) + right/middle-button coverage.
- Item 5 (ROI-edge cursor) was already complete on `main`; no no-op commit. Gate: clippy clean,
  **598 tests pass** (+14), doctests ok.
- **UNFIXED / deferred this sub-wave** (external dep or decision-gated): marker drag + marker-drag
  cursor (marker list lives in `Plot2D`, not core `Plot` — needs ownership decision); `Shape::is_overlay`
  data-layer (no under-chrome render path); `DirtyState::Overlay` short-circuit (render-loop wave);
  high-level mouseClicked/markerClicked/curveClicked **consumption** (lands in `high_level.rs`, 6B).

### Wave 6B-1 — HL view wiring (ImageView side) + standalone widget files (2 parallel clusters)
Two file-disjoint worktree clusters; per-item adversarial verify; 1 fix applied at source.
- **standalone-widgets** (own files, disjoint from `high_level.rs`): ColormapDialog NaN-color picker
  (`503bc34`) + percentile-fields regression test (`b70acc9`); ComplexImageView `show_mode_toolbar`
  mode selector (`3837a68`, silx `_ComplexDataToolButton`); ImageStack public `show_toolbar` +
  `NavAction` first/prev/next/last/goto (`9600976`); mask `.npy` codec moved to one owner in
  `render::save` (`encode_mask_npy`/`decode_mask_npy`, numpy v1.0 uint8) + path-string save/load,
  `mask_tools` delegates (`3054459`); NEW `ItemsSelectionDialog` — per-kind filter + grouped
  selection, genuinely missing vs the flat example (`e21fdff`). Interactive controls factored into
  pure `*_ui` helpers for headless-egui tests.
- **hl-views** (`high_level.rs` sole writer): ColorBar column synced to colormap (`68b8eb7`);
  AlphaSlider→active image (`9d8f890`); interpolation/aggregation selectors (`700edf0`); PositionInfo
  bound to the live cursor via 6A's `PlotPointerEvent` (`50b0f0e`,`9f6eb92` test fix adds
  Clicked/DoubleClicked coverage); RadarView overview→pan/zoom (`c2f35ee`); profile tool (`29e645a`);
  optional pre-upload `ScalarMask`→NaN on `Plot2D` images (`8ef46c2`); `ValueStats` delegated to
  `core::stats` single source (`d2468ab`).
- Gate: clippy `--workspace` clean, **633 tests pass** (+35), doctests ok. All 14 items accepted by
  review except the PositionInfo test gap (fixed). No items deferred within this sub-wave.
- **Deferred to 6B-2** (need `high_level.rs` + `plot_widget.rs`/`interaction.rs` together, or raw pixels):
  ScatterView ColorBar/scatter-viz-dispatch/mask-panel; StatsWidget + FitWidget binding; Stddev3/
  Percentile autoscale from raw pixels (`Plot2D::get_image_pixels_raw`); mask mode-vs-pan
  (`PlotInteractionMode::MaskDraw`) + pencil-draw routing.

### Wave 6B-2 — HL view wiring (ScatterView side) + stats/fit binding + raw-pixel autoscale
Single `high_level.rs`-sole-writer cluster (6 items); per-item adversarial verify (all 6 accepted, 0 issues).
- ScatterView value ColorBar synced to colormap limits (`b03d76c`, silx `ScatterView.py:83-88`).
- ScatterView visualization-mode dispatch: new `ScatterVisualization` enum (silx
  `ScatterVisualizationMixIn`); POINTS = existing marker cloud (default, unchanged); IRREGULAR_GRID /
  REGULAR_GRID / BINNED_STATISTIC convert retained `(x,y,value)` via the Wave-5 `core::scatter_viz`
  primitives → `GridImage` → image path; `rebuild_visualization` is the single owner of the
  scatter-vs-grid item handle (`f6ee396`, silx `scatter.py:402-680`). SOLID deferred (shader).
- ScatterView mask-tools panel: embeds a `ScatterMaskWidget` sized to point count; level/clear/invert/
  undo/redo/threshold/disk/rect/polygon selections applied to the scatter (`2440965`, silx
  `ScatterView.py:116-122`).
- `StatsWidget` bound to live items via new `RetainedItemData` on `ItemRecord` (populated by the typed
  curve/image spec entry points); `feed_active_stats` recomputes from the active item through the one
  `core::stats` engine (`c2d1fa3`, silx `StatsWidget`).
- `FitWidget` bound to a live curve: `set_fit_target`/`set_active_fit_target` pull the curve's retained
  `(x,y)` and call `FitWidget::set_data`; images/non-curves rejected (`43cc1df`, silx `FitWidget`).
- Raw-pixel autoscale: new `Plot2D::get_image_pixels_raw`; `autoscale_active_image` computes Stddev3
  (mean ± 3·std) / Percentile bounds NaN-ignoring via `AutoscaleMode` and re-uploads the image colormap;
  `colormap_dialog.rs` untouched (`1be8f53`, silx `ColormapDialog.py:450-480`).
- Gate: clippy `--workspace` clean, **647 tests pass** (+14), doctests ok. `lib.rs` gained one additive
  re-export (`ScatterVisualization`). No foreign-file edits.
- Integration note: an agent's first Item-1 edits leaked into the MAIN checkout via a bare repo-root
  path (same failure mode as 6B-1); the agent reverted with `git checkout -- <file>` and redid the work
  in the worktree, but its recovery left a stray `git reset` that fast-forwarded `main` to the branch
  tip. Verified safe before recording: `main^{tree}` byte-identical to the adversarially-verified
  `wave6/hl-scatter-stats` tip, 6 commits linear on `8a0ee44`, diff touches only `high_level.rs`+`lib.rs`,
  full-workspace gate re-run green on the main checkout.
- **Deferred to 6C** (need `plot_widget.rs`+`interaction.rs`+a toolbar toggle): mask mode-vs-pan
  (`PlotInteractionMode::MaskDraw`) + on-plot pencil-draw routing. **Deferred (shader):** SOLID scatter
  visualization, per-point scatter alpha.

### Wave 6C-1 — HL actions module + toolbar wiring (rfd Save + arboard Copy)
New `src/widget/actions/{mod,control,io}.rs` mirroring silx `plot/actions/` *behavior* (not the Qt
QAction hierarchy — immediate-mode egui needs none). Added crates `rfd` (native Save dialog) + `arboard`
(clipboard) — user-approved. Single `high_level.rs`+`actions/` sole-writer cluster (7 commits + 4
review-fix commits); per-item adversarial verify.
- ShowAxis toggle (`set_axes_displayed`, silx `ShowAxisAction`); ColorBar show/hide on ImageView+
  ScatterView (silx `ColorBarAction`); CurveStyle cycle of the active curve's `LineStyle` (silx
  `CurveStyleAction` — divergence: silx cycles plot-wide line/points booleans, accepted); ZoomIn/ZoomOut
  about the view center at silx's 1.1 step (silx `ZoomIn/OutAction`); ZoomBack popping `Plot::
  limits_history` with reset-zoom fallback (silx `ZoomBackAction`).
- Save (silx `SaveAction`): `rfd` native dialog → figure PNG (existing `save_graph`, GPU-readback shim)
  or active-curve CSV; `SaveTarget` extension→format + `curve_to_csv` are pure/tested. Copy (silx
  `CopyAction`): figure RGBA→`arboard::ImageData` via pure `decode_png_to_rgba`/`rgba_to_clipboard_image`,
  clipboard call is a shim.
- CurveStyle is backed by a new `RetainedItemData::Curve` holding the full `CurveData` (so the cycle
  preserves color/symbol/width/errors/fill); `lib.rs` gained `actions` + `SaveTarget`/`curve_to_csv`
  re-exports.
- Adversarial review found 4 fix-needed; all fixed at source on `main` as one-commit-per-finding:
  (A) colorbar-toggle observable-effect test (`e748a7d`); (B) toolbar zoom must NOT push limits history —
  silx pushes only from the drag-zoom interaction, removed the `apply_zoom` push (`5664ac8`); (C) zoom on
  a log axis must scale in `log10` space — added silx `scale1DRange`'s log branch + per-axis log threading
  + geometric log-midpoint, float32 clamp left to its separate item (`f2aeeae`); (D) curve CSV must emit
  true C/numpy `%.18e` (`e+00`) not Rust's `e0`, with a non-tautological test cross-checked vs Python
  (`08d9fe8`).
- Integration note: the agent's leak-check passed once after the first commit but later edits still leaked
  an uncommitted partial mirror into the MAIN checkout (missing the final `lib.rs` lines). The committed
  branch was authoritative; `reset --hard wave6/hl-actions` discarded the leak and fast-forwarded `main`
  to the verified tip (tree-identical), then the 4 fixes landed on top.
- Gate: clippy `--workspace` clean, **665 tests pass** (+18), doctests ok.
- **Deferred to 6C-2** (need `plot_widget.rs`+`interaction.rs`+`actions/mode.rs`): mask mode-vs-pan
  (`PlotInteractionMode::MaskDraw`) + on-plot pencil-draw routing. **Deferred to Wave 7:** Print action,
  median-filter + pixel-histogram actions, SVG/PPM/TIFF figure save (needs a public RenderState/format
  save path), per-axis X-only/Y-only autoscale toggles.

### Wave 6C-2 — Mask draw mode + on-plot pencil routing (final Wave-6 sub-wave)
Single worktree-isolated cluster owning `plot_widget.rs` + `actions/mode.rs` (NEW) + `high_level.rs`
(3 commits); per-item adversarial verify. BASE `79583e3`.
- `PlotInteractionMode::MaskDraw` variant — silx's dedicated pencil draw interaction, distinct from
  pan/zoom: primary-drag is reserved for mask painting. `apply_interaction`'s `== Pan`/`== Zoom`
  comparisons already suppress primary-pan and box-zoom for the new variant; the one primary-drag path
  NOT gated by an `== mode` check (ROI-edge grab, was `mode != Pan`) became `mode_grabs_roi_edge(mode)`
  excluding Pan AND MaskDraw, applied at the grab site and the hover resize-cursor site. Secondary-drag
  pan + wheel zoom intentionally left intact (matches silx draw interaction). NO enum-leak: every
  `PlotInteractionMode` use is an `== Variant` comparison (no exhaustive match anywhere), so the variant
  forced zero match-arm edits — clippy `--all-targets` (compiles examples) confirms.
- `actions/mode.rs` (NEW): `zoom_mode`/`pan_mode`/`mask_draw_mode`/`select_mode` — thin one-transition
  setters over `PlotWidget::set_interaction_mode`, mirroring silx `actions/mode.py` `ZoomModeAction`
  (`mode.py:45`) / `PanModeAction` (`mode.py:108`). silx has no `MaskModeAction` (`MaskToolsWidget` owns
  its pencil draw mode), so `mask_draw_mode` is a port-specific setter grouped with the others. `lib.rs`
  re-exports the four; `actions/mod.rs` gains `pub mod mode`.
- ImageView mask painting: embedded a `MaskToolsWidget` (resized to the active image on `set_image`; a
  shape change resets undo history = silx `reset(shape)`). `ImageView::show` routes the captured
  `PlotResponse` to the EXISTING Wave-4 `handle_interaction` strictly gated on `MaskDraw`
  (`image_view_should_paint_mask` — pan/zoom/select never paint); on change `upload_image` re-uploads with
  the painted level buffer → `ScalarMask` (`scalar_mask_from_level_buffer`, oversize lazily clipped to the
  image shape) applied as NaN-holes — the identical 6B-1 pre-upload representation (silx `getValueData`,
  `items/image.py`). Toolbar `set_mask_draw` toggle enters/leaves `MaskDraw` via `actions::mode` (Pencil
  tool on entry, None+Zoom on exit) with pencil/eraser/brush-size/clear controls while active.
- Adversarial review found 1 fix-needed, fixed at source on `main` as one commit (`31815f7`): the reused
  Wave-4 `handle_interaction` painted an inline circular brush at the current cell each frame with no
  inter-frame memory, so a fast pencil/eraser drag left gaps — unlike silx (`MaskToolsWidget.py:848-876`)
  tracking `_lastPencilPos` + `updateLine`. **Structural fix** (remove the dual painting impl): route
  on-plot pencil/eraser through the existing faithful `update_line`/`update_disk` primitives with a
  `last_pencil_pos` anchor — `paint_pencil_point` interpolates a thick Bresenham line from the previous
  sample (silx `updateLine`, width = brush size) then stamps a disk of radius `brush_size/2` (silx
  `updateDisk`); `end_pencil_stroke` clears the anchor on release / new click (silx resets `_lastPencilPos`
  on `drawingFinished`); `reset_geometry` clears it so a stroke never interpolates across a geometry
  change. Two silx-matching side-effects of using the shared primitives: the brush is now radius
  `brush_size/2` (dots match the line thickness, was 2×), and the eraser clears only the current mask
  level (was any level), matching `updatePoints(mask=False)`.
- Gate: clippy `-p siplot --all-targets` clean (== `--workspace`, sole member), **672 tests pass** (+7:
  5 feature tests + 2 fix regression tests), doctests ok.
- **Deferred (shader, unverifiable without GPU):** mask colormap GPU overlay — the port renders masked
  pixels as NaN-holes (scalar pipeline `nan_color`) instead. Per-point scatter on-plot pencil draw (needs
  `scatter_mask.rs`, a non-owned file; `ScatterView::show_mask_tools` already covers whole-mask + threshold
  ops). **Wave 6 complete.** **Deferred to Wave 7:** Print action, median-filter + pixel-histogram actions,
  SVG/PPM/TIFF figure save (needs a public RenderState/format save path), per-axis X/Y-only autoscale.

### Wave 7A — Figure output: SVG/PPM/TIFF save + Print action
Single worktree-isolated cluster owning `src/core/backend.rs` + `src/render/backend_wgpu.rs` + `high_level.rs`
+ `Cargo.toml` (3 commits); per-item adversarial verify. BASE `2de55f6`. Recon found the figure-save
primitives already complete — this wave is wiring + one new dep.
- SVG/PPM/TIFF figure save (NO new dep): the four `encode_{png,ppm,svg,tiff}` encoders and
  `render::save::save_graph_with_format` (one GPU readback → centralized format match) already existed &
  were tested; only the UI entry point dropped non-PNG. Added `Backend::save_graph_with_format` (trait +
  the sole `WgpuBackend` impl — `rg` confirmed one impl, no headless backend) + a `PlotWidget` passthrough;
  `save_to_path` now routes every `SaveTarget::Figure(fmt)` through it at `DEFAULT_SAVE_DPI=96` (PNG stays
  byte-identical, same `encode_png`); `save_dialog` gained PPM/SVG/TIFF rfd filters. Faithful to silx
  `SaveAction._saveSnapshot → plot.saveGraph(filename, fileFormat)` (actions/io.py:225-242). SVG remains the
  intentionally raster-wrapped encoder (true vector export still deferred). Pure test: the extension→
  `SaveFormat` dispatch table (png/ppm/svg/tif/tiff→Figure, csv→CurveCsv, pdf/noext→None) asserted without
  a GPU; the readback + write are shims.
- Print action (user-approved `printers` crate, v2.3.0 — verified to build on macOS: wraps CUPS/winspool).
  `print_graph` mirrors silx `PrintAction.printPlot` (actions/io.py:809-846): silx renders the plot to a PNG
  (`_plotAsPNG`) and draws that bitmap onto the printer via `QPainter`/`QPrinter` (RASTER, not vector). Here
  the figure is rasterized to a process-unique temp PNG via the existing `save_graph`, then submitted to the
  default printer (`get_default_printer` → `print_file`); returns `Ok(false)` with no default printer.
  `ToolbarIcon::Print` + `ToolbarResponse.print` + toolbar button (Save-button pattern, result ignored).
  Pure test: the temp-path naming (`print_temp_png_path`); the GPU readback + printer submit are untested
  platform shims. `printers::PrintersError` has no `Display`, so its `.message` field is used in the error
  string; printer-submit errors reuse `SaveError::Readback` (internal, toolbar ignores it) to avoid a
  `SaveError` enum-variant fanout. Print preview / `QPrintDialog` printer-settings UI intentionally omitted.
- Both adversarial reviewers returned `accept`; the integration diff was ALSO reviewed by hand before the
  fast-forward (the verify reviewers had been handed absolute worktree paths as the `git diff` pathspec,
  which can silently empty the diff — fixed for 7B by reporting repo-relative paths). No findings.
- Gate: clippy `-p siplot --all-targets` clean (printers compiled), **674 tests pass** (+2), doctests ok.
- **Deferred:** true VECTOR SVG (re-emit geometry); PDF/PS/EPS/JPEG (matplotlib-only, `from_extension`
  rejects); print preview / printer-settings dialog (no egui `QPrintDialog`). **Wave 7B next:** per-axis
  X/Y autoscale toggles + the widget-reset flag-respect fix; median filter (crate) + pixel-intensity
  histogram.

### Wave 7B — Per-axis X/Y autoscale toggles + widget-reset structural fix
Single worktree cluster owning `high_level.rs` + `actions/control.rs` (2 commits); per-item adversarial
verify (both accept, `structuralNotPatch` true). BASE `ec1b854`. `core/plot.rs` untouched — the model was
already complete.
- **Structural fix (the real bug):** the per-axis autoscale flags `x/y/y2_autoscale` (all default `true`)
  were honored by the model owner `Plot::reset_zoom_to_data_range` (tested) but the WIDGET reset path
  (`apply_limits_from_data_bounds`) **bypassed** them — it called `set_limits_internal` writing both axes
  unconditionally, so the flags were dead at the widget level (two reset paths, one flag-blind). Fixed by
  the PREFERRED single-owner delegation: a new pure `data_range_from_bounds(DataBounds)→DataRange` (per-axis
  `as_non_degenerate`, `None` for a dataless axis) feeds `reset_zoom_to_data_range`, with the `LimitsChanged`
  event preserved (`limits_snapshot` + `push_limits_changed_if`). Verified safe: `WgpuBackend::set_limits`
  only assigns `plot.limits`/`plot.y2` — the same two fields the model owner writes — and the event is the
  only widget-side bookkeeping, so delegation regresses nothing. Default case (all flags on + zero margins)
  is byte-identical to the old path; the only behavior change is the bug fix (off-axes now pinned) plus
  non-zero `data_margins` now applied on reset (matches silx `_forceResetZoom`, zero by default). The flag/
  log-force/margins logic now lives in exactly ONE place. Faithful to silx `PlotWidget.resetZoom`
  (PlotWidget.py:3352-3403). Pure tests: x-off/y-on keeps X refits Y; converse; all-off no-op; degenerate
  padding.
- Per-axis X/Y autoscale toolbar toggles: `ToolbarIcon::AutoscaleX/AutoscaleY` (+ `draw_autoscale_icon`
  glyph; the only exhaustive `ToolbarIcon` match got the two arms, `size()` uses a wildcard), checkable
  buttons (selected = `plot().x_autoscale()`), `ToolbarResponse.autoscale_x_changed/autoscale_y_changed`,
  + pure `toggle_x_autoscale`/`toggle_y_autoscale` in `control.rs`. Mirrors silx `XAxisAutoScaleAction`/
  `YAxisAutoScaleAction` (control.py:172-223) `_actionTriggered(checked)`: `setAutoScale(checked)` then
  reset-zoom ONLY on enable (disable pins the current view); the reset routes through the now-flag-aware
  widget path. Pure tests: enable refits only the enabled axis; disable does not reset.
- Gate: clippy `-p siplot --all-targets` clean, **680 tests pass** (+6), doctests ok (no doctest changed).
- **Deferred:** Y2 (right-axis) autoscale toolbar button (model flag already honored; a third button would
  bloat the toolbar row). **Wave 7C next:** median filter (`medians` crate) + pixel-intensity histogram.

### Wave 7C — Median filter + pixel-intensity histogram (FINAL substantive Wave-7 sub-wave)
Single worktree-isolated cluster owning new `src/widget/actions/analysis.rs` + `high_level.rs` +
`actions/mod.rs` + `Cargo.toml` (3 commits); per-item adversarial verify (both `accept`, no issues). BASE
`3d27137`. The pure compute is the tested deliverable; the kernel/conditional popup, the histogram window,
and the GPU image re-upload are UI/GPU shims. Agent read silx `silx.math.medianfilter` (`.pyx` defaults +
the C++ `median<T>()` in `include/median_filter.hpp`) and `actions/{medfilt,histogram}.py` for ground truth.
- **Median filter** (user-approved `medians` v3.0.12, build-verified on macOS): pure `median_filter_2d(data,
  w, h, kernel_h, kernel_w, conditional)` + `median_filter_1d` (= 2D with kernel `(kw, 1)`, silx
  `MedianFilter1DAction`) in `analysis.rs`. Faithful to silx `medfilt2d` for the **default `mode='nearest'`**
  that `MedianFilterAction` relies on (calls `medfilt2d` with no `mode`): out-of-bounds window indices clamp
  to `[0, dim-1]`; window median = `sorted[n/2]` (`std::nth_element` semantics — the *higher* of two
  centrals, not an average). Odd NaN-free windows use the crate's `Medianf64::medf_unchecked`; even/NaN-
  reduced windows use `select_nth_unstable_by(n/2)` because the crate's even path AVERAGES the two centrals
  (would diverge from silx) — pinned by `nan_ignored_even_count_takes_higher_central` ({10,20}→20). NaN
  ignored in-window; all-NaN window→NaN. `conditional`: replace center only if it equals the window min or
  max (a NaN center is never an extremum, so it propagates). `apply_median_filter`/`_1d` (on `PlotWidget`)
  read `RetainedItemData::Image` and re-upload in place via the existing `update_image_spec` (silx
  `addImage(replace=True)`) — no non-owned file. `MedianFilterParams` (kernel_width odd, conditional;
  default 3×3) + `Plot2D::show_median_filter`/`show_median_filter_toolbar` popup (silx `MedianFilterDialog`;
  spinbox min 1 step 2 enforced by `force_odd`). 11 unit tests (salt-spike removal, sorted-middle interior,
  NEAREST corner clamp, constant image, conditional keep-vs-fix, 1D≡2D-(k,1), 1×1 identity, NaN cases).
- **Pixel-intensity histogram**: pure `pixel_intensity_histogram(pixels, n_bins) → Option<PixelHistogram>`
  (edges/counts/min/max/mean/std/sum/n_bins) in `analysis.rs`. Faithful to silx `PixelIntensitiesHistoAction`
  / `HistogramWidget._updateFromItem` (histogram.py:226-419): bins = `min(1024, floor(sqrt(finite_count)))`
  then `max(2, …)` (a `Some(n)` override also floored at 2); range = finite `(min,max)` (NaN/±inf excluded
  from range, counts AND stats); `last_bin_closed=True` (max lands in the last bin, interior `v` →
  `floor((v-min)·n_bins/range)`); degenerate `min==max` range enlarged exactly as silx (`(-0.01,0.01)` at 0,
  else sorted `(v·0.99, v·1.01)`); stats = `nanmean`/population `nanstd`(ddof=0)/`nansum`; `None` on all-not-
  finite (silx `reset`). `active_image_histogram` (on `PlotWidget`) reuses `get_image_pixels_raw`;
  `Plot2D::show_pixel_histogram`/`_toolbar` is a CPU/egui-painter shim (bars in silx `#66aad7` + stats +
  editable bin count, NO second GPU `Plot1D`). 9 unit tests (sqrt-floor bins, exact last-bin-closed counts,
  stats, non-finite exclusion, degenerate ranges, all-not-finite None, bins-floored-at-2, interior floor).
- **Self-review fix (caught in my own integration diff, not by the reviewers — 1 commit `d0b86a9`):** the
  branch added `ToolbarResponse.median_filter_applied`/`pixel_histogram_open` but **nothing ever set them** —
  the buttons live on `Plot2D`'s standalone `show_*_toolbar` methods (own bool returns), while the field-
  producing `show_toolbar_controls` is on `PlotWidget` (shared with curve-only `Plot1D`, which silx never
  shows image actions on). Removed the dead fields (their doc comments falsely claimed "this frame"
  semantics); the `Plot2D` toolbar methods remain the API. f64-vs-float32 note: silx casts to float32 before
  `Histogramnd`; this port stays f64 (spec says `pixels: &[f64]`) — binning algebra is exact, a boundary
  value can shift ≤ float32-epsilon vs silx.
- Gate (main, post-fix): fmt clean, clippy `-p siplot --all-targets` clean (medians compiled),
  **700 tests pass** (+20: 11 median + 9 histogram), no doctest changed (new fences are ```ignore).
- **Deferred:** non-default silx median edge modes (reflect/mirror/shrink/constant — `MedianFilterAction`
  only uses `nearest`); silx `HistogramWidget` RangeSlider for custom range, weighted (count·value)
  histograms, and the embedded full `Plot1D`. **Wave 7 is now complete** (7A/7B/7C). Remaining backlog =
  the shader-gated items (SOLID scatter-triangle pipeline, per-point scatter alpha, mask colormap GPU
  overlay — NaN-holes) + marker drag/cursor + `is_overlay` data-layer (render-refactor) + true vector SVG.

### Wave 8 — Shader-gated GPU cluster (4 sequential sub-waves, all share the `high_level.rs` long-pole)
Verification boundary for this wave: WGSL is now **statically** validated headlessly via `naga` (8A) — the
prior "WGSL RUNTIME-UNVERIFIED (no naga CLI)" caveat is partly obsolete — but actual rendered pixels, GPU
alpha-blend, pipeline creation, and the per-vertex marker DRAW remain **GPU-only UNVERIFIED** on this machine
(Metal, no headless rasterizer). CPU geometry/data/LUT math is fully unit-tested. Each sub-wave = one
worktree-isolated implement + parallel adversarial verify; ff-merged to `main` preserving verified SHAs.
- **Wave 8A — headless naga WGSL validation gate** (`c253950`, 707 tests). New test-only `src/render/shaders.rs`
  (registered `#[cfg(test)] mod shaders;` in `render/mod.rs`): `validate_wgsl(name, src)` =
  `naga::front::wgsl::parse_str` + `Validator::new(ValidationFlags::all(), Capabilities::all())`, one `#[test]`
  per shader (clear/curve/errorbars/fill/image/image_rgba/markers) via `include_str!`. **Zero new deps** —
  `naga` is re-exported through `egui_wgpu::wgpu`. A malformed shader now fails `cargo nextest` headlessly.
- **Wave 8B — per-point marker color + per-point scatter alpha** (`62bc335` + `baf1715`, 712 tests). Fixes the
  latent bug that the marker pipeline **dropped** per-vertex colors: `markers.wgsl` gains
  `use_vertex_color: f32` (consumes a `_pad` slot — stays 112-byte std140, guarded by
  `marker_params_std140_size_unchanged`) + `@binding(2) vcolors` storage buffer; `vs_main` passes
  `vcolors[min(inst, len-1)]` through a color varying; `fs_main` = `select(params.color, in.color,
  use_vertex_color > 0.5)` (regression-safe: flag 0 ⟹ old behavior). `gpu_curve.rs` adds the binding +
  shares the existing per-vertex color buffer. `high_level.rs`: `compose_per_point_alpha` (round-trips
  `to_srgba_unmultiplied` to avoid double-premultiply) + `ScatterView.alpha: Option<Vec<f64>>` +
  `with_alpha`/`set_alpha`/`clear_alpha`; silx three-stage `colormap.alpha · per_point.alpha · global.alpha`
  (`scatter.py __applyColormapToData rgbacolors[:,-1] *= __alpha`). **UNFIXED (out of scope, recorded):** the
  pre-existing `apply_curve_alpha` double-premultiply bug on the shared curve/line path (kodex 162cc1a8).
- **Wave 8C — SOLID scatter triangle surface** (`6575cef`/`e5cc70d`/`5fc178b`, 717 tests; `high_level.rs` only).
  `ScatterVisualization::Solid` rendered through the **existing CPU `epaint::Mesh` path** (no GPU shader
  needed — recon corrected the assumption). Extracted `point_colors(values, colormap, alpha)` shared by the
  Points + Solid arms (no drift); `ScatterView.triangles_handle` with the invariant *Some iff a mesh is
  displayed*; Solid arm calls `scatter_viz::solid_triangles`, `None` on <3 finite / collinear clears the
  handle and draws nothing (silx "Cannot display as solid surface" early-out). silx `scatter.py:610-625`
  `backend.addTriangles` GL Gouraud. Enum-leak match arms fixed (`Points | Solid => None`; explicit Solid arm).
- **Wave 8D — mask 256×4 LUT overlay** (`12a7607` + `e38226a`, then 3 review fixes; 724 tests). Replaces the
  flat single-color boolean overlay (which collapsed all 255 levels) with silx's faithful per-level LUT.
  `core/colormap.rs`: `mask_overlay_lut(base, overrides, selected_level, alpha) -> [[u8;4];256]` faithful to
  `_BaseMaskToolsWidget._setMaskColors` (base RGB → per-level override → `alpha/2` for all → full `alpha` at
  the selected level → `lut[0]=[0,0,0,0]` set LAST; float→u8 = `(x*256).clamp(0,255) as u8` truncation
  matching numpy `clip(c*256,0,255).astype(uint8)`). `mask_tools.rs::apply()` rewired to discrete
  `lut[level] → RGBA` (**Option B**: reuses `image_rgba.wgsl`, NOT a scalar+colormap image — the
  linear-filtering LUT sampler would blend adjacent levels and corrupt the selected-level alpha). New fields
  `alpha` (default 0.8 = silx slider 8/10) + `overrides: Vec<Option<[u8;3]>>`(256); setters
  `set_transparency`, `set_mask_colors(rgb, Option<u8>)`, `reset_mask_colors(Option<u8>)`; pure helpers
  `mask_overlay_rgba` + `overlay_z_value`; z = active-image z + 1 (silx `MaskToolsWidget.py:482`, fallback
  `_z=1`). `high_level.rs`: additive `add_rgba_mask`. Default overlay color corrected to silx
  `rgba("gray")` = `#a0a0a4` (160,160,164) (gui/colors.py:71), not `#808080`. **Post-integration review fixes
  (one commit per finding):** `71c52de` wrong silx gray (`#808080`→`#a0a0a4`); `33f1ed6` tautological z test →
  test coupled to the real `overlay_z_value` helper; `8d671ef` `reset_mask_colors` per-level fidelity (silx
  `resetMaskColors(level)`). UNVERIFIED (GPU): on-screen compositing, alpha blend, draw-on-top z-layering.
- **Wave 8 complete** (8A/8B/8C/8D), `main` @ `8d671ef`, 724 tests, full-workspace gate green. Remaining
  backlog = marker drag/cursor (needs ownership decision) + `is_overlay` data-layer + `DirtyState::Overlay`
  + true vector SVG export + `delaunator` robustness upgrade (separately approvable).

### Wave 9 — Legend right-click context menu (silx `LegendSelector`)
Single cluster, all in `high_level.rs` (the single-writer long-pole), one worktree-isolated implement +
two parallel adversarial reviews (both **accept**), ff-merged preserving verified SHAs + one review-fix
commit. Recon confirmed **all 7 actions were already primitively backed** (no backend/render/shader change):
`CurveData` carries `symbol: Option<Symbol>` / `line_style: LineStyle` / `y_axis: YAxis`, and `GpuCurve::draw`
(gated on `line_style.draws_line()`) and `draw_markers` (gated on `symbol.is_none()`) are separate CPU-side
draw calls re-synced by the existing `update_curve_data` path. Verification boundary: PlotWidget cannot be
constructed without a GPU `RenderState` (no crate test builds one), so the egui menu render, the right-click
interaction, the rename `egui::Window`, and the on-screen toggle/axis-move EFFECT are **GPU/UI-UNVERIFIED**
on this machine; the testable core — the pure style transforms — is fully unit-tested.
- **`f10185c` — per-curve style setters + lossless restore cache** (730 tests; 6 new pure-fn tests). Public
  `set_curve_y_axis(handle, YAxis)` (recomputes data bounds + auto limits like `remove`, since Left↔Right
  reassigns which Y/Y2 bounds the curve feeds), `set_curve_points_visible`, `set_curve_lines_visible`.
  Checkable Points/Lines are **lossless**: pure free fns `set_symbol_visibility` / `set_line_visibility`
  stash the visible `Symbol`/`LineStyle` in a UI-only restore cache (`ItemRecord.hidden_symbol` /
  `hidden_line_style`) so hide→show restores the exact variant (e.g. `Dashed`, not `Solid`), falling back to
  documented defaults (`Symbol::Point` / `LineStyle::Solid`) only on an empty cache. Tests assert exact
  variants + no-op-does-not-clobber + empty-cache-default boundaries. **No dual meaning:** the cache is never
  read by any render/bounds/legend path — `CurveData.symbol`/`.line_style` stays the single source of truth.
- **`fd81309` — legend context menu + rename popup + response reporting**. `pub enum LegendAction`
  (re-exported in `lib.rs` beside `LegendResponse`); `legend_row_response` now returns the row
  `egui::Response` so `show_legend` attaches `Response::context_menu` while holding `&mut self`. Curve rows
  (`PlotItemKind::Curve`) get the full silx `LegendListContextMenu` set — Set Active (disabled if already
  active), Map to Y Left / Map to Y Right (current axis disabled), checkable Points (`symbol.is_some()`) and
  Lines (`line_style.draws_line()`), separator, Rename, Remove — re-read from the record each frame so a
  reopened menu is current. Non-curve rows (Image/Scatter/Histogram/Mask) get a graceful subset (Set Active /
  Rename / Remove). Rename = silx `RenameCurveDialog` as an `egui::Window` (single-line `TextEdit` +
  Apply/Cancel, Enter applies, Escape/close cancels) driven by `PlotWidget.rename_state`. Each action
  self-applies and records `(handle, LegendAction)` in `LegendResponse.context_action`.
- **`71f5653` — review fix (one commit, structural):** the visibility setters wrote the restore cache back to
  the record *before* `update_curve_data`; a failed update would have desynced the cache from the unchanged
  drawn state. Now the cache is read by copy/clone and committed only on the update-success path (strong
  state transition: the side cache commits through a finalizer that runs only after the real transition lands).
- **Wave 9 complete**, `main` @ `71f5653`, 730 tests, full-workspace gate green (`-p siplot` == sole
  workspace member). The on-screen menu/rename/toggle render is GPU/UI-UNVERIFIED (see boundary above).

### Wave 10 — Active-curve highlight styling (silx `HighlightedMixIn` / `getCurrentStyle`)
Single cluster, all in `high_level.rs`; one worktree-isolated implement + two parallel adversarial reviews
(both **accept**), ff-merged preserving verified SHAs. **No review-fix commit was owed** — the two findings
were a verified harmless no-op and a GPU-blocked test gap (below). Recon confirmed no backend/render/shader
change: `CurveData`'s `color`/`width`/`line_style`/`symbol`/`marker_size`/`gap_color` are all normal per-curve
fields already pushed by the existing `backend.update_curve`, so the highlight is expressible purely as a
render-time style overlay. Verification boundary: PlotWidget needs a wgpu `RenderState` no crate test builds,
so the GPU push, the `set_active_item` revert/apply wiring, the public setters, and the on-screen thicker line
are **GPU/PlotWidget-UNVERIFIED**; the testable core (the pure merge) is unit-tested.
- **`d852b04` — highlight machinery + pure merge + state + setters + tests** (734 tests; 4 new pure-fn tests).
  `pub struct CurveStyle` (override style: `Option` color/line_width/line_style/symbol/symbol_size/gap_color,
  each `None` = inherit; re-exported in `lib.rs`). Pure free fn `current_curve_style(base, highlight,
  highlighted) -> CurveData` = silx `getCurrentStyle`'s per-field merge exactly (when highlighted, each style
  field = highlight's value if `Some` else base's; when not, base unchanged; data fields x/y/colors/fill/
  baseline/errors/y_axis pass through). State: `active_curve_style` default `{ line_width: Some(2.0), ..None }`
  (silx `DEFAULT_PLOT_ACTIVE_CURVE_LINEWIDTH=2` / `DEFAULT_PLOT_ACTIVE_CURVE_COLOR=None`) + `active_curve_handling`
  default `true`; getters `active_curve_style()`/`is_active_curve_handling()`; single-owner helper
  `sync_curve_highlight`; public setters `set_active_curve_style`/`set_active_curve_handling`. Tests assert
  exact per-field values (not-highlighted→base verbatim; default→only width 2.0 with all else inheriting;
  multi-field override with width inheriting). *(Commit-split note: the owner + public setters landed in this
  commit, not commit 2, because commit 1 would otherwise carry a private `current_curve_style` with no
  non-test caller → `clippy -D warnings` dead-code failure; `#[allow]` is banned. Net diff unchanged.)*
- **`d676c00` — wire the overlay into the automatic transitions**. **Invariant:** GPU render style of a curve
  == `current_curve_style(retained_base, active_curve_style, is-active-curve)`; the retained `record.curve_data`
  is ALWAYS the base (the owner pushes the resolved style to `backend.update_curve` only, never
  `set_record_curve_data`) — single source of truth, no dual meaning (both reviewers traced
  activate→update→deactivate: the base width is never corrupted). `set_active_item` reverts the previous curve
  to base + applies the highlight to the new one through the owner; `update_curve_spec` re-overlays when the
  updated handle is the active curve (so an update keeps the highlight), leaving non-active curves' base push
  unchanged. Gated strictly on `PlotItemKind::Curve` (silx highlights only `kind=='curve'`; Scatter/Histogram,
  though `is_curve_like`, are never highlighted).
- **Reviewer findings, no fix owed:** (1) MINOR — the setters resolve the target via `active_curve()`
  (`is_curve_like`) which is broader than the strict `==Curve` highlight gate; both reviewers confirmed
  harmless (the owner re-derives the strict gate and pushes an idempotent base for non-Curve handles, never a
  dual-meaning record write). (2) GAP — the "retained base never corrupted" invariant is verified by code
  tracing, not a headless test; reviewer 2 suggested it "could be tested without a GPU" but that is mistaken —
  `record_curve_data`/`set_active_item`/`update_curve_spec` are all PlotWidget methods and `PlotWidget::new`
  requires a `&RenderState` (live wgpu device) with no headless constructor, so the regression test is
  GPU-blocked like the whole widget layer. Recorded honestly rather than faked with a GPU-requiring test.
- **Wave 10 complete**, `main` @ `d676c00`, 734 tests, full-workspace gate green. The on-screen highlight
  render is GPU/PlotWidget-UNVERIFIED (see boundary above).

### Wave 11 — Marker drag + cursor (silx `DraggableMixIn.drag` → `setPosition`)
Cross-cutting cluster (6 source files + 3 examples); one worktree-isolated implement + two parallel adversarial
reviews (**both accept**, all six dimensions true), ff-merged preserving verified SHAs, then **one review-fix
commit** for a structural finding (below). **Key architecture:** the marker source of truth is
`WgpuBackend.items` (`BackendItem::Marker`); `plot.markers` is a per-frame, z-sorted **mirror** that
`sync_plot_items` rebuilds every frame, so a drag persists through a new inherent
`WgpuBackend::update_marker` (mirrors `update_image`), never by mutating the mirror alone. A parallel
`plot.marker_handles: Vec<ItemHandle>` (populated alongside `plot.markers` in `sync_plot_items`, same
length+order) bridges the handle-less mirror back to backend identity so `apply_interaction` (which holds only
`&mut Plot`) can report the dragged marker. Verification boundary: `PlotWidget`/`PlotView` need a wgpu
`RenderState` no crate test builds, so the grab/drag/precedence state machine, the cursor side-effect, the
`update_marker` round-trip, `set_marker_position`/`marker_position`, and the `PlotResponse`/event wiring are
**GPU/PlotWidget-UNVERIFIED**; the pure core is unit-tested.
- **`eb924e8` — persistence + handle plumbing + pure helpers + public API + tests**. `Plot::marker_handles`;
  `WgpuBackend::update_marker`/`marker` (inherent, no trait change); pure `Marker::pick` per-kind hit-test in
  `core/marker.rs` (backend `pick_marker` now delegates to it — dedup, not duplicate); pure
  `interaction::marker_at` (topmost **draggable** marker under cursor, rev/z-order, skips non-draggable) and
  `interaction::marker_cursor` (drag-DOF size cursor); `PlotEvent::MarkerMoved`; public
  `set_marker_position`/`marker_position` (silx `setPosition`/`getPosition`, constraint applied via
  `Marker::drag`). Unit tests: `Marker::pick` (in/out radius, on/off span for v/h-line), `marker_at`
  (topmost / skip-non-draggable / none-on-miss), `marker_cursor` (all 5 kind+constraint mappings).
- **`8f6318a` — wire the drag into `apply_interaction`**. A draggable marker under the cursor at drag-start is
  the **highest-precedence primary-drag consumer in every mode except MaskDraw**, pre-empting primary-drag pan,
  the ROI-edge grab, and box-zoom (all gated on `!marker_dragging`); the marker size cursor takes precedence
  over the ROI-edge cursor. Wheel zoom and secondary-drag pan are intentionally **not** gated (silx: only the
  left-button item-drag branch competes with primary pan/zoom). `PlotWidget::show` persists the dragged marker
  back to the backend item via `update_marker` and emits `PlotEvent::MarkerMoved`. *(Commit-split note:
  `PlotEvent::MarkerMoved` + the public API landed in commit 1, not 2, because commit 1's `set_marker_position`
  pushes the variant — an unknown variant would not compile; `#[allow]` is banned. The 3 examples gained a
  `MarkerMoved` match arm as a forced `--all-targets` compile consequence of the exhaustive enum.)*
- **`994c591` — review-fix (structural): key marker drag by stable handle, not mirror index.** **Finding
  (reviewer 2, non-blocking):** `MarkerDrag` stored a `usize` index into `plot.markers`, the per-frame z-sorted
  mirror; reusing that positional value across frames as the grabbed marker's identity is a dual meaning, so a
  mid-drag rebuild/reorder of the mirror could shift the index onto a different marker (bounds-checked, no
  crash, but wrong-marker move). **Structural fix (preferred over the index patch):** store the marker's stable
  `ItemHandle` in `MarkerDrag` and re-resolve the mirror index from it each frame (drag-apply + drag cursor); a
  marker removed mid-drag now no-ops instead of moving an adjacent one. **Anchor audit:** `RoiDrag.roi` is the
  same shape but **distinct** — it indexes `plot.rois`, the source of truth `sync_plot_items` never rebuilds or
  reorders (not a derived mirror), and is pre-existing (out of Wave 11 scope) → left as-is (see UNFIXED).
- **UNFIXED (pre-existing, out of scope):** `RoiDrag` still keys an in-progress ROI drag by `usize` index into
  `plot.rois`; robust today because that Vec is the truth and is not reordered per frame, but a handle/id-keyed
  `RoiDrag` would be the same structural improvement. Not touched (Wave 11 scope is markers; needs user OK).
- **Wave 11 complete**, `main` @ `994c591`, 741 tests, full-workspace gate green. The on-screen drag, cursor,
  `update_marker` round-trip, and event/response wiring are GPU/PlotWidget-UNVERIFIED (see boundary above).

### Wave 12 — On-plot ROI creation (all 11 shapes) + whole-ROI translate (silx `RegionOfInterestManager.start` → `setFirstShapePoints`)
One worktree-isolated implement + two parallel adversarial reviews (**both accept**, all six dimensions —
faithfulToSilx / additiveOnly / testMeaningful / precedenceCorrect / mappingComplete / unverifiedHonest — true),
ff-merged preserving the verified SHAs, then **one review-fix commit**. **Key architecture:** the `DrawState`
gesture machine (`DrawMode`/`DrawParams`/`on_press`/`on_move`/`on_release`/`preview`) and `show_with_draw`
already existed (Wave 4), so this wave is a thin *caller-side* `DrawParams`→`Roi` mapping (a pure, testable core
function) plus a `PlotInteractionMode::RoiCreate(RoiDrawKind)` mode that **reserves the primary drag** for the
draw gesture (the same shape as Wave 6C-2's MaskDraw). Unlike markers, `plot.rois` **is the source of truth**
(`sync_plot_items` never rebuilds or reorders it), so `RoiDrag` correctly keys by `usize` index into it — no
mirror/handle indirection is needed here. **Verification-boundary correction (vs the Wave-11 note):**
`apply_interaction` takes `&mut Plot` (a pure core type), so it *is* headlessly testable via
`egui::Context::default()` + `ctx.run_ui` — wgpu paint callbacks are recorded into the painter, never executed —
and the agent added 5 headless wiring tests (`run_mode_frame`) exploiting this. Only `PlotWidget::show` (needs a
wgpu `RenderState` no crate test builds) and the actual on-screen render stay GPU/PlotWidget-UNVERIFIED.
- **`7636cbd` — ROI-creation pure mapping (testable core).** `RoiDrawKind` (11 kinds, one per `Roi` variant);
  `roi_draw_mode(kind)->DrawMode`; `roi_from_draw(kind, &DrawParams)->Option<Roi>` (the silx `setFirstShapePoints`
  equivalent for all 11, incl. `arc_from_two_points`, a faithful port of silx `_circleEquation` /
  `_createControlPointsFromFirstShape` circle-fit); `RoiGrab { Edge(RoiEdge), Translate }` +
  `roi_grab_at(rois, transform, cursor, grab_px)->Option<(usize, RoiGrab)>` (handle-over-body, topmost-first);
  new `DrawMode::Point` + `DrawParams::Point { x, y }` (single click → `Finished` immediately, for Point/Cross).
  16 pure unit tests.
- **`c00ec50` — wire on-plot creation + whole-ROI translate into `apply_interaction`.** The `RoiCreate` block
  drives `DrawState` from temp memory, appends the finished `Roi`, **re-arms continuously** (silx default), and
  surfaces a live `roi_preview`; `RoiDrag` gains `RoiGrab::Translate` → `Roi::translate(delta)` alongside the
  existing edge-move. `mode_grabs_roi_edge` excludes `RoiCreate` (so a draw gesture is never stolen by an edge
  grab); new `mode_allows_marker_drag` keeps marker drag out of create mode. `Interaction.roi_created` /
  `roi_preview` added; preview rendered via `draw_overlay`. `run_mode_frame` test helper + 5 headless
  apply-interaction wiring tests.
- **`1512ed7` — public API + event + re-exports.** `PlotEvent::RoiCreated { index }` emitted in
  `PlotWidget::show` (mirrors `RoiChanged`); `set_roi_create_mode(kind)`; `lib.rs` re-exports `RoiDrawKind`,
  `RoiGrab`, `roi_draw_mode`, `roi_from_draw`, `roi_grab_at`, `arc_from_two_points`. *(Commit-split note:
  `RoiCreated` forced a `--all-targets` match arm in the 3 examples — exhaustive-enum consequence, same shape as
  Wave 11's `MarkerMoved`.)*
- **`7ff440d` — review-fix (R1.1): strengthen the Point ROI-create test.** The reviewer's literal suggestion
  (assert `roi_created == Some(0)` on the *press* frame) could be wrong — a no-move click likely fires `on_press`
  via `clicked()` on the *release* frame, so a frame-pinned assertion is fragile. Fixed instead with a
  frame-agnostic "reported **exactly once**" assertion (collect `roi_created` across both press and release
  frames, assert `== vec![0]`), which catches both a missing report and a double-create regardless of egui frame
  collapse. Verified passing.
- **Documented deviations (silx-cited, not guessed):** `Roi::Ellipse` now models `EllipseROI._orientation` (an
  `orientation` field; rotation geometry complete); X/Y bands use `Roi::VRange`/`HRange` (silx shows single-line `VerticalLineROI`/
  `HorizontalLineROI` as X/Y markers — distinct single-line kinds are still ☐); Arc/Band/Circle default geometry
  (widths, radii, angle conventions) read from silx source and cited in the mapping doc-comments.
- **UNFIXED (pre-existing, out of Wave 12 scope):** *(R2.1)* the `RoiDrag` apply block reads the in-progress drag
  from temp memory **unconditionally of mode**, so a mid-drag mode switch can apply a stale grab one frame; this
  is identical to `main`'s pre-Wave-12 behaviour (the same pattern the MarkerDrag/MaskDraw blocks use) and was not
  introduced here. Also carried from Wave 11: `RoiDrag` keys by `usize` index — safe because `plot.rois` is the
  truth, not a per-frame mirror, but a future id-keyed `RoiDrag` would be the uniform structural choice.
- **Wave 12 complete**, `main` @ `7ff440d`, 762 tests, full-workspace gate green (clippy `-D warnings` clean,
  doctest ok). The on-screen creation, preview overlay, continuous re-arm, and whole-ROI translate are
  GPU/PlotWidget-UNVERIFIED; the `DrawParams`→`Roi` mapping and the `apply_interaction` wiring are headlessly
  unit-tested.

### Post-Wave-12 commits (landed on `main`, unlogged until the 2026-06-04 re-baseline)
These shipped after the Wave-12 progress entry as individual one-commit-per-feature fixes (not run as a
labelled wave), so they were not in the Progress log; the 2026-06-04 re-baseline folds them into the Done count.
- **ROI handle-drag editing** for the kinds Wave-12 creation left ◐: `e9b046b` Rect corner/diagonal resize,
  `6b9e5d0` Band, `fc14807` Arc (incl. start/end-angle via `Vertex(2)`/`Vertex(3)`), `cac2edf` Circle/Ellipse
  regression tests; `2dbbb8e` cancels an in-progress ROI drag on a mid-drag mode switch (**closes the Wave-12
  R2.1 UNFIXED** — the stale-grab-one-frame window).
- **`cd0338d`** fixes the `apply_curve_alpha` double-premultiply on the shared curve/line path (**closes the
  long-standing cross-wave UNFIXED**, kodex `162cc1a8`).
- **Detached tool windows:** `4c39632` detached-window helper sharing placement maths with `profile_window`;
  `dea42fd` shows all floating tool windows as detachable native OS windows; `a8bb863` doc-example update.
- **Active-curve axis labels** (silx `_setActiveItem`): `2de1a8e` swap in the active curve's labels, `f2ce5b5`
  example; **right-click zoom menu** `b389721` (replaces double-click reset).
- **Rotated-label centering fixes:** `b7b62bd` off-center Y / clipped y2 labels; `fbe7f6e` wrong-sign vertical
  colorbar legend centering.
- **Curve legend icons** (silx `CurveLegendsWidget`/`LegendIcon`): `6fb9f33` line style + color + marker,
  `c0f38dc` example, `525dfac` legend in `high_level_active_curve_labels`, `d04232a` drop the icon bounding box.

### Re-baseline (2026-06-04, recon workflow `wf_b2b09dc2-620`)
The "Remaining work (live, code-verified 2026-06-02)" view was stale (frozen pre-Wave-10, before the commits
above). An 8-slice read-only recon (Explore agents, one per silx.gui.plot slice → synthesis) re-derived every
status from the code on `main`, ignoring the old status column. Result: **≈191 Done · 130 open** (92 enumerated
rows: 10 H / 48 M / 34 L). The new view replaced the 2026-06-02 section at the top of this file; the candidate
next-wave list is recorded there. Two prior cross-wave UNFIXED items were verified **closed** by the post-Wave-12
commits (`apply_curve_alpha` double-premultiply; ROI mid-drag stale-grab). One precision correction over the
recon's raw output: the H-priority "ROI per-instance color/label/selection on canvas" is recorded as a
**two-decoupled-collections** structural gap — `chrome::draw_roi` + `RoiAppearance` already render color
(chrome.rs:732), name (`:916`), and selection-width (`:736`), and `RoiManagerWidget` feeds real appearance
(roi_manager.rs:223-235), but the live plot render (`plot_widget.rs:353 → draw_rois`) passes
`RoiAppearance::default()` over a bare `plot.rois: Vec<Roi>`; the fix is to plumb/join `ManagedRoi` metadata into
the live render path, not to add new render code.

### Wave 13 — ROI on-canvas styling + selection feedback (unify the two decoupled ROI collections)
Done **inline** (not the parallel worktree method), 4 one-feature-per-commit landings on `main` (not pushed),
each passing the full per-crate gate (`cargo fmt --all`; `cargo clippy -p siplot --all-targets -- -D warnings`
clean; `cargo nextest run -p siplot`; `cargo test --doc -p siplot`). Closes candidate-wave #1 — the top
H-priority cluster — via the **structural fix the re-baseline named**: the root cause was two decoupled ROI
collections (`Plot.rois: Vec<Roi>`, rendered live with a hardcoded `RoiAppearance::default()`, vs
`RoiManagerWidget`'s own `Vec<ManagedRoi>` whose `draw()` was never overlaid), so per-ROI styling never reached
the interactive plot.
- **`3c614c8` — Unify ROI collections: `plot.rois` holds `ManagedRoi` as the single source of truth.** Moved
  `ManagedRoi`/`RoiLineStyle` from `widget::roi_manager` into `core::roi` (core may not depend on widget;
  re-exported from `widget::roi_manager` + `lib.rs` for path compat). `Plot::rois` is now `Vec<ManagedRoi>`;
  added `Plot::roi_color` (silx-default red) + private `current_roi`; the one live `chrome::draw_rois(painter, t,
  rois: &[ManagedRoi], default_color, style)` path resolves each ROI's appearance. **Single-owner invariant:**
  `Plot::set_current_roi` is the SOLE writer of every `ManagedRoi.selected`; `Plot::remove_roi` (remove + adjust
  index) and `Plot::clear_rois` (clear + `current_roi=None`) are the SOLE collection-shrinking mutators → "exactly
  the current ROI is highlighted; the current index never dangles" holds by construction. Bypass audit anchor
  `rg '\.rois\.(push|remove|clear)'`: push sites distinct (a new ROI is not auto-current); the two high-level
  remove/clear bypass sites routed through the owner. Adapted every `.roi` reader/writer (plot_widget render +
  edit-drag + creation + cursor, interaction `roi_grab_at`, high_level `add_roi`/`rois`/`rois_mut`/
  `show_roi_manager`, 4 examples). Tests: selection/removal invariant in `core::plot`.
- **`c60bae6` — high-level ROI styling API + current-ROI selection event.** `set_roi_color`/`set_roi_name`/
  `set_roi_line_width`/`set_roi_line_style`/`set_roi_fill` (handle-indexed), `add_managed_roi`,
  `current_roi`/`set_current_roi`; `PlotEvent::CurrentRoiChanged { previous, current }` emitted only when the
  selection actually changes (silx `sigCurrentRoiChanged`). *(Commit-split note: the new variant forced a
  `--all-targets` match arm in 3 exhaustive-match examples — same enum consequence as Wave 11/12.)*
- **`b29db3c` — rework `RoiManagerWidget` onto `plot.rois`; remove the second ROI collection.** The manager is now
  stateless beyond its window (`{ win, open }`): every control (current-ROI radio, color swatch, name, line
  width/style, fill, remove, the +shape and clear-all buttons) mutates the plot's single `ManagedRoi` collection
  via the owner methods, so edits render on the plot at once. Its own `Vec<ManagedRoi>`/`current`/`default_color`
  + dead `draw()` removed (−4 owned-state tests). `examples/high_level_roi_manager.rs` now seeds two styled, named
  ROIs (blue "feature A" rect; orange filled "spot" circle set current) in Select mode.
- **`7495590` — extract testable ROI appearance resolution.** Pure `chrome::roi_appearance(&ManagedRoi,
  default_color) -> RoiAppearance` (color fallback, name→Option, selected, width, style, fill) with a headless
  `mod tests` case — the GPU-render boundary's testable seam.
- **Verification boundary (reported honestly):** `PlotWidget::new` needs a wgpu `RenderState` no crate test
  builds, so the on-screen ROI pixels stay **GPU/PlotWidget-UNVERIFIED**. Headlessly tested = the core
  selection/removal invariant (`core::plot`) + pure `chrome::roi_appearance` resolution. Final gate: 781 tests
  passed / 0 failed, doctest ok (11 ignored), clippy `-D warnings` clean.
- **Wave 13 complete**, `main` @ `7495590` (NOT pushed). Per-crate scope only (`-p siplot` == `--workspace`,
  sole member), so the pre-push full-workspace pass is satisfied by the per-crate run.


## PlotWidget core, axes, frame, ticks  — 9✅ 0◐ 0☐

siplot has strong coverage of core axis features: linear/log/inverted axes, dual Y2 axis, axis labels with active-curve-label fallback (`resolved_axis_label`), grid modes (major/major+minor/none) with an independent grid color, nice-number tick layout with minor ticks, TIME_SERIES/datetime axes (DST-aware named IANA zones via `TimeZone`), per-axis autoscale (`set_x/y/y2_autoscale`, honored by `reset_zoom_to_data_range`), data-margin expansion ratios for reset-zoom (`DataMargins`, log-aware), axis-visibility toggle (`set_axes_displayed`), axis constraints, keep-aspect-ratio, and auto-zoom-to-data. The silx dirty/replot lifecycle is resolved-not-a-gap (siplot is immediate-mode with persistent GPU buffers; `DirtyState` is a faithful port, set not consumed). `Plot::data_range()` mirrors silx `getDataRange` and is kept live by `recompute_data_bounds` (every content change pushes the raw per-axis bounds into the cache), so this section is fully covered.

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Per-axis autoscale control (Axis.setAutoScale / isAutoScale) | `PlotWidget.py:2935-2957, items/axis.py:310-324` | `core/plot.rs` per-axis flags `x_autoscale`/`y_autoscale`/`y2_autoscale` (silx `Axis.setAutoScale`/`isAutoScale`) with matching getters/setters (`:596-625`); `reset_zoom_to_data_range` (`:842-875`) refits only axes whose flag is on, and forces autoscale on a log axis whose lower limit is ≤ 0 (silx). Tested `:1149`/`:1165`. |
| ✅ | H | S | Data range computation (getDataRange for bounds auto-expand) | `PlotWidget.py:908-918, resetZoom:3331-3350` | `core/plot.rs` `DataRange` (silx `_PlotDataRange`, each member `(min,max)` or `None`) + `data_range()`/`set_data_range()` cache (silx `getDataRange`); `reset_zoom_to_data_range` consumes a `DataRange`. The cache is kept live in `recompute_data_bounds` — the single content-change funnel (silx `_notifyContentChanged`→`_updateDataRange`): every add/update/remove pushes the raw per-axis min/max via `set_data_range(raw_data_range_from_bounds(..))`, so `Plot::data_range()` reflects the data on all paths instead of reading all-`None`. A single point reads as `(v, v)` un-padded, matching silx getDataRange; the non-degenerate span + data margins stay a refit-time concern in `data_range_from_bounds`. Tested: `raw_data_range_from_bounds_keeps_raw_bounds_unpadded`, `recompute_data_bounds_populates_live_data_range_cache`. |
| ✅ | M | L | TIME_SERIES / datetime axis tick mode (TickMode enum) | `items/axis.py:43-48, 296-308, _utils/dtime_ticklayout.py (entire file)` | `core/dtime_ticks.rs` ports the layout kernel: `DtUnit` (silx `DtUnit`), `best_unit` (`bestUnit`), `calc_ticks_tz`/`format_ticks_tz` (`calcTicks`/`formatDatetimes`), `DateTime` + `TimeZone` (Utc/FixedOffset). `core/plot.rs` `enum TickMode { Numeric, TimeSeries }` + `set_x_tick_mode`/`x_tick_mode` (silx `getXAxis().setTickMode`, X-only) + `set_time_zone`/`time_zone` (`setTimeZone`). Wired live: `plot_widget.rs:338 draw_axes_with_x_tick_mode` → `chrome.rs:283` formats datetime labels when `TimeSeries` on a linear axis (ignored on log, per silx). Tested (`chrome.rs:1552`, `plot.rs:1413`). |
| ✅ | L | M | Data margin ratios for resetZoom (setDataMargins / getDataMargins) | `PlotWidget.py:3251-3270, resetZoom:3352-3397 margin expansion logic` | `core/plot.rs` `DataMargins { x_min,x_max,y_min,y_max }` per-side ratios (silx `setDataMargins`) + `data_margins`/`set_data_margins` (`:653-661`); `DataMargins::expand_axis` expands in log space and skips non-positive log bounds (silx `addMarginsToLimits`); `reset_zoom_to_data_range` applies them only to refit axes (`:854-875`). Tested `:1452-1524` (linear/log/log-skip/refit-only). |
| ✅ N/A | L | M | Dirty flag and replot lifecycle (_setDirtyPlot, replot, autoreplot) | `PlotWidget.py:719-730, 3309-3311, 3279-3289` | Resolved W15 — not a gap. silx's `_dirty`/`replot`/`autoreplot` are a retained-backend deferral mechanism; siplot is immediate-mode (egui owns the frame loop) with persistent GPU buffers, so redraw is always-on and uploads are event-driven. `DirtyState` exists as a faithful port (set, not consumed). Not a correctness issue. |
| ✅ | L | M | Axis label fallback to active curve label | `items/axis.py:187-218 (setLabel, _setCurrentLabel; PlotWidget handles active curve label swapping)` | `core/plot.rs` `resolved_axis_label(default, active)` (`:190`) + `x_axis_label`/`y_axis_label`/`y2_axis_label` (`:764-777`): the active curve's per-axis label wins over the default, falling back when empty. Wired live — `high_level.rs` `active_axis_label_overrides`/`sync_active_axis_labels` (`:2170`/`:3174`) route the active curve's labels onto `Plot::active_*_label`, and `plot_widget.rs:405` renders `displayed_x_label()`. Tested `:1263-1293`. |
| ✅ | L | S | Axis visibility toggle (setAxesDisplayed / isAxesDisplayed) | `PlotWidget.py:2838-2855` | `core/plot.rs` `axes_displayed` flag + `set_axes_displayed` (marks the plot dirty; silx `setAxesDisplayed`/`isAxesDisplayed`, `:666-676`). Wired: `plot_widget.rs:277` reads it, `:286` sets `axes_hidden`, and `:382`/`:404` skip frame/ticks/labels when hidden (chrome `:111`). Tested `:1336`/`:1385`. |
| ✅ N/A | L | S | Overlay-only replot optimization | `PlotWidget.py:719-730 (dirty='overlay')` | Resolved W15 — not a gap. siplot is immediate-mode with persistent GPU buffers (data uploaded on add/update, not per frame); there is no per-frame full upload for dirty='overlay' to skip. Parity achieved differently. |
| ✅ | L | S | Separate foreground (axes/frame) and grid colors | `PlotWidget.py:setForegroundColor, setGridColor (separate calls); backends/_PlotFrameCore.py splits axis vs grid stroke` | `core/plot.rs` `foreground` and `grid_color` are independent `Option<Color32>` (silx `setForegroundColor`/`setGridColor`, `:381-384`/`:805-816`); chrome `Theme::with_overrides(fg, grid)` (`chrome.rs:52`) takes both and defaults the grid to a faint tint of the foreground when unset; `plot_widget.rs:331` passes `plot.grid_color` through, so axis/frame stroke and grid lines render with independent colors. |

## Interaction, events, panzoom, limits history  — 29✅ 0◐ 0☐

siplot implements the full pan/zoom interaction surface: pan and wheel zoom (both log-axis-aware — performed in log10 space with float32-safe clamps), box zoom, interaction-mode switching (select/pan/zoom), double-click reset, crosshair cursor, arrow-key panning, and an unbounded limits-history (zoom-back) stack. All silx draw modes are present — Rectangle/Ellipse/Line/HLine/VLine/Polygon/FreeHand/Point via `DrawMode`/`DrawParams` — with live previews. The signal surface is modeled as the `PlotEvent` enum (plus `PlotPointerEvent` for raw pointer events): mouseClicked/mouseDoubleClicked, hover (`ItemHovered` with kind/label/data+pixel position/draggable), drawingProgress/drawingFinished, limitsChanged (x/y/y2 tuples), curveClicked/imageClicked/itemClicked (markerClicked folds into the generic item-pick), and the full marker drag lifecycle. silx's `selectable` hover flag is the one documented omission (constant-true in siplot's active-on-click model). The silx interaction state machine (`StateMachine`/`ClickOrDrag`) is achieved differently via imperative `apply_interaction` dispatch + `DrawState` — resolved-not-a-gap.

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Pan respects logarithmic axes with proper math | `PlotInteraction.py:233-290` | `interaction.rs` `pan_axis` (`:42`) shifts a `Scale::Log10` axis in log10 space (`d_log = delta_px·(log_max−log_min)/extent_px`, silx `dx = log10(xData) − log10(lastX)`) and linearly otherwise; `pan` (`:79`) selects per-axis `x_scale`/`y_scale`. Non-positive log bounds fall back to a no-op. |
| ✅ | H | M | Wheel zoom respects logarithmic axes | `panzoom.py:80-119` | `interaction.rs` `scale1d_range` (`:100`) zooms a log axis in log10 space and clips to the positive float32 range (`FLOAT32_MINPOS`..`FLOAT32_SAFE_MAX`; silx substitutes `FLOAT32_MINPOS` for a non-positive bound); `zoom_about` (`:148`) passes `x_scale == Scale::Log10` per axis (silx `scale1DRange`). |
| ✅ | M | L | Limits history (undo/redo stack for zoom history) | `LimitsHistory.py:34-82` | `core/plot.rs` `limits_history: Vec<LimitsHistoryEntry>` (left + optional y2 ranges) pushed before each zoom/box-zoom/pan; `zoom_back` pops the most recent (silx `LimitsHistory`, unbounded stack). |
| ✅ | M | L | Draw mode (polygon, rectangle, ellipse, line, freehand, etc.) | `PlotInteraction.py:1648-1683` | `interaction.rs` `enum DrawMode` (Rectangle/Ellipse/Line/HLine/VLine/Polygon/FreeHand/Point) + `enum DrawEvent { InProgress, Finished }` (silx drawingProgress/drawingFinished) via `DrawState`; wired through `show_with_draw`/`feed_draw_state`, surfaced as `PlotEvent::DrawingFinished`. |
| ✅ | M | L | Polygon drawing interaction (select mode) | `PlotInteraction.py:485-621` | `interaction.rs` polygon mode: point-by-point vertices, snaps the last vertex to the first within `close_threshold_px` (silx onMove); close target rendered by `draw_polygon_first_point` (silx `updateFirstPoint`). |
| ✅ | M | M | Pan via arrow keys (left/right/up/down directional input) | `PlotInteraction.py (search PlotWidget for arrow handling)` | `interaction.rs` `apply_pan` + `enum PanDirection` (silx `applyPan`) for arrow-key panning. |
| ✅ | M | M | Zoom enforces float32 safety limits during zoom | `PlotInteraction.py:241-250, panzoom.py:44-47` | `interaction.rs` `FLOAT32_SAFE_MIN/MAX` (= silx ±1e37) + `FLOAT32_MINPOS`; zoom clamps linear and log limits into the safe range (`:53-135`) so span subtractions cannot overflow f32. |
| ✅ | M | M | Signal: mouseClicked with button, position (data and pixel coords) | `PlotInteraction.py:168-199, PlotEvents.py:58-70` | `interaction.rs` `PlotPointerEvent::Clicked { button: MouseButton, data:(f64,f64), pixel:(f32,f32) }` (+ DoubleClicked/Moved); emitted by `detect_pointer_event`, surfaced as `PlotResponse.pointer_event`. |
| ✅ | M | M | Selection area visualization (semi-transparent overlay during zoom/draw) | `PlotInteraction.py:98-141 (setSelectionArea), 421-430 (draw area), 526-528, etc.` | `interaction.rs` `enum FillMode { Hatch, Solid, None }` + `SelectionStyle` + `hatch_lines`; `draw_selection_polygon` (solid = color@half-alpha, hatch via clipped diagonals, dashed outline) is shared by box-zoom AND every draw-mode preview (`draw_overlay`/`paint_draw_preview`). |
| ✅ | M | M | Rectangle drawing interaction (select mode) | `PlotInteraction.py:767-807` | `interaction.rs` Rectangle two-point phase → `DrawParams::Rectangle { x, y, width, height }` (normalized corners) with a 4-corner preview ring. |
| ✅ | M | M | Floating-point overflow protection during pan/zoom | `PlotInteraction.py:233-289, panzoom.py:114-118` | Same `FLOAT32_SAFE_MIN/MAX` clamps apply on the pan/zoom paths in `interaction.rs` (`:53-135`), clipping results into the f32-safe range rather than only checking `is_valid()`. |
| ✅ | L | L | Signal: drawingProgress (real-time feedback during shape drawing) | `PlotInteraction.py:529-532, 789-792, 903-906, etc., PlotEvents.py:34-55` | `PlotEvent::DrawingProgress { mode, points }` (silx `drawingProgress`) carries the current rubber-band's data-space vertices; emitted each frame the preview advances (`high_level.rs:3128`) from `DrawEvent::InProgress`. |
| ✅ | L | L | Signal: drawingFinished (on shape completion) | `PlotInteraction.py:545-548, 800-803, 911-914, etc., PlotEvents.py:34-55` | `PlotEvent::DrawingFinished { mode, params: DrawParams }` (silx `drawingFinished`) carries the resolved shape parameters; emitted on draw completion (`high_level.rs:3132`) from `DrawEvent::Finished`. |
| ✅ N/A | L | L | Interaction state machine (ClickOrDrag, StateMachine) | `Interaction.py:87-198, PlotInteraction.py:153-209` | Resolved — not a gap. Implementation structure, not a user feature; siplot achieves the same interaction behavior with imperative `apply_interaction` dispatch + `DrawState`. Functional parity achieved differently. |
| ✅ | L | M | Signal: hover (mouseMoved) with item label, type, draggable/selectable flags | `PlotInteraction.py:1135-1154, PlotEvents.py:73-85` | `PlotEvent::ItemHovered { handle, kind, label, x, y, xpixel, ypixel, draggable }` (silx `hover`/`prepareHoverSignal`) emitted on bare-hover over a pickable item (`high_level.rs:3381`). silx's `selectable` flag is intentionally omitted — every pickable item activates on click, so it would be a constant `true` carrying no information. |
| ✅ | L | M | Signal: markerClicked with marker details and position | `PlotInteraction.py:1223-1241, PlotEvents.py:88-139` | A marker click is surfaced via the generic `PlotEvent::ItemClicked { handle, button }` (`high_level.rs:3343`, doc'd as covering "marker, scatter, or shape" — silx `markerClicked` + the generic item-pick path); read the kind via `item_kind` and the position via `marker_position(handle)`. |
| ✅ (W14) | L | M | Signal: markerMoving/markerMoved (marker drag feedback) | `PlotInteraction.py:1276-1299, 1350` | Now split: `MarkerMoved{handle}` each frame during the drag (silx `markerMoving`) plus a distinct on-release `MarkerDragFinished{handle}` (silx `markerMoved`), bracketed by `MarkerDragStarted{handle}`. Each event carries the handle; position is read via `PlotWidget::marker_position`. |
| ✅ (W14) | L | M | Signal: curveClicked with curve indices and position | `PlotInteraction.py:1243-1261, PlotEvents.py:159-173` | `PlotEvent::CurveClicked{handle,index,x,y,button}` carries the nearest picked vertex index + data position + button. (silx's full xdata/ydata arrays are reachable via the handle, not inlined.) |
| ✅ (W14) | L | M | Signal: imageClicked with pixel (col, row) index and position | `PlotInteraction.py:1263-1272, PlotEvents.py:142-156` | `PlotEvent::ImageClicked{handle,col,row,button}` emitted from the `pick_topmost` owner path over the existing `image_index`/`pick_image_pixel` picker. |
| ✅ | L | M | Cursor shape change (resize cursors for draggable markers/handles) | `PlotInteraction.py:1165-1184` | ROI-edge resize cursors were already set; Wave 11 adds the draggable-marker size cursor (marker_cursor: VLine→SizeHor, HLine→SizeVer, free Point→SizeAll, constrained Point→the free axis), shown on hover and during drag, taking precedence over the ROI-edge cursor. Matches silx CURSOR_SIZE_HOR/VER/ALL. |
| ✅ | L | M | Selection area color and fill mode (hatch, solid, none) | `PlotInteraction.py:98-141` | Closed W15: box-zoom + draw preview share one selection-overlay renderer honoring FillMode Hatch/Solid/None; box-zoom uses silx Zoom `fill="none"` dashed outline. |
| ✅ | L | M | Ellipse drawing interaction (select mode) | `PlotInteraction.py:681-765` | `interaction.rs` `DrawMode::Ellipse` → two-point drag resolved to `DrawParams::Ellipse { center, semi-axes }` (silx `SelectEllipse`, `:369`); handled in `feed_draw_state` (`:503`/`:550`) with a live preview, surfaced via `DrawingFinished`. |
| ✅ | L | M | Freehand/polyline drawing (select mode) | `PlotInteraction.py:955-1110` | Done: `DrawMode::FreeHand` accumulates vertices (DrawState); the mask pencil footprint preview circle is now drawn at the cursor (W15, `ImageView::draw_brush_preview` + `pencil_preview_circle`). |
| ✅ | L | M | Axis constraints (minXRange, maxXRange, minYRange, maxYRange) | `panzoom.py:222-366` | Closed W15: `AxisConstraints::apply` mirrors silx `ViewConstraints` allow_scaling=True normalization — max-range capped to the position window, and a view wider than the window snaps to it (adaptive expansion) |
| ✅ | L | S | Signal: mouseDoubleClicked with position | `PlotInteraction.py:168-177, PlotEvents.py:58-70` | `PlotPointerEvent::DoubleClicked { button, data, pixel }` (silx `prepareMouseSignal("mouseDoubleClicked")`, `interaction.rs:1617`/`:1653`); emitted from `detect_pointer_event` (`plot_widget.rs:1289`) and surfaced as `PlotResponse.pointer_event`. Tested `:2466`. |
| ✅ | L | S | Signal: limitsChanged with x/y/y2 range tuples | `PlotEvents.py:176-184` | `PlotEvent::LimitsChanged { x: (f64,f64), y: (f64,f64), y2: Option<(f64,f64)> }` (silx `limitsChanged`) carries the new left x/y ranges and the optional right (y2) range; emitted on pan/zoom/programmatic update (`high_level.rs:3277`). |
| ✅ | L | S | Line drawing interaction (select mode) | `PlotInteraction.py:809-840` | `interaction.rs` `DrawMode::Line` → `DrawParams::Line { start, end }` (silx `SelectLine`, `:375`); two-point drag handled in `feed_draw_state` (`:503`/`:550`) with a preview, surfaced via `DrawingFinished`. |
| ✅ | L | S | Horizontal line drawing (select mode) | `PlotInteraction.py:885-918` | `interaction.rs` `DrawMode::HLine` → `DrawParams::HLine { y }` (silx `SelectHLine`, `:378`); one-point capture handled in `feed_draw_state` (`:507`/`:554`), surfaced via `DrawingFinished`. |
| ✅ | L | S | Vertical line drawing (select mode) | `PlotInteraction.py:920-953` | `interaction.rs` `DrawMode::VLine` → `DrawParams::VLine { x }` (silx `SelectVLine`, `:380`); one-point capture handled in `feed_draw_state` (`:507`/`:554`), surfaced via `DrawingFinished`. |

## Items: Curve, Histogram, Scatter  — 14✅ 0◐ 0☐

siplot covers curve rendering (solid/dashed lines, single symbols, error bars, fill + baseline) and per-vertex colors, with correct line-style scaling and custom dash patterns. All 17 silx point symbols are implemented (only the `'♥'` Heart glyph is unported). Histograms render as filled step curves with left/center/right alignment tracking (`HistogramAlign`/`histogram_edges`) and filled-region picking (`pick_histogram`: bbox + searchsorted bin + baseline≤y≤value). Scatter supports all silx visualization modes — Points, Solid (Delaunay), RegularGrid, IrregularGrid, and BinnedStatistic (2D binning with mean/count/sum over a rows×cols grid) — plus per-point alpha, and mode-specific picking. Active-curve highlight styling is applied at render (Wave 10) via silx's `getCurrentStyle` per-field merge. IRREGULAR_GRID now renders as silx's vertex-indexed quadrilateral triangle mesh (`irregular_grid_triangles`), so its picking maps the cell under the cursor to its source point (`irregular_grid_pick`, silx vertex `// 4`) — the render redesign that closed rows 152/1086. No remaining ◐ in this section.

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Curve symbols (circle, square, cross, plus, triangle) | `core.py:722-820 (SymbolMixIn), supported list includes o,d,s,+,x,.,etc.` | `core/items.rs` `Symbol` now has all 17 silx symbols (Circle/Diamond/Square/Plus/Cross/Point/Pixel/VerticalLine/HorizontalLine + 4 Tick + 4 Caret) plus the egui-extra Triangle; `Symbol::ALL`/`name()`/`from_code` map them to silx codes; rendered in `markers.wgsl` (`code()` 0-17). Only silx's `'♥'` Heart glyph is unimplemented. |
| ✅ | M | L | Scatter visualization mode: Solid (Delaunay triangulation) | `scatter.py:283-296, core.py:1271-1275 (Visualization.SOLID)` | `core/scatter_viz.rs` `delaunay` (Bowyer-Watson) + `solid_triangles`; `ScatterView::rebuild_visualization` Solid arm → `add_triangles_data` → `chrome::draw_triangles` (egui Mesh, per-vertex Gouraud colors). Render path is egui-tessellated, not a wgsl shader; GPU-unverified on-screen. |
| ✅ | M | L | Scatter visualization mode: IrregularGrid (vertex-indexed triangle mesh) | `scatter.py:283-296, 682-797, core.py:1286-1293 (Visualization.IRREGULAR_GRID)` | `scatter_viz.rs` `irregular_grid_triangles` arranges the points onto the detected grid (`arrange_irregular_grid_points`), builds the dual cell-corner grid (`quadrilateral_grid_coords`, interior corners = mean of 4 neighbours) and emits one flat-shaded quad (2 triangles) per point (silx `_quadrilateral_grid_as_triangles`) → IrregularGrid arm of `rebuild_visualization` → `add_triangles_data` → `chrome::draw_triangles`. Replaces the prior interpolated-image render (the `irregular_grid_image` barycentric raster remains as a standalone core primitive). Headlessly tested; egui-tessellated, GPU-unverified on-screen. |
| ✅ | M | M | Curve highlight/selection state with different style | `curve.py:196,280-311 (getCurrentStyle), core.py:1875-1905 (HighlightedMixIn)` | Wave 10: the active curve renders with a distinct highlight style. `current_curve_style` is silx `getCurrentStyle`'s per-field merge (highlight field if Some, else the curve's own); default active style = line width 2 (silx `DEFAULT_PLOT_ACTIVE_CURVE_LINEWIDTH`), color unchanged. Applied as a render-time overlay (retained base stays the single source of truth), gated to `PlotItemKind::Curve`. **GPU-UNVERIFIED:** the on-screen thicker line needs a GPU `RenderState` no crate test constructs; only the pure merge is unit-tested. |
| ✅ | M | M | Histogram bin alignment (left, center, right) | `histogram.py:53-85 (_computeEdges), setData(align='center'/'left'/'right'), getAlignment` | W15: `HistogramAlign` + pure `histogram_edges(positions, align)` mirror silx `_computeEdges`; public `add_histogram_aligned`/`_with_legend` accept N positions + N counts + alignment. Headlessly tested per alignment. |
| ✅ | M | M | Scatter visualization mode: RegularGrid (image-like grid rendering) | `scatter.py:283-296, core.py:1277-1282 (Visualization.REGULAR_GRID)` | `scatter_viz.rs` `detect_regular_grid`/`guess_z_grid_shape` auto-detect the grid shape; `regular_grid_image` renders via the image pipeline (grid arm of `rebuild_visualization`). Tested. GPU-unverified on-screen. |
| ✅ | M | M | Scatter per-point alpha transparency | `scatter.py:1009,1024,1051-1060 (setData with alpha parameter, __alpha field)` | `ScatterView.alpha: Option<Vec<f64>>` + `with_alpha`/`set_alpha`/`clear_alpha`; `compose_per_point_alpha` applies `colormap.alpha × per_point × global` (clamped) in the Points and Solid arms. Grid modes intentionally ignore per-point alpha (matches silx). |
| ✅ | M | M | Scatter picking with mode-specific logic | `scatter.py:804-860 (pick with special handling per Visualization mode)` | `ScatterView::show_position_info` dispatches the live pick by visualization mode (silx `Scatter.pick` + `_pickScatterData`): REGULAR_GRID → `regular_grid_pick` (cursor cell → index by major order, no radius), BINNED_STATISTIC → `BinnedStatistic::pick` then `nearest_candidate_in_data` (nearest bin point, highest-index tie-break, silx `indices[::-1]`+argmin), POINTS/SOLID → nearest-pixel `scatter_pick_pixels`, IRREGULAR_GRID → `irregular_grid_pick` (point-in-cell over the retained quadrilateral mesh, triangle `t` → point `t/2`, silx picked-vertex `// 4`). The IRREGULAR_GRID gap is closed by the triangle-mesh render redesign (row 1081): each point now owns a vertex-indexed cell, so the silx `// 4` mapping has a direct equivalent. Closes row 152. |
| ✅ | M | M | Plot item selection/highlight state tracking | `core.py:1875-1905 (HighlightedMixIn with setHighlighted/isHighlighted)` | Wave 10: the active-item selection is now propagated to the renderer for curves. `set_active_item` reverts the previous active curve to its base style and applies the highlight overlay to the new one (silx `setHighlighted(False)`/`(True)` in `_setActiveItem`), through a single owner `sync_curve_highlight`; `set_active_curve_style`/`set_active_curve_handling` mirror silx. Image/scatter selection still has no highlight style (silx highlights only `kind=='curve'`). |
| ✅ | L | L | Histogram picking (filled region sensitivity) | `histogram.py:244-290 (pick, __pickFilledHistogram with bounds check)` | `pick_histogram(edges, values, baseline, x, y)` ports silx `__pickFilledHistogram`: data-space bounding box (`_getBounds`, y includes 0), `searchsorted(side="left")-1` bin index via `partition_point`, then the `baseline ≤ y ≤ value` fill check. Tested (locate-bin/outside-bbox/nonzero-baseline). |
| ✅ | L | L | Scatter visualization mode: BinnedStatistic (2D histogram with statistics) | `scatter.py:283-296, core.py:1295-1301 (Visualization.BINNED_STATISTIC)` | `binned_statistic(x, y, values, rows, cols)` (scatter_viz.rs) bins points into a rows×cols grid with per-bin mean/count/sum (`BinnedStatistic`); rendered through the colormapped-image pipeline and pickable via `BinnedStatistic::pick`. Tested. |
| ✅ | L | S | Scatter visualization parameter: grid_major_order (row/column) | `core.py:1303-1308, 1346 (VisualizationParameter.GRID_MAJOR_ORDER)` | `GridMajorOrder::{Row, Column}` (scatter_viz.rs) drives `regular_grid_pick` index math (`row*cols+col` vs `row+col*rows`) and `RegularGrid.order`; auto-detected by `detect_regular_grid`. Tested. |
| ✅ | L | S | Scatter visualization parameter: binned_statistic_function (mean/count/sum) | `core.py:1325-1329, 1347 (VisualizationParameter.BINNED_STATISTIC_FUNCTION)` | `BinnedStatisticFunction::{Mean, Count, Sum}` selects the per-bin reduction via `BinnedStatistic::select`. Tested. |
| ✅ | L | S | Scatter visualization parameter: binned_statistic_shape (grid dimensions) | `core.py:1325,1333 (VisualizationParameter.BINNED_STATISTIC_SHAPE)` | The grid dimensions are the `rows`/`cols` arguments to `binned_statistic` (returns `None` when either is 0); `BinnedStatistic` stores the rows×cols arrays. Tested. |

## Items: Image, Marker, Shape, Complex  — 10✅ 1◐ 1☐

siplot implements core image (scalar colormapped + RGBA direct), marker (point/vline/hline), and shape (polygon/rectangle/polyline/hline/vline) drawing. Colormaps support 8 built-in names with linear/log/sqrt/gamma normalization and a 256-entry LUT. Images support origin/scale placement, GPU tiling for large datasets, per-pixel validity masking (NaN→transparent), nearest/linear data interpolation, and max/mean/min aggregation downsampling. Markers support point symbols, draggability with horizontal/vertical/custom-callback constraints, text labels with 9-way anchor positioning, and line styling. Shapes support fill, dashed-gap outline styling, and a data-vs-overlay layer flag. Complex images render via `ComplexImageView` (all 7 silx modes), 3D stacks via `ImageStack` (in-memory frame browser), and infinite lines via the `Line` item (`y = slope·x + intercept`). Remaining gaps: per-pixel image alpha (needs an alpha texture + shader sample — GPU) and concave-polygon fill (egui rasterizes convex-only).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ☐ | M | M | Image per-pixel alpha map | `items/image.py:462-500 (ImageData.getAlphaData/setAlphaData, alternative image with alpha)` | GENUINELY MISSING (GPU). siplot's image `Params` exposes only a scalar `alpha: f32` (`image.wgsl`); `gpu_image.rs` has no per-pixel alpha array, alpha texture, or shader sample. Implementation needs: a per-pixel alpha field on `ImageData`, a new alpha texture + bind-group entry + sampler (also through the GPU tiling path), and an `image.wgsl` alpha-texture multiply. GPU-visually unverifiable headlessly — needs design sign-off (analogous to the nan_color shader work). |
| ✅ | M | M | Image masking (per-pixel validity mask) | `items/image.py:209-251 (getMaskData/setMaskData), 273-284 (getValueData applies mask as NaN)` | `core/items.rs` `ScalarMask` (`get_mask_data`/`set_mask_data`/`apply` = getValueData→NaN); wired via `apply_image_mask` + `Plot2D::try_add_masked_image`. Masked pixels become `f32::NAN`, rendered transparent by the shader's non-finite → nan_color branch (`image.wgsl`, commit `0356d5c`). Tested. On-screen pixels GPU-unverified. (Same closure as row 153.) |
| ✅ | M | M | Marker draggability (isDraggable/drag callback) | `items/marker.py:52-67 (MarkerBase with DraggableMixIn), 177-206 (drag method, setPosition)` | Wave 11: a draggable marker (is_draggable) is grabbed on primary drag (topmost via marker_at), follows the cursor through Marker::drag (constraint-aware), persists to the backend item, and reports via PlotResponse.marker_moved / PlotEvent::MarkerMoved. Public set_marker_position / marker_position (silx setPosition/getPosition). No separate sigDragStarted/sigDragFinished split (single move signal). |
| ✅ | L | L | Image aggregation/downsampling modes (max/mean/min) | `items/image_aggregated.py:46-138 (ImageDataAggregated.Aggregation enum with NONE/MAX/MEAN/MIN, _getLevelOfDetails LOD reduction)` | `AggregationMode::{None, Max, Mean, Min}` (gpu_image.rs) + `aggregate_blocks(field, w, h, bx, by, mode)` reduce a field by integer block factors (silx `ImageDataAggregated` MAX/MEAN/MIN); wired as the image spec's `aggregation` + `aggregation_block` fields applied on the display path. Default None matches silx. Reducer tested (max/min/mean/none, odd dims). |
| ✅ | L | L | Complex image modes (7 variants: absolute/phase/real/imaginary/amplitude_phase/log10_amplitude_phase/square_amplitude) | `items/complex.py:105-378 (ImageComplexData class, ComplexMode enum with 7 modes, mode-specific colormaps and conversions)` | `ComplexImageView` + `ComplexMode` (complex_image_view.rs) implement all 7 silx modes (Absolute/Phase/Real/Imaginary/SquareAmplitude/AmplitudePhase/Log10AmplitudePhase, plus an extra Log10Amplitude): scalar modes feed the colormapped pipeline, the amplitude-phase composites build HSV RGBA (hue=phase, value=normalized amplitude, with a settable log10 displayed-max). Conversions tested. |
| ◐ | L | L | Shape fill concavity limitation (convex-only rasterization) | `items/shape.py (silx defers to backend, matplotlib/pygfx handle concave, but silx does not guarantee correctness)` | egui's convex_polygon rasterizer will render concave polygon fill incorrectly (as convex hull). silx does not formally restrict to convex, so a concave polygon may display differently. No warning or f |
| ✅ | L | M | Image interpolation mode (nearest vs linear sampling) | `backends/BackendMatplotlib.py:805 (interpolation='nearest' hardcoded; silx backends via matplotlib accept 'nearest'/'bilinear'/etc.)` | `InterpolationMode::{Nearest, Linear}` (gpu_image.rs) is exposed as the `ImageData.interpolation` field (`with_interpolation` setter, default Nearest = silx) and carried into `image.wgsl` via `code()` (0/1) where the shader branches between nearest and bilinear scalar sampling. Mode plumbing tested; on-screen sampling GPU-unverified. |
| ✅ | L | M | Image stack (3D array, show one frame at a time) | `items/image.py:593-669 (ImageStack class, setStackData/getStackData/setStackPosition)` | `ImageStack` (image_stack.rs) browses an in-memory ordered list of 2D `Frame`s: a current-frame index driven by a first/prev/next/last slider (silx `setStackPosition`), a selectable frame table, per-frame visibility, a waiting overlay for empty slots, and display of the current frame through the `Plot2D` backend. Lazy URL/HDF5 frame loading is a separate gap (rows 1286-1287). Navigation state tested; render GPU-unverified. |
| ✅ | L | M | Marker constraint function (horizontal/vertical/custom drag filter) | `items/marker.py:208-235 (getConstraint/_setConstraint with _horizontalConstraint/_verticalConstraint), 273-292 (Marker subclass overrides for constraint strings)` | Wave 11 wired the horizontal/vertical presets (MarkerConstraint::Horizontal/Vertical) into the drag and the size cursor (Horizontal→SizeVer, Vertical→SizeHor). The arbitrary custom-callback form is now `Marker::drag_with(to, fn)` — silx's callable `constraint(x,y)->(x,y)`; `drag` delegates to it so both paths share one update owner. Closure is call-time state (keeps the Marker derives). |
| ✅ | L | M | Shape overlay flag (data layer vs separate overlay layer) | `items/shape.py:54-73 (_OverlayItem with isOverlay/setOverlay)` | `Shape::is_overlay`/`Line::is_overlay` (`with_overlay` setter) now drive a two-pass render: `draw_shapes`/`draw_lines` take an `overlay: bool` filter; `PlotWidget` draws non-overlay items in the base data layer (above grid, under ROIs/markers/crosshair) and overlay items in the overlay layer on top of the chrome (silx base-render vs `_drawOverlays`). Field/setter tested; render-layer split GPU/painter-unverified. |
| ✅ | L | M | Line item (infinite y = slope·x + intercept) | `items/shape.py:289-393 (Line class, distinct from Shape, infinite line with slope/intercept, computed vertices via visible bounds tracking)` | `Line` (core/shape.rs) is a distinct infinite-line item: `y = slope·x + intercept` (non-finite slope → vertical `x = intercept`), constructible from slope/intercept or two points (silx `setSlopeInterceptFromPoints`), clipped to the visible `[xmin, xmax]` bounds at draw time. Tested. |
| ✅ | L | S | Marker text anchor/alignment (positioning relative to marker point) | `backends/BackendMatplotlib.py:951-1047 addMarker + _TextWithOffset.__get_xy:304-340` | `TextAnchor` (9 variants) wired into `chrome::draw_markers` via pure `rect_offset` + `pixel_offset` (the latter ports silx's alignment-dependent offset sign). Per-kind silx defaults: point/vline `TopLeft` (`ha="left"`), hline `TopRight` (`ha="right",va="top"`, text at the right edge). Core tested headlessly; painter draw GPU-unverified |

## Backend Render (WgpuBackend vs BackendPygfx)  — 36✅ 2◐ 2☐

The WgpuBackend implements the core Backend trait with substantial coverage of BackendPygfx: additive item methods (addCurve, addImage, addTriangles, addShape, addMarker with the full silx symbol set + drag constraints), state management (limits, axes, colors, margins), and core rendering (transforms, data-to-pixel, picking). `save_graph` exports PNG/PPM/TIFF/SVG (DPI accepted-but-ignored, matching silx). Remaining true gaps: GPU line joins/caps (round/bevel/miter — `design.md` §7·§13 B1, butt-only today) and async GPU stats/histogram reduction (functional autoscale/stats/histogram exist on CPU; the GPU-compute performance path is not mirrored). Several silx backend methods — setGraphGrid, setGraphCursor, time-series axis, postRedisplay — live at siplot's `Plot`/chrome/widget layer rather than the GPU backend: an architectural split, not missing features (marked ✅ N/A below).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ◐ | H | L | addMarker: point/hline/vline marker with symbol, text, line constraints | `BackendBase.py:211-267, BackendPygfx.py:1397-1465` | Markers (point/hline/vline) with symbol + text + drag constraints are implemented: `MarkerConstraint` (None/Horizontal/Vertical) + `apply_constraint` replace silx's Qt drag-filter callback (data-driven). Label background rect (silx `bgcolor`/`setBackgroundColor`) IS wired: `Marker::bgcolor` + `with_bgcolor`, rendered by `chrome::draw_marker_label` as a `rect_filled` behind the galley (`chrome.rs:1123-1129`), live via `draw_markers` (`plot_widget.rs:453`). Residual: custom text font (silx `setFont`/QFont) is not ported — marker labels render in a fixed 11 pt proportional font (egui `FontId` cannot express QFont weight/italic without registered font variants). |
| ✅ | H | M | Symbol support: circle, square, cross, plus, diamond, etc. | `BackendBase.py:116-126, BackendPygfx.py:85-98 (_SYMBOL_MAP)` | `MarkerSymbol` (Circle/Point/Pixel/Plus/Cross/Diamond/Square) and `items::Symbol` (adds Triangle/TickLeft/TickRight) cover the full silx `_SYMBOL_MAP` set ('o'/'.'/','/'+'/'x'/'d'/'s'). |
| ✅ | M | L | saveGraph: PNG export at specified DPI and size | `BackendBase.py:372-382, BackendPygfx.py:2588-2610` | `save_graph`/`save_graph_with_format` + `SaveFormat` export PNG, PPM, TIFF, and SVG (`encode_png`/`encode_ppm`/`encode_tiff`/`encode_svg`). The DPI parameter is accepted-but-ignored, matching silx (noted there too). |
| ☐ | L | L | Line joins: round/bevel/miter for connected segments | `BackendPygfx.py uses pygfx LineMaterial (no explicit join config visible)` | Deferred design-doc item (§7·§13 B1). Butt caps + gaps visible at sharp turns; high-res curves hide this. |
| ☐ | L | L | Line caps: butt, round, square at line endpoints | `BackendPygfx.py uses pygfx LineMaterial` | Deferred (§7·§13 B1). Only butt caps implemented; round/square caps listed as future work. |
| ✅ N/A | L | L | Grid on/off: toggle major/both grid display | `BackendBase.py:543-549, BackendPygfx.py:2754-2756` | Resolved differently — grid display is `Plot::grid` (`GraphGrid::None`/`Major`/`MajorAndMinor`), drawn by the chrome layer. silx exposes `setGraphGrid` on the backend; siplot keeps it on `Plot`/chrome (the GPU backend draws data, not the grid) — an architectural split, not a missing feature. |
| ✅ N/A | L | M | Crosshair cursor (setGraphCursor): show/hide crosshair, set color/width/style | `BackendBase.py:289-310, BackendPygfx.py:1948-1990 (_updateCrosshair)` | Resolved at the widget layer — `Plot::crosshair`/`set_graph_cursor` toggle + `chrome::draw_crosshair` (`plot_widget.rs:457`) draw a crosshair + coordinate readout following the pointer (appearance via the chrome `Style`). A backend-trait method is, by design, not where siplot puts this. |
| ✅ N/A | L | M | Time-series axis: display datetime objects on X axis | `BackendBase.py:468-489, BackendPygfx.py:2704-2714` | Implemented at the chrome layer (`TickMode::TimeSeries` + `core/dtime_ticks`, wired in `chrome.rs`; see the PlotWidget-core TIME_SERIES row), not the GPU backend — the chrome-layer split the gap note itself describes ("chrome-layer responsibility in both silx and siplot"). |
| ◐ | L | M | GPU stats/histogram: async GPU compute for min/max/histogram (streaming data) | `BackendPygfx.py:1579-1622 (_WgpuComputeHelper, _AsyncCompute, _computeGpuDataStats, _computeGpuHistogram)` | The functional results exist on CPU — colormap autoscale (`AutoscaleMode::range`: MinMax/Stddev3/Percentile), curve/image stats (`core/stats`), and histograms (`pick_histogram`, `scatter_viz::binned_statistic`). silx's async GPU reduction (atomic + readback) for streaming-data performance is not mirrored. |
| ✅ N/A | L | S | postRedisplay / request_draw: request a repaint on next frame | `BackendBase.py:363-365 (postRedisplay calls replot)` | Resolved-not-a-gap — egui is immediate-mode and redraws every frame, so `postRedisplay`/`replot` has no consumer by design (same basis as the dirty/replot lifecycle rows). |

## Colormap, ColorBar, and AlphaSlider  — 12✅ 3◐ 0☐

siplot implements colormaps with a 45-entry catalog covering silx's full named set (gray, reversed gray via `reversed()`, red, green, blue, temperature, jet, hsv, the viridis family, plus 30+ colorous gradients standing in for matplotlib's catalog), all 5 silx normalizations (Linear/Log/Sqrt/Gamma/Arcsinh), NaN-color configuration, and the three silx autoscale modes (MinMax/Stddev3/Percentile, with configurable percentile bounds via `AutoscaleMode::range`). The `ColorBarWidget` is a self-contained drawer with min/max labels, log/linear ticks, a rotated legend, and both vertical/horizontal orientations; the `ColormapDialog` adds name/normalization/gamma/autoscale/range controls plus a data-distribution histogram; an `AlphaSlider` widget exposes 0-255 opacity. `Colormap` supports custom Nx4 LUTs (`from_colors`/`set_lut`), an editable flag, copy/equality, and `set_from`. Remaining ◐: a global named-LUT registry (`register_colormap`), the active-item alpha-binding slider subclasses, and state serialization (needs serde).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Colormap catalog names | `/Users/stevek/codes/silx/src/silx/gui/colors.py:444-455, /Users/stevek/codes/silx/src/silx/math/colormap.py:53-65` | `ColormapName` is a 45-entry static catalog (`ALL`) covering silx's entire documented named set (`setName`: "at least" `gray`, `reversed gray`, `temperature`, `red`, `green`, `blue`, `jet`, `viridis`, `magma`, `inferno`, `plasma`). `Gray`/`Red`/`Green`/`Blue` are exact `single_channel_ramp`s, `Temperature` is transcribed channel-by-channel from `_create_colormap_lut`'s `arange` slices (`temperature_lut`, tested), `reversed gray` = `Colormap::reversed()` (LUT reverse), and `Jet`/`Hsv` + 30+ colorous gradients (Blues/Greens/Warm/Cool/Cubehelix/diverging/cyclical) stand in for matplotlib's catalog. Residual (not a gap in silx's contract): siplot ships a fixed static catalog rather than resolving arbitrary matplotlib names at runtime — it carries no matplotlib dependency (analogous to silx loading the viridis family from bundled `.npy` resources). |
| ✅ | H | M | Autoscale mode selection | `/Users/stevek/codes/silx/src/silx/gui/colors.py:318-331, 563-586` | `AutoscaleMode::{MinMax, Stddev3, Percentile}` (colormap.rs) with `AutoscaleMode::range(data, percentiles)` computing each mode faithfully — MinMax, mean±3·std clamped to data range (silx STDDEV3), and `nanpercentile` (silx PERCENTILE) — plus silx's empty/non-finite/inverted fallbacks. ColormapDialog exposes the mode selector. Tested. |
| ✅ | H | M | Autoscale percentile bounds | `/Users/stevek/codes/silx/src/silx/gui/colors.py:588-599, /Users/stevek/codes/silx/src/silx/math/colormap.py:355-368` | `Colormap.autoscale_percentiles: (f64, f64)` (default `DEFAULT_PERCENTILES = (1.0, 99.0)`) + `set_autoscale_percentiles` (silx `setAutoscalePercentiles`, each clamped to `[0,100]`, ordered low≤high); consumed by `AutoscaleMode::Percentile`. ColormapDialog has the low/high inputs. Tested. |
| ✅ | M | L | ColormapDialog interface | `/Users/stevek/codes/silx/src/silx/gui/dialog/ColormapDialog.py:24-300` | `widget/colormap_dialog.rs`: name (45 colormaps), normalization, vmin/vmax, gamma, AND the two cited gaps now closed — data-distribution histogram (`set_histogram`/`get_histogram` + auto-compute from the active image, per-norm cache; silx `setHistogram`/`_computeNormalizedHistogram`) drawn behind the range, plus autoscale-mode selector (`AutoscaleMode::{MinMax, Stddev3, Percentile}` = silx's full set, with `(low,high)` percentile inputs; silx `setAutoscaleMode`). |
| ✅ Done | M | M | NaN color configuration | `/Users/stevek/codes/silx/src/silx/gui/colors.py:506-518, 337` | `0356d5c`: `Colormap.nan_color` ([u8;4], default fully-transparent white #FFFFFF00) now reaches the GPU — `image.wgsl` detects non-finite samples and returns the sRGB→linear `nan_color` premultiplied by `alpha`, so NaN/masked pixels render transparent (also fixes ScalarMask, which sets masked pixels to f32::NAN). std140 size (128 B) + sRGB transfer unit-tested; GPU-visually unverified |
| ✅ | M | M | Colorbar drawing with ticks | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:296-920` | `widget/colorbar.rs` `ColorbarWidget`: 256-control-point gradient strip (silx `_NB_CONTROL_POINTS`), `paint_ticks_and_labels` draws major nice/decade ticks **with labels** plus minor sub-ticks (`tick_layout`/`optimal_nb_ticks` at `DEFAULT_TICK_DENSITY`), respecting the colormap normalization, in both vertical and horizontal orientations. |
| ✅ | M | M | Colorbar min/max labels | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:364-452` | `widget/colorbar.rs` `paint_end_labels` (silx `ColorScaleBar._updateMinMax`): formatted `vmax` at the top / `vmin` at the bottom (vertical) or left/right (horizontal) via `format_end_label`. The Qt hover-tooltip-with-full-precision is a Qt-widget affordance not applicable to a painted egui widget; the formatted end labels themselves are present. |
| ✅ | M | S | Normalization types | `/Users/stevek/codes/silx/src/silx/gui/colors.py:292-316` | All 5 present: `Normalization::{Linear, Log, Sqrt, Gamma, Arcsinh}` (`core/colormap.rs:19-35`). Arcsinh (silx `ARCSINH`) transforms `v.asinh()` on the CPU path (`:63`, `code()==4`), in `image.wgsl` (`norm==4u`, `asinh(raw)`), and in the colorbar tick layout (`colorbar.rs:857`); tested (`colormap.rs:1144-1169`). |
| ✅ | L | L | ColorbarWidget standalone | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:44-263` | `ColorBarWidget` (colorbar.rs) is a self-contained, exported widget: `new(colormap, vmin, vmax)` + `with_legend`/`with_orientation` builders, drawing the gradient strip, ticks/labels, end labels, and legend into any allotted rect. Caller syncs it to the active item's colormap (the silx signal-sync is the host's job). |
| ✅ | L | M | Colorbar legend label | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:44-200, 266-294` | `ColorBarWidget.legend` + `with_legend` + `paint_legend` draw a rotated title running along the bar (silx `_VerticalLegend`, 270°); empty legend hides the strip (`split_legend`). |
| ✅ | L | M | Colorbar orientation (vertical/horizontal) | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py (vertical only in examples but widget is orientation-agnostic)` | `ColorBarOrientation::{Vertical, Horizontal}` is a `ColorBarWidget` field (`with_orientation` builder, default Vertical); the bar, ticks, end labels, and legend strip all branch on it (silx Qt::Vertical/Qt::Horizontal). |
| ◐ | L | M | AlphaSlider widget | `/Users/stevek/codes/silx/src/silx/gui/plot/AlphaSlider.py:86-250` | The base widget is done: `AlphaSlider` (alpha_slider.rs) is a 0..=255 opacity slider exposing `u8`/`f32` alpha (`value/255`, silx `valueChanged`) with `AlphaSliderOrientation` and a `ui() -> Response`. Remaining ◐: the item-binding subclasses (`ActiveImageAlphaSlider`/`NamedItemAlphaSlider`) that track a specific plot item's alpha are deferred (need the plot model). |
| ◐ | L | M | Custom colormap registration | `/Users/stevek/codes/silx/src/silx/math/colormap.py:158-176, /Users/stevek/codes/silx/src/silx/gui/colors.py:343-386` | Per-instance custom LUTs are done: `Colormap::from_colors(&[[u8;4]], vmin, vmax)` (resamples an arbitrary Nx4 array to 256 entries via `resample_lut`), `with_lut`/`set_lut` (silx `setColormapLUT`). Remaining ◐: no global named-LUT registry (silx `register_colormap`) so a custom LUT can't be referenced by name. |
| ◐ | L | S | Colormap copy/comparison/serialization | `/Users/stevek/codes/silx/src/silx/gui/colors.py:399-423, 960-1050` | `Colormap` derives Clone (copy) + PartialEq (`__eq__`) and has `set_from` (silx `setFromColormap`, editable-guarded). Remaining ◐: no `saveState`/`restoreState` round-trip serialization (needs serde, which siplot deliberately omits). |
| ✅ | L | S | Colormap editability flag | `/Users/stevek/codes/silx/src/silx/gui/colors.py:351, 659-674` | `Colormap.editable: bool` (default true) with `is_editable`/`set_editable` (silx `setEditable`); the editable-guarded setters (`set_lut`, `set_autoscale_percentiles`, `set_from`) become no-ops returning `false` when frozen (silx `NotEditableError`). |

## ROI system (creation, editing, manager, statistics)  — 23✅ 7◐ 0☐

siplot implements a ROI core with all 11 silx shape kinds (Rect, HRange, VRange, Point, Line, Polygon, Cross, Circle, Ellipse [oriented], Arc, Band), supporting on-plot interactive creation for every kind (Wave 12: arm a draw shape, draw it, the ROI is appended), edge dragging via handle-detection, and whole-ROI translate — all in pure data space. Wave 13 unified `plot.rois` to a single `Vec<ManagedRoi>` (geometry + appearance), closing the visual/behavioral gap: per-ROI color, on-canvas naming/labels, selection highlighting (single-owner `Plot::set_current_roi` + `sigCurrentRoiChanged`), handle-symbol roles, and style properties (line width, style, gap color, fill) all reach the live plot; `Roi::contains()` is implemented for every variant; and ROI statistics (curve raw/net counts + area, image/curve stats tables via CurvesROIWidget/ROIStatsWidget) landed in W15. Remaining gaps are narrower: the per-shape interaction sub-modes (Arc PolarMode↔ThreePointMode, Band Bounded↔Unbounded live toggle), a richer manager table widget, and ROI persistence/load (needs serialization).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Interactive ROI creation mode (draw mode vs select mode) | `tools/roi.py:833-885 (start/stop/isStarted/isDrawing)` | Wave 12: `PlotInteractionMode::RoiCreate(RoiDrawKind)` arms a draw shape (silx `start(roiClass)`); the existing `DrawState` machine drives the gesture, `roi_from_draw` maps the finished `DrawParams`→`Roi` (silx `setFirstShapePoints`), the ROI is appended, the mode re-arms continuously (silx default), and a live preview overlay is surfaced during the gesture. Public `set_roi_create_mode(kind)` toggles draw vs select. Still missing the toolbar `CreateRoiModeAction`/mode-selection widget. On-screen creation is GPU/PlotWidget-UNVERIFIED; the `DrawParams`→`Roi` mapping and the `apply_interaction` wiring are headlessly unit-tested. |
| ◐ | H | L | Manager ROI list as table widget (ROITable/CurvesROIWidget) | `CurvesROIWidget.py:62-400, ROITable:452-860` | egui shows a scrollable list with remove button per ROI and add buttons (Rect/HRange/VRange/Point/Line only). silx ROITable is a rich table with per-ROI stats columns (min/max/sum/mean/etc.), editable |
| ✅ | H | L | ROI statistics (min/max/mean/sum/integral, raw/net counts, COM/coords) | `CurvesROIWidget.py:355-430, ROIStatsWidget.py (full file)` | `roi_stats` computes count/min/max/mean/sum/integral per ROI (image + curve) plus center-of-mass and first-min/first-max **data coordinates** (silx `StatCOM`/`StatCoordMin`/`StatCoordMax`): value-weighted COM, first-extremum tie-break matching numpy argmin/argmax, 2D `(x, y)` for image ROIs / x-only for curve ROIs. `curve_roi_counts` adds curve raw/net counts + raw/net area (silx `computeRawAndNetCounts/Area`); `RoiStatsWidget` renders all columns incl. COM / coord min / coord max. "Peaks" was a misattribution — not a silx ROI stat (peak-search lives in the fit cluster, `peak_search`); neither `CurvesROIWidget` nor the `stats` framework (`StatMin/Max/Delta/CoordMin/CoordMax/COM`) computes peaks. Reductions are pure and headlessly tested (image value-weighted COM = (13/6, 8/3) on the 4×4 ramp with coord min/max; curve x-only COM + coords); the widget table is GPU-unverified. |
| ✅ | H | L | CurvesROIWidget integration (ROI stats per curve item) | `CurvesROIWidget.py (entire file)` | W15: `CurvesRoiWidget` shows a per-ROI raw/net counts + raw/net area table over the active curve via `PlotWidget::feed_curves_roi_stats`/`show_curves_roi_widget`, reducing each x-span ROI with the pure `curve_roi_counts` (silx `computeRawAndNetCounts`/`computeRawAndNetArea`). |
| ✅ | H | L | ROIStatsWidget (image/curve ROI stats display) | `ROIStatsWidget.py (entire file)` | W15: `RoiStatsWidget` shows a per-ROI stats table (`ROI \| N \| min \| max \| mean \| sum \| integral`) over the active item via `PlotWidget::feed_roi_stats`/`show_roi_stats_widget`, reducing each ROI with `image_roi_stats`/`curve_roi_stats`. |
| ✅ | H | M | ROI per-instance color (independent from manager default) | `items/_roi_base.py:389-405, tools/roi.py:713-742` | Wave 13: `ManagedRoi.color: Option<Color32>` (silx `setColor`/`getColor`); the live `chrome::draw_rois` path resolves `managed.color.unwrap_or(plot.roi_color)` via pure `chrome::roi_appearance`; high-level `set_roi_color` + manager color buttons write the per-ROI override. Appearance resolution headlessly tested; on-screen pixels GPU-unverified. |
| ✅ | H | M | ROI label/text display on canvas | `items/_roi_base.py:492-511` | Wave 13: `ManagedRoi.name` flows through `chrome::roi_appearance.name` into `draw_roi`, which renders the name as a label at the ROI anchor (chrome.rs:1006); empty name → no label. High-level `set_roi_name` + manager name editor. Resolution tested; label pixels GPU-unverified. |
| ✅ | H | M | ROI selection/highlighting (visual feedback) | `tools/roi.py:528-590 (setCurrentRoi/getCurrentRoi/sigCurrentRoiChanged)` | Wave 13: `Plot::set_current_roi` (plot.rs:518) is the sole writer of `ManagedRoi.selected` (silx `setHighlighted`); `draw_roi` thickens the selected ROI's stroke (chrome.rs:782). Single-owner invariant (exactly one selected; out-of-range clears; remove adjusts index) headlessly tested; highlight pixels GPU-unverified. |
| ✅ | H | M | ROI contains() point-in-region test | `items/roi.py:61-1599 (all ROI classes have contains method)` | `Roi::contains((x,y))` implemented for every variant (roi.rs:838): Rect/HRange/VRange inclusive bbox, HLine/VLine/Point exact-coord, Cross x\|y, Line unit-square intersection, Polygon/Band point-in-polygon, Circle dist≤r, oriented-Ellipse projection, Arc ring+sweep (`arc_contains`). 13 headless tests (rect/band/point/cross/circle/ellipse×2/polygon/line/arc×2). |
| ✅ | H | M | Manager current ROI tracking (setCurrentRoi/getCurrentRoi) | `tools/roi.py:528-590` | Wave 13: `Plot::current_roi`/`set_current_roi` (plot.rs:509/518) own the selected-ROI index; `RoiManagerWidget` reads `plot.current_roi()` and writes via `plot.set_current_roi()` (no own state); `PlotEvent::CurrentRoiChanged { previous, current }` mirrors silx `sigCurrentRoiChanged`. Selection invariant tested. |
| ✅ | H | S | ROI name/label (string identifier) | `items/_roi_base.py:77-92, CurvesROIWidget.py:594-640` | `ManagedRoi.name: String` (silx `getName`/`setName`); high-level `set_roi_name` and the manager's per-row name editor write it; resolved into the canvas label (row above). |
| ✅ | M | L | EllipseROI kind (center, radii, orientation handles) | `items/roi.py:950-1177` | `Roi::Ellipse { center, radii, orientation }` with on-plot creation (`RoiDrawKind::Ellipse`) and full rotational geometry: the two axis handles sit at θ / θ+π/2 and dragging one off-axis sets that semi-axis and rotates (silx `EllipseROI.handleDragUpdated`); `contains`, `screen_rect`, and the outline are all orientation-aware. Models `EllipseROI._orientation` as `orientation` (radii.0 along it, radii.1 perpendicular). |
| ✅ Done | M | M | CrossROI kind (point with cross marker) | `items/roi.py:133-185` | `Roi::Cross { center }` exists with Wave 12 on-plot creation (`RoiDrawKind::Cross`, single click via `DrawMode::Point`); `1cbda70` makes the drag handle a square (silx `addHandle()` default symbol "s") via `kind()` returning a `Vertex`. silx's transient label-handle management remains a manager-path concern (same as row 173). |
| ✅ | M | M | CircleROI kind (center + radius handles) | `items/roi.py:800-950` | `Roi::Circle { center, radius }` with on-plot creation (`RoiDrawKind::Circle`) AND dedicated edit handles: `edges()` → `[Vertex(0) center, Vertex(1) perimeter]`, `move_edge` (V0 translate, V1 sets radius), rendered+hit-tested via `draw_roi_handles`/`roi_grab_at`. Tests `circle_handles_drag_center_and_radius`, `circle_perimeter_resize_works_under_inverted_y`. |
| ✅ | M | M | ROI handle symbols ('+', 's', 'o', custom glyphs) | `items/_roi_base.py:735-759 (addHandle/addLabelHandle/addTranslateHandle), PolygonROI:1244-1274` | `draw_roi_handles` draws the silx role symbols: `+` for translate/center, square `s` for vertex/edge. The `o` is silx `PolygonROI._handleClose` (a transient role-"user" creation marker from the `RegionOfInterestManager` path); siplot creates polygons via the `PlotInteraction` draw path and shows its faithful close target instead — the `SelectPolygon.updateFirstPoint` fill=None box. |
| ✅ | M | M | ROI line style (solid, dash, dot, gap color) | `items/_roi_base.py:600-680, RectangleROI:534-552` | `ManagedRoi.line_style` (`RoiLineStyle::{Solid,Dashed,Dotted}` → `LineStyle`) emits real dash/dot segments via `chrome::draw_styled_line`; `ManagedRoi.gap_color` fills dash gaps (silx `setLineGapColor`, commit `a6298b9`). Manager UI exposes style combo + gap swatch; `set_roi_line_style`/`set_roi_line_gap_color` setters. GPU-unverified on-screen. |
| ◐ | M | M | Manager signals (sigRoiAdded, sigRoiChanged, sigCurrentRoiChanged, sigInteractiveRoiCreated/Finalized) | `tools/roi.py:331-371` | Now distinct: `RoiAdded { index }` (silx `sigRoiAdded`) fires from every add — `add_roi`/`add_managed_roi` and the interactive create (which emits `RoiAdded` then `RoiCreated`, silx's `sigRoiAdded`→`sigInteractiveRoiFinalized` order). `RoiChanged` (silx `sigRoiChanged`) is now geometry-change-only; `CurrentRoiChanged` = `sigCurrentRoiChanged`; `RoiCreated` = `sigInteractiveRoiFinalized` (draw finished). Remaining ◐: no separate `sigInteractiveRoiCreated` (mid-gesture) — siplot builds the ROI only on draw-finish, so there is no mid-draw ROI object to signal; and no `sigRoiAboutToBeRemoved` (removal emits `RoisCleared`). Emission is GPU-path only (no headless test). |
| ✅ | M | M | ROI creation phase UI (preview overlay, mode indicator, close-polygon handle) | `PolygonROI:1241-1256, tools/roi.py:493-510, 1101-1175` | All three present: (1) live preview overlay during the draw gesture (`Interaction.roi_preview` → `draw_overlay`); (2) the polygon 'close' target — `paint_draw_preview` draws `draw_polygon_first_point` (silx `SelectPolygon.updateFirstPoint` fill=None box) around the first vertex while drawing a polygon; (3) mode indicator — `PlotInteractionMode::roi_creation_message(roi_count)` / `PlotWidget::roi_creation_message()` produce silx's `InteractiveRegionOfInterestManager.getMessage` string ("Select {SHORT_NAME}s ({n} selected)") while a create mode is armed, for a host status bar (silx's status message is app-displayed too). Message + `RoiDrawKind::short_name` are headlessly tested; the on-screen preview/close-target render is GPU-UNVERIFIED. |
| ✅ | M | S | ROI line width customization | `items/_roi_base.py:600-680, RectangleROI:534-552` | `ManagedRoi.line_width` (default 1.0) → `RoiAppearance.line_width` applied as the stroke width (selected → `max(w, 2)`); manager UI `DragValue` (0.1..=20.0); `set_roi_line_width` setter. |
| ✅ | M | S | ROI fill color and fill enable/disable | `items/Shape.py (shape draw), RectangleROI:531-539` | `ManagedRoi.fill` (default false) → `RoiAppearance.fill`; `Some(false)` draws no interior (silx `setFill(False)`), else the translucent tint at the ROI color (matches silx, which fills with the ROI color). Manager UI fill checkbox; `set_roi_fill` setter. Fill color is not separately configurable (derives from outline color), matching silx. |
| ✅ | M | S | Manager ROI color default (setColor/getColor) | `tools/roi.py:782-797` | `Plot::roi_color` (silx default red) is the per-ROI fallback in `draw_rois(.., default_color, ..)`/`roi_appearance` when `managed.color` is None; manager rows show the resolved color and write per-ROI overrides. |
| ◐ | M | S | ROI context menu (edit, delete, mode selection) | `tools/roi.py:625-642` | Right-clicking a ROI on the canvas now opens a per-ROI menu (silx `_createMenuForRoi`): ROI-name title, "Make current" (→ `set_current_roi`, silx `sigCurrentRoiChanged`), and "Remove" (→ `remove_roi`), above the Zoom-Back/Reset items. The target ROI is captured at right-click via `roi_grab_at` and held in `ui.data` for the menu's lifetime; the canvas only signals intent (`Interaction.roi_removed`/`roi_make_current`) and the high-level owner performs the mutation + event. "edit" = on-canvas handle drag (Wave 12, separate). Remaining ◐: no interaction-mode submenu — siplot's ROIs do not implement silx `InteractionModeMixIn` (Arc start-angle-vs-radius / Band rotation sub-modes are deferred, rows 1180/1184). Menu emission is GPU-path only (no headless test). |
| ◐ | L | L | ArcROI kind (circular arc with start/end angle) | `items/_arc_roi.py (full file)` | `Roi::Arc { center, inner/outer radius, start/end angle }` exists; Wave 12 adds on-plot creation (`RoiDrawKind::Arc`, 2-point line drag) and PolarMode handle editing (mid/weight/start/end). The ThreePointMode geometry is now ported too — `arc_from_three_points` (faithful `_createGeometryFromControlPoints`), `arc_control_points`, and `arc_three_point_drag` (control-point reshape, re-fits the circumcircle, preserves weight); `arc_from_two_points` delegates to it. Remaining ◐: the live toggle that swaps PolarMode for ThreePointMode handle editing on the canvas (GPU/UI, unwired). |
| ◐ | L | L | BandROI kind (rotatable rectangular band) | `items/_band_roi.py (full file)` | `Roi::Band { begin, end, width }` exists with on-plot creation (`RoiDrawKind::Band`) and silx's width constraints (`width>=0`, width handles constrained to the band normal). Orientation is free via the begin/end handles (silx has no separate rotate handle). silx's real sub-modes are `BoundedMode` (polygon, siplot default) and `UnboundedMode` (three infinite lines) — the Unbounded geometry is now `Roi::band_lines()` (faithful `BandGeometry` line equations, `Sloped`/`Vertical` sum type). Remaining ◐: the live Bounded↔Unbounded render/toggle (GPU/UI). |
| ✅ | L | M | HorizontalLineROI kind (single y coordinate) | `items/roi.py:373-435` | `Roi::HLine { y }` is a single-y full-span line, distinct from the `HRange` band: one edge (the line's y), one handle, `contains` = exact-y. `RoiDrawKind::HLine`→`DrawMode::HLine` (silx `SelectHLine` `_plotShape="hline"`), `roi_from_draw` captures the row; chrome draws it as a styled full-width line. Geometry/grab/draw-build tested; GPU draw unverified |
| ✅ | L | M | VerticalLineROI kind (single x coordinate) | `items/roi.py:437-510` | `Roi::VLine { x }` analogue: single-x full-span line, one edge (the line's x), exact-x `contains`. `RoiDrawKind::VLine`→`DrawMode::VLine` (silx `SelectVLine`). Tested; GPU draw unverified |
| ◐ | L | M | ROI interaction modes (select, edit, focus constraints) | `items/_roi_base.py:135-230 (InteractionModeMixIn, RoiInteractionMode enum)` | Wave 12 adds a select-vs-create mode toggle and, within editing, both edge-drag and whole-ROI translate (`RoiGrab::Edge`/`RoiGrab::Translate`). Still missing silx's per-shape interaction sub-modes (e.g. Arc start-angle vs radius editing) — those remain a single fixed edit behaviour per kind. |
| ✅ | L | M | ROI edge position constraints | `items/roi.py:666-718 (RectangleROI), _arc_roi.py:889, _band_roi.py:66,383-392` | All silx-real edge constraints are enforced in `move_edge`: Rect/Range handles flip around the fixed opposite (silx min/max `_setBound`) rather than collapsing; Arc keeps `inner<=outer`/`inner>=0`; Band keeps `width>=0` + the orthogonal width-handle constraint. silx has no ratio-lock, grid-snapping, or inter-ROI collision/overlap prevention (the "Band preventing overlap" was a mis-attribution — `_band_roi.py` only clamps width and constrains width handles to the normal). |
| ◐ | L | M | ROI save/load from file (dictdump format) | `CurvesROIWidget.py:194-210, roi save/load methods` | Data layer ported: `core::roi_io` `encode_rois`/`decode_rois` (hand-written line-oriented text, no serde — mirrors the `.npy` mask path) round-trip every `Roi` variant + name/color/line width+style/gap color/fill (the transient `selected` is dropped, as silx `ROI.toDict` does), with `save_rois`/`load_rois` file wrappers (silx `CurvesROIWidget.save`/`load(filename)`). 9 headless tests. Remaining ◐: in-app Save/Load buttons + native file-picker dialog (GPU/UI-unwired). |
| ✅ | L | S | ROI keyboard shortcuts / text input for named creation | `tools/roi.py:1072-1098 (eventFilter)` | Ported the real silx ROI key bindings via pure `roi_key_action`: Enter→Validate (close polygon), Delete/Backspace/Ctrl+Z→UndoLast (`removeRoi(rois[-1])`). silx has no key→shape map and no named-creation dialog (those were speculative); ROI naming is via the manager (`ManagedRoi.name`). Wired into the RoiCreate block; mapping headlessly tested. |

## Mask tools (image + scatter)  — 25✅ 2◐ 0☐

siplot's mask tools are comprehensive. `MaskToolsWidget` (image) and `ScatterMaskWidget` (scatter points by index) both implement multi-level masks (0 unmasked, 1..=255 levels), the full drawing-tool set (pencil/eraser brush with Bresenham line interpolation + variable width, rectangle, polygon, ellipse), threshold masking (below/between/above), NaN masking, per-level invert/clear, undo/redo history (silx `BaseMask`), per-level color overrides, a transparency slider, the Mask/Unmask + Ctrl toggle, and a pan/browse (no-draw) tool — all rendered by `show_toolbar` and faithful to silx `_BaseMaskToolsWidget`. The colormapped overlay (`mask_overlay_lut`) renders the masked pixels. Remaining ◐: mask file save/load have the `.npy` AND `.edf` paths (`write_npy`/`read_npy`, `write_edf`/`read_edf`, sharing the `apply_loaded_mask` crop/pad owner) but no file dialog and no TIFF/HDF5/csv/msk formats (external-crate-bound, deferred under the no-new-dep pattern).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Multi-level masks (1-255 levels) | `_BaseMaskToolsWidget.py:279, 583-585` | `MaskToolsWidget` masks a per-pixel `u8` grid: 0 unmasked, 1..=255 are up to 254 non-overlapping levels; drawing writes the current `level` (highlighted at full alpha, others half). `ScatterMaskWidget` is the 1D analogue. Tested. |
| ✅ | H | L | Scatter mask variant (1D point masking) | `ScatterMaskToolsWidget.py (entire file, 643 lines)` | `ScatterMaskWidget` (scatter_mask.rs): a multi-level mask over scatter points by index (1D `u8` buffer, one per point), same level/invert/clear/undo semantics as the image mask but over point coordinates (silx `ScatterMaskToolsWidget`). Tested. |
| ✅ | H | M | Drawing tool: Rectangle | `MaskToolsWidget.py:805-826, _BaseMaskToolsWidget.py:307-317, shapes.polygon_fill_mask` | `MaskTool::Rectangle` → `DrawMode::Rectangle`; `handle_shape_draw` + `rect_params_to_cells` (silx int truncation) → `update_rectangle` on finish. Live row 194. Tested. |
| ✅ | H | M | Drawing tool: Polygon | `MaskToolsWidget.py:840-847, _BaseMaskToolsWidget.py:319-326, shapes.polygon_fill_mask` | `MaskTool::Polygon` → click-to-add/snap-close draw; `polygon_vertices_to_cells` + `polygon_fill_mask` (silx `(y,x)` swap) → `update_polygon` on finish. Live row 195. Tested. |
| ✅ | H | M | Drawing tool: Ellipse | `MaskToolsWidget.py:828-838, _BaseMaskToolsWidget.py:351-361, shapes.ellipse_fill` | `MaskTool::Ellipse` → `ellipse_params_to_cells` (center int, y-semi→radius_r, x-semi→radius_c) → `update_ellipse` on finish. Live row 196. Tested. |
| ✅ | H | M | Undo/Redo history | `_BaseMaskToolsWidget.py:144-194 (resetHistory, commit, undo, redo), 609-629 (undo/redo actions)` | `MaskHistory` (bounded snapshot stack, default depth = silx `historyDepth`) with `commit`/`undo`/`redo` mirroring silx `BaseMask` (`_history`/`_redo`, redo cleared on commit, undo needs >1 snapshot); `can_undo`/`can_redo` = silx `sigUndoable`/`sigRedoable`. Tested. |
| ✅ | H | M | Threshold-based masking (below, between, above) | `_BaseMaskToolsWidget.py:265-294 (updateBelowThreshold, updateBetweenThresholds, updateAboveThreshold), 848-937 (threshold UI), 1204-1225 (apply handler)` | `ThresholdMode::{Below, Between, Above}` + `update_threshold` (below→data<min, between→min≤data≤max, above→data>max) at the current level; ImageView toolbar has the mode selector + min/max fields + Apply. Live row 199. Tested. |
| ◐ | H | M | Mask save to file (npy/edf/tif/h5/csv/msk) | `MaskToolsWidget.py:104-141 (save method: edf/tif/npy/h5/msk), 698-785 (_saveMask dialog and format handling)` | `MaskToolsWidget::write_npy` writes a 2D `uint8` `.npy` (silx `save(.., "npy")`); `write_edf`/`save_edf`/`save_mask_edf` now write fabio-style EDF (`render::save::encode_mask_edf`, hand-written, no new dep). Remaining ◐: no file dialog and no TIFF/HDF5/csv/msk encoders (external-crate-bound). Live row 200. |
| ◐ | H | M | Mask load from file (npy/edf/tif/h5/csv/msk) | `MaskToolsWidget.py:589-629 (load method), 630-677 (_loadMask dialog)` | `read_npy` and `read_edf` read a 2D `uint8` mask and apply it through the shared `apply_loaded_mask` owner (crop/pad to the image, committed to undo; silx `load` npy/edf branches); `decode_mask_edf` parses the fabio `{…}` header. Remaining ◐: no file dialog and no TIFF/HDF5/csv/msk decoders (external-crate-bound). |
| ✅ | H | M | Scatter mask disk/circle drawing | `ScatterMaskToolsWidget.py:137-148 (updateDisk), 585-612 (pencil event handling)` | `ScatterMaskWidget::update_disk` masks points inside a disk: stencil `(y-cy)²+(x-cx)² < radius²` (silx `updateDisk`). Tested. |
| ✅ | H | M | Scatter mask polygon selection | `ScatterMaskToolsWidget.py:107-122 (updatePolygon with point-in-polygon test)` | `ScatterMaskWidget` masks points via `update_stencil` over a `point_in_polygon` test of each point (silx `Polygon.is_inside`). Tested. |
| ✅ | H | S | Mask level spinbox (1-255) with tooltip | `_BaseMaskToolsWidget.py:583-592` | `MaskToolsWidget::show_toolbar` renders a `Slider` over `level` 1..=255 (silx `levelSpinBox`; siplot uses a slider rather than a spinbox); the selected level is the highlighted/edited one. |
| ✅ | M | M | Mask visibility overlay with colormap | `MaskToolsWidget.py:371-392 (_updatePlotMask creates MaskImageData item), _BaseMaskToolsWidget.py:984-1010 (_setMaskColors)` | `colormap.rs` `mask_overlay_lut` builds the per-level discrete LUT (selected level full alpha, others half, level 0 transparent — faithful `_setMaskColors`); `mask_tools.rs` `apply()` uploads it as an overlay image at active-image z+1. Tested. GPU-unverified on-screen. |
| ✅ | M | M | Mask display sync on active item change | `MaskToolsWidget.py:402-433 (showEvent/hideEvent), 434-443 (_activeImageChanged)` | `ImageView::set_image` auto-calls `mask.reset_geometry(width,height)` on a dimension change before re-upload — the silx `ImageView`+mask parity target syncs automatically. (The standalone low-level `MaskToolsWidget` still needs a manual `reset_geometry`.) |
| ✅ | M | S | Drawing tool: Pencil/Brush with variable width | `_BaseMaskToolsWidget.py:822-846 (pencilSpinBox 1-1024, slider 1-50), MaskToolsWidget.py:854-876` | `7b6f263`: a 1-50 slider AND a 1-1024 `DragValue` spin box, both binding the same `brush_size` (egui keeps them in lockstep, ≈ silx `_pencilWidthChanged`), in both mask-draw toolbars. GPU-unverified on-screen. |
| ✅ Done | M | S | Drawing tool: Eraser (unmask) | `_BaseMaskToolsWidget.py:806-810 (maskStateGroup radio buttons Mask/Unmask, Ctrl modifier toggles)` | `d85c751`: Mask/Unmask radio (`mask_state`) plus `effective_do_mask = base ^ ctrl` (silx `_isMasking()`); holding Ctrl (egui `command`, = Cmd on macOS per Qt) inverts every draw, captured once per pencil stroke. siplot's mask stays boolean (no 1-255 levels). Toolbar GPU-unverified |
| ✅ | M | S | Clear mask (per level or all) | `_BaseMaskToolsWidget.py:198-205 (clear method), 655-670 (Clear/Clear All actions)` | `mask_tools.rs` `clear()` (current level only, silx `clear(level)`) + `clear_all()` (all levels), surfaced as "Clear" + "Clear All" buttons in `MaskToolsWidget::show_toolbar`. (ImageView's mask toolbar exposes `clear_all` only.) |
| ✅ Done | M | S | Invert mask (per level) | `_BaseMaskToolsWidget.py:207-218, 645-653 (invert action and handler)` | `ImageMask::invert` swaps masked/unmasked pixels (boolean mask, so a single "level"); `d85c751` adds the silx Ctrl+I shortcut via `consume_key(COMMAND, Key::I)` alongside the existing invert button. `invert_swaps_zero_and_current_level_only` tested. Toolbar GPU-unverified |
| ✅ | M | S | Mask NaN/non-finite values | `_BaseMaskToolsWidget.py:296-304 (updateNotFinite), 941-952 (button)` | `mask_tools.rs` `mask_not_finite` (`!v.is_finite()` stencil, silx `updateNotFinite`); "mask non-finite" button in ImageView's and ScatterView's mask toolbars. Tested. (Standalone `MaskToolsWidget::show_toolbar` does not carry this button.) |
| ✅ | M | S | Mask transparency/alpha slider | `_BaseMaskToolsWidget.py:554-577 (slider range 3-10, affects alpha in _setMaskColors)` | `badfbee`/`f3cbd95`: transparency slider in `MaskToolsWidget::show_toolbar` routes through the tested `set_transparency` (silx `transparencySlider` → `_setMaskColors`: selected level at this alpha, others at half) and re-renders the overlay LUT. (Same as row 202.) GPU-unverified on-screen. |
| ✅ | M | S | Mask interaction mode vs pan/zoom | `_BaseMaskToolsWidget.py:1115-1161 (mode activation sets plot.setInteractiveMode()), 1096-1106 (interaction mode changed handler)` | `ImageView::set_mask_draw(true)` switches the image plot to `PlotInteractionMode::MaskDraw` + `MaskTool::Pencil` (and restores Zoom/None on exit), so activating the mask tool disables pan/zoom automatically — `actions::mode::mask_draw_mode`. (The low-level standalone widget still relies on the caller setting the mode.) |
| ✅ Done | M | S | Mask/Unmask mode toggle with Ctrl modifier | `_BaseMaskToolsWidget.py:790-810 (radio buttons), 1169-1178 (_isMasking checks Ctrl)` | `d85c751`: `mask_state` Mask/Unmask radio + Ctrl-modifier inversion (`effective_do_mask = base ^ ctrl`, silx `_isMasking()`). Truth-table tested; toolbar GPU-unverified |
| ✅ | M | S | Load colormap range button | `_BaseMaskToolsWidget.py:883-892, 880-896 (MaskToolsWidget override)` | `0d83368`: "Min-max from colormap" button in ImageView's threshold row copies `colormap.vmin/vmax` (the effective post-autoscale range) into `mask.threshold_min/max`. Threshold masking itself (`update_below/between/above`) was already present. GPU-unverified on-screen. |
| ✅ | M | S | Pencil line interpolation between drag events | `MaskToolsWidget.py:849-876 (updateLine if lastPencilPos != current)` | `mask_tools.rs` `paint_pencil_point` tracks `last_pencil_pos` and calls `update_line` (Bresenham, width = brush size) from the previous sample before stamping the disk, so a fast drag leaves no gap (silx `updateLine`). Tested. |
| ✅ | L | S | Custom mask color per level (per-level RGB) | `_BaseMaskToolsWidget.py:394-398 (_defaultColors, _overlayColors arrays), 973-982 (getCurrentMaskColor), 1026-1046 (setMaskColors, getMaskColors)` | `overrides: Vec<Option<[u8;3]>>` + `set_mask_colors`/`reset_mask_colors` (faithful `setMaskColors`/`resetMaskColors`, per-level or all) flow through `mask_overlay_lut`; `current_mask_color` mirrors `getCurrentMaskColor`. `MaskToolsWidget::show_toolbar` exposes a per-level color swatch + "Reset color" button. Helpers tested; toolbar swatch GPU-unverified. |
| ✅ | L | S | Pan/Browse tool (no drawing) | `_BaseMaskToolsWidget.py:720-722 (PanModeAction added)` | `MaskTool::None` ("○", tooltip "Disable masking") in `show_toolbar` is the explicit no-draw tool (the default), leaving pan/zoom active — silx's Browse/pan button. |
| ✅ | L | S | Pencil size sync (spinbox + slider) | `_BaseMaskToolsWidget.py:825-846, 1057-1069` | A 1-50 `Slider` AND a `DragValue` spin box both bind the same `brush_size` (egui keeps them in lockstep, silx `_pencilWidthChanged`), in `show_toolbar` and the ImageView mask toolbar. Same as row 1209. |

## Actions, Toolbars, Tool Buttons  — 30✅ 1◐ 0☐

siplot ships a rich unified toolbar via `show_toolbar()` / `show_toolbar_with()`, with `ToolbarResponse` exposing every state change. Implemented actions: Reset Zoom, Zoom In/Out, Zoom Back (limits-history pop), Select/Pan/Zoom modes, X/Y axis autoscale toggles, X/Y log scale, X/Y invert, Major/Minor grid, Keep-aspect, Show-axis, Crosshair, Curve Style cycle, Save (PNG/PPM/SVG/TIFF + CSV), Copy (clipboard), Print, Median Filter (1D/2D), Pixel-Intensity Histogram, and the Data-Aggregation mode selector (None/Max/Mean/Min). Colormap and colorbar have dedicated widgets (`ColormapDialog`, `ColorBarWidget`); profile/symbol tool buttons exist (`ProfileToolButton`, `SymbolToolButton`); `LimitsWidget` exposes editable axis-limit fields. silx's separate Qt composite toolbars (Interactive/Output/Curve/Image) are delivered as one immediate-mode `show_toolbar` + standalone widgets — a UI-paradigm difference, not missing features. The `PanWithArrowKeysAction` enable/disable toggle is now wired (`Plot::set_pan_with_arrow_keys` + `actions::control::toggle_pan_with_arrow_keys`, default-on per silx). The `RulerToolButton` now exists as a reusable checkable widget with a tested distance core (◐ — the live line-ROI distance label is host integration, not yet wired end-to-end), and the zoom-axes menu (silx `ZoomEnabledAxesMenu`) is wired. The Curve Style action now mirrors silx's plot-wide `(lines,points)` boolean cycle (`PlotWidget::cycle_curve_style` → `set_default_plot_lines`/`set_default_plot_points`), replacing the earlier concrete-`LineStyle` divergence. Remaining ◐: the `RulerToolButton` live label only. The OpenGL-vs-Matplotlib backend toggle is N/A (single wgpu backend).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Save Action (PNG/SVG/data formats) | `actions/io.py:77-747` | `SaveTarget` (`Figure(SaveFormat)` = PNG/PPM/SVG/TIFF, `CurveCsv` = data) + `from_extension`/`from_path`; the `Save` toolbar button (`ToolbarIcon::Save`) calls `save_graph`/`save_graph_with_format`. The figure GPU readback is the untestable shim. |
| ✅ | H | M | Zoom Back (limits history pop) | `actions/control.py:101-122` | `actions/control.rs` `zoom_back` pops `Plot::limits_history` (unbounded stack, pushed before each zoom/pan); wired to the `ToolbarIcon::ZoomBack` button (silx `ZoomBackAction` / `getLimitsHistory().pop()`). |
| ✅ | H | M | X Axis Autoscale (separate toggle) | `actions/control.py:172-197` | `actions/control.rs` `toggle_x_autoscale` flips `Plot::x_autoscale` and re-fits (silx `XAxisAutoScaleAction`, checkable); surfaced via `ToolbarIcon::AutoscaleX` + `ToolbarResponse::autoscale_x_changed`. |
| ✅ | H | M | Y Axis Autoscale (separate toggle) | `actions/control.py:199-224` | `actions/control.rs` `toggle_y_autoscale` flips `Plot::y_autoscale` and re-fits (silx `YAxisAutoScaleAction`); surfaced via `ToolbarIcon::AutoscaleY` + `ToolbarResponse::autoscale_y_changed`. |
| ✅ | H | M | OutputToolBar composite (Copy/Save/Print) | `tools/toolbars.py:71-113` | The Copy (`rgba_to_clipboard_image`), Save (`save_graph`), and Print (`print_graph`) actions are all present as `ToolbarIcon::Copy`/`Save`/`Print` buttons in the unified `show_toolbar` (siplot uses one toolbar rather than silx's separate QToolBar composites). |
| ✅ | M | L | Pixel Intensities Histogram Action | `actions/histogram.py:1-180+` | `actions/analysis.rs` `pixel_intensity_histogram` (silx Histogramnd + nanmean/nanstd/nansum → `PixelHistogram`); `ImageView::show_pixel_histogram_toolbar` opens a detached tool window (`detached::show_detached`) with live bars + stats + editable bin DragValue. GPU-unverified on-screen. |
| ✅ | M | M | Curve Style Cycling (lines/lines+marks/marks) | `actions/control.py:317-350` | `actions/control.rs` `curve_style_cycle` → `PlotWidget::cycle_curve_style` now mirrors silx's plot-wide `(lines,points)` boolean cycle: `(F,F)→(T,F)`, otherwise `(T,F)→(T,T)→(F,T)→(T,F)` (pure `next_curve_style_state`, silx `CurveStyleAction._actionTriggered`). It applies the new defaults to every curve via `set_default_plot_lines`/`set_default_plot_points` (silx `setDefaultPlotLines`/`setDefaultPlotPoints`: linestyle `"-"`/`" "`, symbol `"o"`/`""` on all `getAllCurves` items, i.e. `PlotItemKind::Curve`). State lives in `PlotWidget::default_plot_lines`/`default_plot_points` (silx init `True`/`False`), exposed via `is_default_plot_lines`/`is_default_plot_points`; toolbar `ToolbarIcon::CurveStyle` wired. Per-curve lines/points toggles remain separately via `set_curve_lines_visible`/`set_curve_points_visible`. Transition tested headlessly; the per-curve GPU re-apply is GPU-unverified. |
| ✅ | M | M | Colorbar Toggle (ColorBarAction) | `actions/control.py:452-488` | `actions/control.rs` `image_colorbar_toggle`/`scatter_colorbar_toggle` flip `show_colorbar`; surfaced as a selectable "colorbar" toolbar control in ImageView/ScatterView. |
| ✅ | M | M | Zoom Mode Action (with optional axes menu) | `actions/mode.py:45-106, toolbars.py:50-51` | Zoom mode button (checkable) plus a "Zoom axes" menu in `show_toolbar` (silx `ZoomEnabledAxesMenu`): X-axis / Y-axis checkboxes (default on) drive `Plot::set_zoom_enabled_axes`/`zoom_x_enabled`/`zoom_y_enabled`. A box zoom is constrained via the pure `interaction::constrain_zoom_axes` (silx `Zoom._getAxesExtent`: a disabled axis keeps its current range), wired at the box-zoom commit (`plot_widget.rs`), `ToolbarResponse::zoom_axes_changed`. siplot box zoom is left-axis only, so there is no y2 entry (silx's third). Constraint + state core tested; menu GPU/UI-unverified. |
| ✅ | M | M | Copy to Clipboard Action | `actions/io.py:848-927` | `PlotWidget::copy_to_clipboard(size)` (`high_level.rs:6379`) renders the figure to RGBA and writes it via `arboard::Clipboard` (`io::rgba_to_clipboard_image`); toolbar `ToolbarIcon::Copy` button wired (`:5552`, "Copy figure to clipboard"). The off-screen GPU render shares save_graph's headless-unverified boundary; the copy + clipboard-write path is implemented. |
| ✅ | M | M | InteractiveModeToolBar composite | `tools/toolbars.py:37-69` | Select/Pan/Zoom mode actions (`actions/mode.rs` `select_mode`/`pan_mode`/`zoom_mode`) render as `ToolbarIcon::Select`/`Pan`/`Zoom` in the unified `show_toolbar` (`ToolbarResponse::interaction_mode_changed`); `show_toolbar_with` allows custom button sets. egui immediate-mode UI has no Qt composable-QToolBar paradigm. |
| ✅ | M | M | CurveToolBar composite | `tools/toolbars.py:179-227` | All constituents exist in the unified `show_toolbar`: Reset Zoom (`Home`), X/Y Autoscale (`AutoscaleX`/`AutoscaleY`), Grid (`Grid`/`MinorGrid`), Curve Style (`CurveStyle`), Crosshair (`Cursor`). The stale "missing Curve Style and per-axis Autoscale" no longer holds. |
| ✅ | M | M | LimitsToolBar (editable X/Y min/max fields) | `tools/LimitsToolBar.py:35-123` | `PlotWidget::show_limits_toolbar` (`high_level.rs`): a `Limits: X: [min][max] Y: [min][max]` row of `DragValue` fields that always reflect the effective limits (silx `limitsChanged` slot) and, on edit, apply via `set_graph_x_limits`/`set_graph_y_limits` ordered min ≤ max (silx `_xFloatEditChanged` swap, `ordered_limits`, tested). Y edits the primary axis. |
| ✅ | M | S | Zoom In (1.1x factor) | `actions/control.py:124-146` | `control::zoom_in` → `apply_zoom(plot, ZOOM_STEP)` with `ZOOM_STEP = 1.1` (silx `applyZoomToPlot` factor) about the view center; toolbar `ToolbarIcon::ZoomIn` button wired (`high_level.rs:5370`); tested (`control.rs:272`). |
| ✅ | M | S | Zoom Out (1/1.1x factor) | `actions/control.py:148-170` | `control::zoom_out` → `apply_zoom(plot, 1.0 / ZOOM_STEP)`; toolbar `ToolbarIcon::ZoomOut` button wired (`high_level.rs:5374`); tested (`control.rs:278`). |
| ✅ | L | L | Print Action | `actions/io.py:747-847` | `print_graph` (rasterize → temp PNG via `print_temp_png_path` → printer) wired to the `ToolbarIcon::Print` button (`ToolbarResponse::print`). The GPU readback + printer submit are native shims (as with Save's figure readback); the in-app print-*preview* dialog is tracked separately (Stats/Print section). |
| ✅ | L | L | Median Filter Actions (1D/2D) | `actions/medfilt.py:49-150+` | `actions/analysis.rs` `median_filter_1d` and `median_filter_2d` (silx `MedianFilter1DAction`/`MedianFilter2DAction`); the `ToolbarIcon::MedianFilter` button toggles it. |
| ✅ Done | L | L | ScatterVisualizationToolButton | `PlotToolButtons.py:480-549` | `4477bc2`: `ScatterView` toolbar ComboBox over `ScatterVisualization::ALL` (silx order) → `set_visualization`. Note: the SOLID/GRID/triangulation *renderers* remain unimplemented (rows 1080/1086); only POINTS renders today, so non-POINTS modes are selectable but render as points. Toolbar GPU-unverified |
| ✅ | L | M | Pan with Arrow Keys Toggle | `actions/control.py:603-625` | `Plot::pan_with_arrow_keys` flag (default `true`, silx `_panWithArrowKeys = True`) with `set_pan_with_arrow_keys` (silx `setPanWithArrowKeys`); `plot_widget.rs` gates the arrow-key handler on it (silx `if self._panWithArrowKeys`); `actions::control::toggle_pan_with_arrow_keys` is the checkable `PanWithArrowKeysAction`. Arrow-key panning itself via `interaction.rs` `apply_pan` + `PanDirection`. |
| ✅ | L | M | Show Axis Toggle | `actions/control.py:627-651` | `actions/control.rs` `show_axis_toggle` flips `Plot::axes_displayed` (silx `ShowAxisAction`/`setAxesDisplayed`); surfaced via `ToolbarIcon::ShowAxis` + `ToolbarResponse::show_axis_changed`. Hidden axes collapse the chrome gutters. |
| ✅ | L | M | Close Polygon Interaction Action | `actions/control.py:653-683` | `DrawState::validate` (silx `_validate`) closes the in-progress polygon at its committed vertices; wired in `apply_interaction` to the Enter key while a `RoiCreate(Polygon)` draw is active. `len > 2` gate matches the snap-close path; no-op otherwise. `validate` unit-tested; key wiring GPU-unverified. |
| ✅ | L | M | Data Aggregation Mode Selector | `actions/image.py:45-104` | `ImageView::set_aggregation` + a `selectable_value` menu (None/Max/Mean/Min, `high_level.rs:8029-8032`) drive `AggregationMode` + GPU `aggregate_blocks` (silx `AggregationModeAction` / `ImageDataAggregated`). |
| ✅ | L | M | ProfileToolButton (1D/2D profiles) | `PlotToolButtons.py:304-392` | `widget::tool_buttons::ProfileToolButton` — a standalone dropdown for the 1D/2D dimension toggle (silx STATE labels/tooltips, `set_dimension`/`dimension`, `ui() -> Option<u8>`). Complements the existing None/H/V/L/R profile-mode selector. State core tested; popup GPU/UI-unverified. |
| ✅ | L | M | SymbolToolButton (marker/size) | `PlotToolButtons.py:458-478` | `widget::tool_buttons::SymbolToolButton` — a standalone dropdown over `Symbol::ALL` with a 1..=20 size slider (default 6.0), emitting `SymbolToolChange`. State core (symbol + clamped size) tested; popup GPU/UI-unverified. |
| ◐ | L | M | RulerToolButton (distance measurement tool) | `tools/RulerToolButton.py:83-180+` | `widget::tool_buttons::RulerToolButton` — a checkable button (silx `setCheckable`/`isChecked`/`toggled`) plus the pure distance formatter `distance_text` (silx `buildDistanceText`: Euclidean norm of the two data endpoints, formatted `" .1f"` + `px`), tested headlessly. Same reusable-widget split as `ProfileToolButton`/`SymbolToolButton`: silx's `_RulerROI` maps to siplot's existing line-ROI draw; while active the host enters that draw and labels the line via `distance_text`. Residual: the live-updating distance label on the drawn line ROI is host integration, not yet wired end-to-end. |
| ✅ | L | S | Grid Toggle (both/major modes) | `actions/control.py:284-315` | `Plot::grid` (`GraphGrid::None`/`Major`/`MajorAndMinor`) toggled via `ToolbarIcon::Grid` + `MinorGrid` (two buttons, `grid_changed`/`minor_grid_changed`), covering silx `GridAction`'s major-vs-both modes. |
| ✅ N/A | L | S | OpenGL Backend Toggle | `actions/control.py:685-753` | N/A — silx `OpenGLAction` switches between its Matplotlib and OpenGL backends; siplot has a single wgpu backend, so there is no backend to toggle (the gap note itself says "no backend selection UI needed"). |
| ✅ | L | S | ImageToolBar composite | `tools/toolbars.py:115-177` | Constituents present: Reset Zoom (`Home`), Colormap (`ColormapDialog`), Aspect (`Aspect`), X/Y axis origin (`InvertX`/`InvertY`), plus a colorbar (`ColorBarWidget`) — via the unified `show_toolbar` + standalone widgets rather than a Qt composite class. |
| ✅ | L | S | AspectToolButton (menu: keep/don't keep) | `PlotToolButtons.py:57-125` | Keep-aspect is a toggle button (`ToolbarIcon::Aspect` "1:1" + `ToolbarResponse::aspect_changed`) over `Plot`'s keep-aspect state — the functional equivalent of silx's two-action menu (toggle vs menu is a UI-shape difference). |
| ✅ | L | S | XAxisOriginToolButton (menu: invert/non-invert X) | `PlotToolButtons.py:193-199` | X-axis inversion is a toggle button (`ToolbarIcon::InvertX` + `ToolbarResponse::x_inverted_changed`) — the functional equivalent of silx's menu-style invert/non-invert button (toggle vs menu is a UI-shape difference). |
| ✅ Done | L | S | ProfileOptionToolButton (sum/mean) | `PlotToolButtons.py:227-302` | `340b80c`: the `ProfileWindow` toolbar row has a Mean/Sum ComboBox driving `ProfileMethod` (functional equivalent of silx's menu button; in-window rather than a discrete tool-button popup). Window UI GPU-unverified |

## Composite views (ImageView/ScatterView/StackView/CompareImages/ComplexImageView/ImageStack)  — 25✅ 4◐ 5☐

siplot implements all six composite views. ImageView has side histograms, axis sync, the `RadarView` position-overview, profile-toolbar integration (embedded + popup `ProfileWindow`), an intensity-histogram API (`active_image_histogram`), and aggregation modes. ScatterView has value-coloured scatter, an X/Y/Data/Index position-info panel, the `ScatterMaskWidget` per-point mask tool, and line-profile extraction across the scatter (`line_profile`, silx `ScatterProfileToolBar`). StackView browses frames plus 3D volumes (perspective/axis selection, auto-transposition, per-dimension labels, and per-frame max/mean/min block aggregation). CompareImages offers A/B, vertical-line (`HalfHalf`) and horizontal-line (`SplitHorizontal`) separators, A−B subtract, red-blue-gray RGB composite modes (`RedBlueGray`/`RedBlueGrayNeg`), and origin/center/stretch image alignment for differently-shaped A and B (`CompareAlignment`: zero-pad to a common max grid, or bilinear-resample B to A's shape). ComplexImageView renders the full `ComplexMode` set with a mode toolbar and amplitude-range controls. ImageStack provides a frame browser with a selectable table, per-frame visibility, and a waiting overlay. CompareImages also has a coordinate/value status bar (`show_status_bar`, silx `CompareImagesStatusBar`). Remaining true gaps concentrate in CompareImages' SIFT/auto-alignment tooling — SIFT keypoint detection + visibility toggle and affine-transform tracking, which silx populates only from the SIFT path (`getTransformation` is `None` for origin/center/stretch); all need a heavy computer-vision dependency — plus the interactive UI residuals on ScatterView's scatter-profile and StackView's full 3D-profile toolbar (◐, extraction cores done), and ImageStack's URL-backed lazy loading / prefetch (file-IO, out of scope).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | M | L | ImageView: colorbar display | `ImageView.py:501` | `ImageView::colorbar()` (silx `getColorBarWidget`) builds a side `ColorBarWidget` from the active colormap; `ImageView::show` renders it (`high_level.rs:8295`) at the image's right (silx grid (0,2)). Visibility via `set_show_colorbar`; tested (`:11467`). |
| ✅ | M | L | ScatterView: colorbar widget | `ScatterView.py:83-88` | `ScatterView::colorbar() -> Option<ColorBarWidget>` (silx `getColorBarWidget`, `None` when no value colormap); `ScatterView::show` renders it to the right (`high_level.rs:9588`). Tracks the scatter value limits; tested (`:11484`). |
| ✅ | M | L | StackView: perspective selection (axis selection for 3D browsing) | `StackView.py:364-397` | `StackPerspective` + `perspective_ui` combo browses dimension 0, 1, or 2; `set_volume`+`stack_frame` auto-transpose per perspective |
| ✅ | M | M | ImageView: show/hide side histograms | `ImageView.py:552-559` | `is_side_histogram_displayed()` / `set_side_histogram_displayed(show)` (silx `isSideHistogramDisplayed`/`setSideHistogramDisplayed`); when hidden, `ImageView::show` (`high_level.rs:8228`) reserves no top/right strip or radar and the image reclaims that space. |
| ✅ | M | M | ImageView: valueChanged signal (pixel/histogram hover) | `ImageView.py:381-390,585-646` | `ImageView::value_changed() -> Option<(f64,f64,f64)>` (silx `valueChanged` `(col,row,value)`), computed by `image_value_at` from `self.cursor` (updated each frame in `show` at `high_level.rs:8305`); `None` off-image / before any pointer move (silx emits nothing then). Tested (`:10759`). |
| ✅ | M | M | ScatterView: position info panel (X, Y, Data, Index) | `ScatterView.py:90-101` | `show_position_info` shows X/Y/Data/Index, snapping X/Y/value/index to the picked scatter point (pixel-space pick, "-" when none) |
| ✅ | M | M | StackView: 3D transposition and dimension swapping | `StackView.py:409-441` | `stack_frame` re-slices the volume per perspective (silx transpose (1,0,2)/(2,0,1)); `set_perspective` rebuilds frames + axis labels |
| ✅ | M | M | CompareImages: vertical line separator (vline mode) | `CompareImages.py:124-133,397-407` | `CompareMode::HalfHalf` (silx `VisualizationMode.VERTICAL_LINE`): a draggable left/right split at the `split` fraction — A left of the separator, B right (`high_level.rs:7097`, render `:7297`). |
| ✅ | M | M | CompareImages: horizontal line separator (hline mode) | `CompareImages.py:135-144,397-407` | `CompareMode::SplitHorizontal` (silx `VisualizationMode.HORIZONTAL_LINE`): a draggable top/bottom split at the `split` fraction — A above the separator, B below (render `high_level.rs:7303`). |
| ✅ | L | L | ImageView: RadarView (position overview widget) | `ImageView.py:486-490,494-500` | `RadarView` mini-map: ImageView wires `set_data_bounds`/`set_viewport_limits` and renders the draggable viewport rect via `RadarView::ui`, dragging back through `set_limits` (silx full-range + current-viewport overview). |
| ✅ | L | L | ImageView: profile toolbar integration | `ImageView.py:451-453,692-697` | `show_profile_toolbar` (`ProfileMode` None/Horizontal/Vertical/Line/Rectangle) drives interactive profile extraction (`horizontal/vertical/line/rect_profile_values`), with profile curves added via `add_horizontal/vertical_profile_curve` (silx `ProfileToolBar`). |
| ✅ | L | L | ScatterView: mask tools widget (ScatterMaskToolsWidget) | `ScatterView.py:116-122` | `ScatterMaskWidget` (the silx `ScatterMaskToolsWidget` equivalent): a 1D per-point u8 mask edited by disk stencil (`update_disk`) or polygon (`update_stencil` + `point_in_polygon`) over scatter data. |
| ◐ | L | L | ScatterView: scatter profile toolbar | `ScatterView.py:141,306` | Extraction wired: `core::scatter_viz::scatter_line_profile` + `ScatterView::line_profile(start, end, n_points)` port silx `ScatterProfileToolBar::_computeProfile` (`tools/profile/rois.py:737-762`) — `numpy.linspace` samples interpolated through the scatter's Delaunay mesh (`LinearNDInterpolator` via `delaunay` + `interpolate`), `None` outside the convex hull (silx `NaN`), returning a `ScatterLineProfile`. Pure extraction headlessly tested (affine field exact along the line, outside-hull all-None, <3-points all-None). Still no `ScatterProfileToolBar` line-ROI tool or side profile-plot display (GPU/UI). |
| ◐ | L | L | StackView: profile 3D toolbar (extract profiles over all frames) | `StackView.py:84,948-951` | Extraction wired (row 222): `StackView::stack_aligned_profile`/`stack_line_profile` project a profile across all frames of the loaded volume via the row-253 cores. Still no `Profile3DToolBar` buttons or side-plot display of the stacked profile (GPU/UI). |
| ✅ | L | L | StackView: calibration support (per-axis scale/origin) | `StackView.py:551,565-566` | `core::Calibration` {None, Linear{constant,slope}} ports the affine subset of `silx.math.calibration` (`get_slope`/`is_affine`/`__call__`); `StackView::set_calibrations([Calibration;3])`/`calibrations()`/`calibrations_axes()`/`image_z()` mirror `getCalibrations(order=array/axes)` + `_getImageOrigin/Scale/Z`. The displayed image add applies the calibrated origin (`calib(0)`) + scale (slope) via `add_image_with_geometry`. Pure axes-ordering + geometry + Z headlessly tested; GPU image placement unverified |
| ✅ | L | L | CompareImages: composite RGB modes (red-blue-gray channels) | `tools/compare/core.py:60-61` | `CompareMode::RedBlueGray`/`RedBlueGrayNeg` (silx `COMPOSITE_RED_BLUE_GRAY`/`_NEG`): pure `red_blue_gray_composite` normalises A and B through the shared colormap to 0..=255 and lays them out R=a, G=a/2+b/2, B=b (NEG inverts each channel), faithful to `__composeRgbImage` (CompareImages.py:744-751). Toolbar buttons + `build_composite` arms wired; channel layout headlessly tested. GPU render is the shared CompareImages shim. |
| ◐ | L | L | CompareImages: alignment modes (origin/center/stretch/auto) | `tools/compare/core.py:66-72` | `CompareAlignment` {Origin,Center,Stretch} (silx `AlignmentMode`): `set_images` now takes separate `(w,h)` shapes for A and B, and the pure `align_compare_images` places them on a common grid per mode — Origin/Center zero-pad to `max×max` via `margin_image` (top-left, or silx `size//2−shape//2` centering), Stretch keeps A and bilinearly resamples B to A's shape via `rescale_array` (silx `__rescaleArray`/`BilinearImage.c_funct`, corner-aligned, edge-clamped). `raw_pixel_data` remaps the cursor per mode (`compare_aligned_coords`). Toolbar alignment selector wired. 8 headless tests (margin origin/center, rescale identity/upscale, align per mode, coord remap). AUTO defers with SIFT (row 1284). GPU render is the shared CompareImages shim. |
| ☐ | L | L | CompareImages: SIFT keypoint detection and alignment | `CompareImages.py:45,350,517-518` | No keypoint detection; silx detects SIFT features, shows them as scatter overlay, computes affine transform |
| ✅ | L | L | ComplexImageView: complex-valued image display | `ComplexImageView.py:259-310` | `ComplexImageView` renders 2D complex data via `ComplexMode` — Absolute/SquareAmplitude/Phase/Real/Imaginary/Log10Amplitude scalar maps + AmplitudePhase/Log10AmplitudePhase HSV-RGBA composites (the full silx set). |
| ☐ | L | L | ImageStack: lazy frame loading from URLs | `ImageStack.py:148-200` | No ImageStack widget; silx loads HDF5/image URLs on demand with threading, prefetch queue, and progress overlay |
| ◐ | L | L | ImageStack: URL selection table and browser | `ImageStack.py:65-128,337-392` | The selectable frame table + browser (slider + per-row toggleable visibility) is implemented (silx `_ToggleableUrlSelectionTable` equivalent, `image_stack.rs`). Residual: frames are held in memory — URL-backed add/remove (`silx.io` `DataUrl`) is out of scope (file-IO). |
| ☐ | L | L | ImageStack: prefetch queue for smooth browsing | `ImageStack.py:251-293` | No prefetch mechanism; silx preloads next N frames in background threads |
| ✅ | L | M | ImageView: getHistogram() API | `ImageView.py:699-725` | `ImageView::active_image_histogram` returns the active image's intensity histogram (data + extent), plus `show_pixel_histogram`/`show_pixel_histogram_toolbar` and a toggleable side histogram (silx `getHistogram`). |
| ✅ | L | M | ImageView: profile window behavior (popup/embedded) | `ImageView.py:392-401,656-690` | Profiles render embedded inline via `show_profile_toolbar` (`ProfileMode` H/V/Line/Rectangle) and in a popup via the standalone `ProfileWindow` widget (silx embedded-vs-popup behavior). |
| ✅ | L | M | ImageView: aggregation mode action (for multi-band images) | `ImageView.py:434-438,529-548` | `ImageView::aggregation`/`set_aggregation` + a None/Max/Mean/Min `selectable_value` menu drive `AggregationMode` + GPU `aggregate_blocks` (silx `AggregationModeAction`). |
| ✅ | L | M | ScatterView: getSelectionMask() and setSelectionMask() | `ScatterView.py:412-418` | `selection_mask`/`set_selection_mask` give programmatic per-point selection (u8 levels); set is length-validated + history-committed |
| ✅ | L | M | StackView: frame number / dimension labels | `StackView.py:799-827` | `set_dimension_labels`/`dimension_labels` (silx setLabels/getLabels); custom labels for 3D dims rotate onto X/Y axes per perspective |
| ✅ | L | M | StackView: aggregation mode for multi-band frames | `StackView.py:301-305` | `StackView::aggregation`/`set_aggregation` + a None/Max/Mean/Min combo and per-axis `bx`/`by` block factors in `show_frame_controls` (silx `AggregationModeAction` → `_stackItem.setAggregationMode`); each displayed frame routes through an `ImageSpec` carrying the calibrated origin/scale + `aggregation`/`aggregation_block`, driving the GPU `aggregate_blocks` reducer. silx's `Aggregation` enum is exactly NONE/MAX/MEAN/MIN (the prior "mean/sum" note was stale — there is no SUM) and siplot matches it. Reuses the tested `aggregate_blocks` primitive; StackView needs a wgpu RenderState no test builds, so the wiring is GPU-unverified (parity with ImageView row 1291). |
| ☐ | L | M | CompareImages: affine transformation tracking | `CompareImages.py:880-889` | No getTransformation() API. silx returns the AffineTransformation (tx,ty,sx,sy,rot) ONLY from the SIFT/AUTO path — `__updateData` sets `self.__transformation = None` and it is reassigned only in `__createSiftData`, so getTransformation is `None` for origin/center/stretch. It is therefore coupled to SIFT (rows 1284/1297) and defers with it; there is no standalone pure affine for the non-SIFT modes. |
| ✅ | L | M | ComplexImageView: amplitude range dialog (max / delta in log10) | `ComplexImageView.py:50-155` | `ComplexImageView::show_amplitude_range_controls` + `set_amplitude_range_info`/`amplitude_range_info` adjust the displayed `(max_amplitude, delta)` (silx `_AmplitudeRangeDialog`: displayed_max + log10 dlogs) for the Log10AmplitudePhase composite. |
| ☐ | L | S | CompareImages: keypoint visibility toggle | `CompareImages.py:346-359` | No setKeypointsVisible() API; silx renders SIFT keypoints on top with separate colormap |
| ✅ | L | S | CompareImages: status bar with coordinate/value info | `CompareImages.py:191-193` | `CompareImages::show_status_bar` (silx `CompareImagesStatusBar`): the cursor data coordinate (tracked from the pointer event in `show`) plus the raw A / B pixel value under it via `CompareImages::raw_pixel_data` — which remaps the display coordinate to each raw image per the alignment mode (`compare_aligned_coords`) before the bounds-checked `compare_pixel_at` lookup (silx `getRawPixelData`) — formatted as silx `_formatData` `%f`. silx's affine-transform label is omitted: silx populates it only from the SIFT path (`getTransformation` is `None` for the origin/center/stretch modes siplot supports — rows 1283/1295). Lookup + format core tested; status-bar render GPU/UI-unverified. |
| ✅ | L | S | ComplexImageView: complex display mode toolbar | `ComplexImageView.py:157-256` | `ComplexImageView::show_mode_toolbar`/`show_mode_controls` present a `ComplexMode` selector (silx `_ComplexDataToolButton` dropdown: amplitude/phase/real/imag/...). |
| ✅ | L | S | ImageStack: waiting/loading overlay | `ImageStack.py:43` | A centred "waiting" spinner overlay is shown when the current frame slot is empty/`None` or hidden (silx `WaitingOverlay`, `image_stack.rs` module doc :20-23). |

## Stats, Legends, Profile, Fit, Position-Info, Print, Selection Dialogs  — 20✅ 0◐ 1☐

siplot implements a full stats engine (min/max/delta/mean/sum/center-of-mass/coord-min/coord-max under `All`/`OnLimits`/`for_curve_roi`/`for_image` scopes), the `StatsWidget` table UI, legend display with single-click row selection, eye-icon visibility toggles, and a right-click context menu (Wave 9), profile extraction helpers for horizontal/vertical/line/rect/cross ROI types (incl. profile-over-stack) with automatic window placement mirroring silx, a fit widget spanning linear/Gaussian/GaussianArea/Lorentzian/PseudoVoigt/multi-Gaussian models with fit-range UI and a results table (value ± error, reduced chi-square), a `PositionInfo` readout bar with custom converters, the `ItemsSelectionDialog`, and a limits dialog for axis control. Remaining gaps are narrow: the RadarView standalone-widget conflict (code has `RadarView::ui`, the live re-baseline still flags it — needs adjudication), and print preview (needs a printer/PDF path).

| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Stats engine: min/max/mean computation | `stats/stats.py:783-814` | `Stats` (stats.rs) computes the full silx `DEFAULT_STATS` set with finite-value filtering: min, max, delta (`max - min`), mean, sum, center-of-mass (`StatCOM`), and coord-min/coord-max (first argmin/argmax mapped to data coords). For curve and image scalars. Tested (`for_curve`/`for_image` cases). |
| ✅ | M | L | Stats context framework (data masking, bounds clipping, ROI filtering) | `stats/stats.py:143-600` | Bounds clipping (`StatScope::{All, OnLimits{x_range,y_range}}` + `Stats::for_curve`/`for_image`) AND ROI filtering (`Stats::for_curve_roi(xs, ys, from, to)`, plus arbitrary-shape per-ROI stats via `roi_stats`/`image_roi_stats`) cover silx `_StatsContext` in full. Verified against source: `_StatsContext`'s mask is ONLY the `onlimits` viewport rectangle (`_ImageContext`/`_CurveContext.clipData`: `int((v-origin)/scale)` index clip) or the `~roi.contains(points)` per-pixel ROI mask — there is **no** separate per-pixel user-mask kwarg (the prior "mask kwarg" note misread `numpy.ma.array(data, mask=clip_mask)`, where `clip_mask` is exactly that onlimits/roi clip). siplot mirrors both: `OnLimits` reproduces the index-window clip and `roi.contains` the per-pixel ROI mask. |
| ✅ | M | L | StatsWidget UI (scrollable table of stat rows, per-item, update mode toggle) | `StatsWidget.py:200-700` | `StatsWidget::ui` (`stats_widget.rs:175`): `ScrollArea::both` table, one row per `(label, item)` input, columns min/coords-min/max/coords-max/COM/mean/sum/delta (silx `DEFAULT_STATS` order) with `format_stat`/`format_coord` formatters, an Auto/Manual update-mode toggle (silx `UpdateMode`) + Manual "Update" button, and a "Visible data only" toggle. Active-vs-all live binding is tracked separately (rows 246/247). |
| ✅ | M | L | Stats: ROI-scoped statistics (compute stats within a selected ROI) | `stats/stats.py:68-140; ROIStatsWidget.py` | `Stats::for_curve_roi(xs, ys, from, to)` computes the full stat set over a curve x-range ROI; `roi_stats` (`image_roi_stats`/`curve_roi_stats`) computes per-ROI stats for arbitrary ROI shapes (image + curve), surfaced by `RoiStatsWidget` (rows above). Tested. |
| ✅ Done | M | M | Profile: line width / averaging method (mean vs sum) | `tools/profile/rois.py: _DefaultImageProfileRoiMixIn line_width, method properties` | `340b80c`: `ProfileWindow` exposes a Width DragValue + Mean/Sum ComboBox feeding the `line_profile_band`/`aligned_profile_values`/`rect_profile_values` extractors (band of N px, mean or sum). No longer mean-only. Window UI GPU-unverified |
| ✅ | M | M | Fit: gaussian fit (amplitude, center, width, background) | `silx.math.fit.fittheories: GaussianArea, etc.` | Both paths present: analytical `GaussianEstimateFit` AND iterative least-squares (`core::fitting::IterativeFit`, Levenberg-Marquardt → `IterativeFitResult` with covariance/chi-square). FitWidget model selector (`fit_widget.rs:366`) offers `IterativeGaussian`/`GaussianArea`/`Lorentzian`/`PseudoVoigt` (`PeakModel`), result via `iterative_result()`. |
| ✅ | M | M | Stats: on-limits computation (mask data to current X/Y viewport range) | `stats/stats.py:216-300; StatsWidget with onLimits checkbox` | `StatsWidget::on_visible_data` / `set_on_visible_data` (silx `setStatsOnVisibleData`) + "Visible data only" toggle; when on, `recompute` builds `StatScope::OnLimits { x_range, y_range }` from the passed viewport and `Stats::for_curve`/`for_image` clip to it. Tested (`recompute_on_visible_data_clips`). |
| ✅ | M | S | Fit: fit range selection UI (xmin, xmax for fitting region) | `silx.gui.fit: FitWidget with xmin/xmax input fields` | FitWidget `show` (`fit_widget.rs:383-401`): a checkbox toggles whole-curve vs a restricted `[xmin, xmax]` window, two `DragValue`s edit the bounds (seeded from the data's finite x extent via `default_fit_range`). Only points inside the window are fitted (`in_range_points`, public `set_fit_range`). |
| ✅ | L | L | Profile: profile-over-stack (slice extraction across stack dimension) | `tools/profile/rois.py:1058-1165 (ProfileImageStack* classes)` | `high_level::StackProfile` + `stack_aligned_profile`/`stack_line_profile` (row 253): one 1D profile per frame stacked row-major into `(frame_count, profile_len)`, via the `stack_profile_with` core over `stack_frame`. Mirrors silx `ProfileImageStack{HorizontalLine,VerticalLine,Line}ROI`. Extraction headlessly tested; profile-window curve display GPU-unverified. |
| ✅ | L | L | RadarView: miniature overview of full data extent with draggable viewport rect | `tools/RadarView.py:139-300` | `RadarView` (radar_view.rs) ports silx `RadarView.py:139-360`: a thumbnail of the data extent (gray fill/white outline) with a draggable blue viewport rect; `RadarView::ui` paints it and emits the dragged limits. Fully bound in `ImageView` (data extent via `set_data_bounds`, viewport via `set_viewport_limits`, drag → `Plot2D::set_limits`). Geometry (mapping/clamp/hit-test) unit-tested; paint GPU-unverified. |
| ☐ | L | L | Print preview: send plot to printable page with movable/resizable rect | `PrintPreviewToolButton.py:24-120` | No print preview or print button. Would require integrating with system print dialog and rendering to PDF/printer. |
| ✅ | L | M | Legend context menu (set active, map-to-Y-left/right, toggle points/lines, rename, remove) | `LegendSelector.py:697-845 (LegendListContextMenu)` | Wave 9: right-click on a legend row pops an `egui::Response::context_menu`. Curve rows get Set Active / Map to Y Left / Map to Y Right / checkable Points / checkable Lines / Rename / Remove; non-curve rows get a Set Active / Rename / Remove subset. Points/Lines toggle losslessly via a UI-only restore cache; rename via an `egui::Window`. Backend was already primitively complete. **GPU/UI-UNVERIFIED:** the menu render, right-click interaction, and rename window need a GPU `RenderState` no crate test constructs; only the pure style transforms are unit-tested. silx's copy-color / toggle-colormap actions are not ported (egui has no equivalent clipboard color affordance here). |
| ✅ | L | M | Fit: Lorentzian fit (amplitude, center, width, background) | `silx.math.fit.fittheories: Lorentzian` | `PeakModel::Lorentzian` (`lorentzian_model`/`estimate_lorentzian`) fits via the iterative LM solver; offered by the FitWidget model selector. Numerics tested in `core::fitting`. |
| ✅ | L | M | Fit: Pseudo-Voigt fit (Gaussian + Lorentzian blend) | `silx.math.fit.fittheories: PseudoVoigt` | `PeakModel::PseudoVoigt` (`pseudo_voigt_model`/`estimate_pseudo_voigt`) fits via the iterative LM solver; offered by the FitWidget model selector. Numerics tested in `core::fitting`. |
| ✅ | L | M | PositionInfo readout bar (X, Y, plus custom converters) | `tools/PositionInfo.py:64-250` | `PositionInfo` (position_info.rs) holds an ordered list of `(label, converter)` pairs (X/Y defaults, a polar Radius/Angle pair, custom numeric converters formatted `%.7g`); `PositionInfo::ui(ui, cursor)` renders the readout bar. Converter logic tested; bar render GPU-unverified. |
| ✅ | L | M | ItemsSelectionDialog: multi-select table of plot items filtered by kind | `ItemsSelectionDialog.py:40-200; actions/fit.py:237-241` | `ItemsSelectionDialog` (Wave 6B-1) groups items by kind with a `KindsSelector`-style filter; `set_available_kinds` (silx `setAvailableKinds`) restricts offered kinds and `SelectionMode::Single/Multi` (silx `setItemsSelectionMode`) toggles single vs multi selection. Reused by the fit tool as a single-select curve+histogram picker (`examples/high_level_fit_widget.rs`, silx `_initFit`). Pure logic unit-tested; UI GPU-unverified. |
| ✅ | L | S | Profile: cross profile (horizontal + vertical lines from point) | `tools/profile/rois.py:663-700; _ProfileCrossROI` | `ProfileWindow::update_profile` over a `Roi::Cross` shows both the horizontal and vertical full profiles through the cross center as two simultaneous legended curves in one window (`profiles_for_roi` → `Vec<ProfileCurve>`, `curve_handles: Vec`). Faithful to `ProfileImageCrossROI`. Extraction tested; window GPU-unverified. |
| ✅ | L | S | Fit widget UI: show fit results table (chi-squared, parameters, errors) | `silx.gui.fit.FitWidget: results table with parameter names, values, errors` | FitWidget `show` renders a per-parameter `value ± error` column (`format_param_value_error`, error = covariance-diagonal sigma via `IterativeFitResult::std_errors`) and a "Reduced chi-square" row (`format_reduced_chisq`). Matches silx's FitWidget results table (which shows value/sigma, not a full covariance matrix). Formatters tested; table render GPU-unverified. |
| ✅ | L | S | Stats: center-of-mass (weighted sum of positions) | `stats/stats.py:881-910` | `Stats::com` (`ComCoord`): `COM = sum(pos·val)/sum(val)`, undefined when the finite-value sum is zero (silx NaN). Curve → `com[0]`; image → `(x, y)`. Tested. |
| ✅ | L | S | Stats: coordinate of min/max (argmin/argmax with axis lookup) | `stats/stats.py:841-878` | `Stats::coord_min`/`coord_max`: data coords of the first min/max (silx `StatCoordMin`/`StatCoordMax`, argmin/argmax to first extremum); image coords mapped `origin + scale·index`. Tested. |
| ✅ | L | S | Stats: integral (sum of all values, optionally weighted by axis) | `stats/stats.py via silx.math.combo` | `Stats::sum` (sum/integral of finite values). Tested. |