use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
use egui_wgpu::RenderState;
use crate::core::backend::ItemHandle;
use crate::core::colormap::{AutoscaleMode, Colormap, ColormapName};
use crate::core::plot::PlotId;
use crate::widget::high_level::Plot2D;
#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
pub width: u32,
pub height: u32,
pub data: Vec<f32>,
pub label: Option<String>,
}
impl Frame {
pub fn new(width: u32, height: u32, data: Vec<f32>, label: Option<String>) -> Self {
Self {
width,
height,
data,
label,
}
}
pub fn is_displayable(&self) -> bool {
let expected = (self.width as usize).saturating_mul(self.height as usize);
expected != 0 && self.data.len() == expected
}
}
pub trait FrameLoader: Send + Sync {
fn load(&self, source: &str) -> Option<Frame>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Hdf5FrameLoader;
impl FrameLoader for Hdf5FrameLoader {
fn load(&self, source: &str) -> Option<Frame> {
let parts: Vec<&str> = source.split("::").collect();
let (path, data_path, slice) = match parts.as_slice() {
[path, data_path] => (*path, *data_path, None),
[path, data_path, index] => (*path, *data_path, Some(index.parse::<usize>().ok()?)),
_ => return None,
};
let p = std::path::Path::new(path);
let (height, width, data) = match slice {
None => crate::render::save::read_image_hdf5(p, data_path).ok()?,
Some(index) => crate::render::save::read_image_hdf5_slice(p, data_path, index).ok()?,
};
Some(Frame::new(width, height, data, Some(source.to_string())))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
enum LoadState {
#[default]
NotRequested,
InFlight,
Loaded,
Failed,
}
#[derive(Clone, Debug, Default, PartialEq)]
struct LoadSchedule {
state: Vec<LoadState>,
}
impl LoadSchedule {
fn reset(&mut self, len: usize) {
self.state = vec![LoadState::NotRequested; len];
}
fn remove(&mut self, index: usize) {
if index < self.state.len() {
self.state.remove(index);
}
}
fn needs_load(&self, i: usize) -> bool {
matches!(self.state.get(i), Some(LoadState::NotRequested))
}
fn mark_in_flight(&mut self, i: usize) {
if let Some(s) = self.state.get_mut(i) {
*s = LoadState::InFlight;
}
}
fn mark_loaded(&mut self, i: usize) {
if let Some(s) = self.state.get_mut(i) {
*s = LoadState::Loaded;
}
}
fn mark_failed(&mut self, i: usize) {
if let Some(s) = self.state.get_mut(i) {
*s = LoadState::Failed;
}
}
fn to_dispatch(&self, candidates: impl IntoIterator<Item = usize>) -> Vec<usize> {
let mut out = Vec::new();
for i in candidates {
if self.needs_load(i) && !out.contains(&i) {
out.push(i);
}
}
out
}
}
fn prefetch_candidates(current: usize, len: usize, n: usize) -> Vec<usize> {
if len == 0 {
return Vec::new();
}
let mut out = vec![current];
for k in 1..=n {
if current + k < len {
out.push(current + k);
}
}
for k in 1..=n {
if let Some(prev) = current.checked_sub(k) {
out.push(prev);
}
}
out
}
#[derive(Clone, Debug, Default, PartialEq)]
struct FrameNav {
frames: Vec<Option<Frame>>,
current: usize,
visible: Vec<bool>,
dirty: bool,
}
impl FrameNav {
fn set_frames(&mut self, frames: Vec<Option<Frame>>) {
self.visible = vec![true; frames.len()];
self.frames = frames;
self.current = 0;
self.dirty = true;
}
fn remove(&mut self, index: usize) {
if index >= self.frames.len() {
return;
}
self.frames.remove(index);
self.visible.remove(index);
if self.frames.is_empty() {
self.current = 0;
} else if index < self.current {
self.current -= 1; } else if index == self.current {
self.current = self.current.min(self.frames.len() - 1);
self.dirty = true; }
}
fn len(&self) -> usize {
self.frames.len()
}
fn is_empty(&self) -> bool {
self.frames.is_empty()
}
fn set_current(&mut self, index: usize) {
if self.frames.is_empty() {
return;
}
let clamped = index.min(self.frames.len() - 1);
if clamped != self.current {
self.current = clamped;
self.dirty = true;
}
}
fn next_frame(&mut self) {
if self.frames.is_empty() {
return;
}
self.set_current(self.current + 1);
}
fn prev_frame(&mut self) {
if self.current > 0 {
self.set_current(self.current - 1);
}
}
fn first_frame(&mut self) {
self.set_current(0);
}
fn last_frame(&mut self) {
if !self.frames.is_empty() {
self.set_current(self.frames.len() - 1);
}
}
fn is_visible(&self, index: usize) -> bool {
self.visible.get(index).copied().unwrap_or(false)
}
fn set_visible(&mut self, index: usize, visible: bool) {
if let Some(slot) = self.visible.get_mut(index)
&& *slot != visible
{
*slot = visible;
if index == self.current {
self.dirty = true;
}
}
}
fn frame_label(&self, index: usize) -> String {
match self.frames.get(index) {
None => String::new(),
Some(None) => format!("Frame {} (empty)", index + 1),
Some(Some(frame)) => match &frame.label {
Some(label) => label.clone(),
None => format!("Frame {}", index + 1),
},
}
}
fn current_is_displayable(&self) -> bool {
if !self.is_visible(self.current) {
return false;
}
matches!(self.frames.get(self.current), Some(Some(frame)) if frame.is_displayable())
}
fn current_frame(&self) -> Option<&Frame> {
if !self.current_is_displayable() {
return None;
}
self.frames.get(self.current).and_then(Option::as_ref)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NavAction {
First,
Previous,
Next,
Last,
Goto(usize),
}
impl NavAction {
fn apply(self, nav: &mut FrameNav) {
match self {
NavAction::First => nav.first_frame(),
NavAction::Previous => nav.prev_frame(),
NavAction::Next => nav.next_frame(),
NavAction::Last => nav.last_frame(),
NavAction::Goto(index) => nav.set_current(index),
}
}
}
fn navigation_toolbar_ui(ui: &mut egui::Ui, current: usize, len: usize) -> Option<NavAction> {
if len == 0 {
return None;
}
let mut action = None;
ui.horizontal(|ui| {
if ui.button("⏮").on_hover_text("First frame").clicked() {
action = Some(NavAction::First);
}
if ui.button("◀").on_hover_text("Previous frame").clicked() {
action = Some(NavAction::Previous);
}
let mut idx = current;
if ui
.add(egui::Slider::new(&mut idx, 0..=len - 1).text("frame"))
.changed()
{
action = Some(NavAction::Goto(idx));
}
if ui.button("▶").on_hover_text("Next frame").clicked() {
action = Some(NavAction::Next);
}
if ui.button("⏭").on_hover_text("Last frame").clicked() {
action = Some(NavAction::Last);
}
ui.label(format!("{}/{}", current + 1, len));
});
action
}
pub struct ImageStack {
plot: Plot2D,
nav: FrameNav,
colormap: Colormap,
image_handle: Option<ItemHandle>,
auto_reset_zoom: bool,
show_table: bool,
sources: Vec<String>,
n_prefetch: usize,
loader: Option<Arc<dyn FrameLoader>>,
schedule: LoadSchedule,
load_tx: Sender<(usize, Option<Frame>)>,
load_rx: Receiver<(usize, Option<Frame>)>,
}
fn frame_colormap(base: &Colormap, data: &[f32]) -> Colormap {
let data_f64: Vec<f64> = data.iter().map(|&v| v as f64).collect();
let (vmin, vmax) = base.autoscale_range(AutoscaleMode::MinMax, &data_f64);
let mut cm = base.clone();
cm.vmin = vmin;
cm.vmax = vmax;
cm
}
impl ImageStack {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
let mut plot = Plot2D::new(render_state, id);
plot.set_keep_data_aspect_ratio(true);
plot.set_graph_cursor(true);
let (load_tx, load_rx) = std::sync::mpsc::channel();
Self {
plot,
nav: FrameNav::default(),
colormap: Colormap::new(ColormapName::Gray, 0.0, 1.0),
image_handle: None,
auto_reset_zoom: true,
show_table: true,
sources: Vec::new(),
n_prefetch: 10,
loader: None,
schedule: LoadSchedule::default(),
load_tx,
load_rx,
}
}
pub fn set_frames(&mut self, frames: Vec<Option<Frame>>) {
self.nav.set_frames(frames);
self.sources.clear();
self.schedule.reset(0);
self.image_handle = None;
self.plot.clear_images();
}
pub fn set_loader(&mut self, loader: Arc<dyn FrameLoader>) {
self.loader = Some(loader);
}
pub fn set_sources(&mut self, sources: Vec<String>) {
let len = sources.len();
self.nav.set_frames(vec![None; len]);
self.sources = sources;
self.schedule.reset(len);
self.image_handle = None;
self.plot.clear_images();
}
pub fn sources(&self) -> &[String] {
&self.sources
}
pub fn remove_source(&mut self, index: usize) {
if index >= self.sources.len() {
return;
}
self.sources.remove(index);
self.schedule.remove(index);
self.nav.remove(index);
self.image_handle = None;
}
fn row_label(&self, index: usize) -> String {
match self.sources.get(index) {
Some(source) => source.clone(),
None => self.nav.frame_label(index),
}
}
pub fn set_n_prefetch(&mut self, n: usize) {
self.n_prefetch = n;
}
pub fn n_prefetch(&self) -> usize {
self.n_prefetch
}
pub fn len(&self) -> usize {
self.nav.len()
}
pub fn is_empty(&self) -> bool {
self.nav.is_empty()
}
pub fn current(&self) -> usize {
self.nav.current
}
pub fn set_current(&mut self, index: usize) {
self.nav.set_current(index);
}
pub fn next_frame(&mut self) {
self.nav.next_frame();
}
pub fn prev_frame(&mut self) {
self.nav.prev_frame();
}
pub fn first_frame(&mut self) {
self.nav.first_frame();
}
pub fn last_frame(&mut self) {
self.nav.last_frame();
}
pub fn is_visible(&self, index: usize) -> bool {
self.nav.is_visible(index)
}
pub fn set_visible(&mut self, index: usize, visible: bool) {
self.nav.set_visible(index, visible);
}
pub fn toggle_visible(&mut self, index: usize) {
let now = self.nav.is_visible(index);
self.nav.set_visible(index, !now);
}
pub fn frame_label(&self, index: usize) -> String {
self.nav.frame_label(index)
}
pub fn current_is_displayable(&self) -> bool {
self.nav.current_is_displayable()
}
pub fn set_colormap(&mut self, colormap: Colormap) {
self.colormap = colormap;
self.image_handle = None;
self.plot.clear_images();
self.nav.dirty = true;
}
pub fn set_auto_reset_zoom(&mut self, reset: bool) {
self.auto_reset_zoom = reset;
}
pub fn is_auto_reset_zoom(&self) -> bool {
self.auto_reset_zoom
}
pub fn set_table_visible(&mut self, visible: bool) {
self.show_table = visible;
}
pub fn plot(&self) -> &Plot2D {
&self.plot
}
pub fn plot_mut(&mut self) -> &mut Plot2D {
&mut self.plot
}
fn load_candidates(&self) -> Vec<usize> {
prefetch_candidates(self.nav.current, self.nav.len(), self.n_prefetch)
}
fn drain_loaded(&mut self) {
while let Ok((index, frame)) = self.load_rx.try_recv() {
match frame {
Some(frame) => {
if let Some(slot) = self.nav.frames.get_mut(index) {
*slot = Some(frame);
self.schedule.mark_loaded(index);
if index == self.nav.current {
self.nav.dirty = true;
}
}
}
None => self.schedule.mark_failed(index),
}
}
}
fn dispatch_loads(&mut self, ctx: &egui::Context) {
let Some(loader) = self.loader.clone() else {
return;
};
for index in self.schedule.to_dispatch(self.load_candidates()) {
let Some(source) = self.sources.get(index).cloned() else {
continue;
};
self.schedule.mark_in_flight(index);
let loader = loader.clone();
let tx = self.load_tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || {
let frame = loader.load(&source);
let _ = tx.send((index, frame));
ctx.request_repaint();
});
}
}
fn rebuild_image(&mut self) {
let Some(frame) = self.nav.current_frame() else {
self.plot.clear_images();
self.image_handle = None;
return;
};
let (width, height) = (frame.width, frame.height);
let data = frame.data.clone();
let colormap = frame_colormap(&self.colormap, &data);
if self.auto_reset_zoom {
self.plot.clear_images();
self.image_handle = None;
}
if let Some(handle) = self.image_handle
&& self
.plot
.try_update_image(handle, width, height, &data, colormap.clone())
.unwrap_or(false)
{
return;
}
if let Ok(handle) = self.plot.try_add_image(width, height, &data, colormap) {
self.image_handle = Some(handle);
}
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
if self.show_table {
self.frame_table_ui(ui);
}
self.navigation_ui(ui);
let ctx = ui.ctx().clone();
self.drain_loaded();
self.dispatch_loads(&ctx);
if self.nav.dirty {
self.rebuild_image();
self.nav.dirty = false;
}
let response = self.plot.show(ui).response;
if !self.nav.current_is_displayable() && !self.nav.is_empty() {
Self::paint_waiting_overlay(ui, response.rect);
}
response
}
fn frame_table_ui(&mut self, ui: &mut egui::Ui) {
let len = self.nav.len();
if len == 0 {
ui.label("No frames");
return;
}
let current = self.nav.current;
let lazy = !self.sources.is_empty();
let mut to_remove = None;
egui::ScrollArea::vertical()
.max_height(120.0)
.show(ui, |ui| {
for index in 0..len {
ui.horizontal(|ui| {
let mut vis = self.nav.is_visible(index);
if ui
.checkbox(&mut vis, "")
.on_hover_text("Show this frame")
.changed()
{
self.nav.set_visible(index, vis);
}
if lazy
&& ui
.small_button("✕")
.on_hover_text("Remove this source")
.clicked()
{
to_remove = Some(index);
}
let label = self.row_label(index);
if ui.selectable_label(index == current, label).clicked() {
self.nav.set_current(index);
}
});
}
});
if let Some(index) = to_remove {
self.remove_source(index);
}
}
pub fn show_toolbar(&mut self, ui: &mut egui::Ui) {
self.navigation_ui(ui);
}
fn navigation_ui(&mut self, ui: &mut egui::Ui) {
if let Some(action) = navigation_toolbar_ui(ui, self.nav.current, self.nav.len()) {
action.apply(&mut self.nav);
}
}
fn paint_waiting_overlay(ui: &egui::Ui, rect: egui::Rect) {
if !ui.is_rect_visible(rect) {
return;
}
let painter = ui.painter_at(rect);
let spinner_size = 30.0;
let plate = egui::Rect::from_center_size(
rect.center(),
egui::vec2(spinner_size + 24.0, spinner_size + 36.0),
);
painter.rect_filled(
plate,
10.0,
egui::Color32::from_rgba_unmultiplied(150, 150, 150, 40),
);
let spinner_rect = egui::Rect::from_center_size(
rect.center() - egui::vec2(0.0, 8.0),
egui::vec2(spinner_size, spinner_size),
);
egui::Spinner::new()
.size(spinner_size)
.paint_at(ui, spinner_rect);
painter.text(
rect.center() + egui::vec2(0.0, spinner_size / 2.0),
egui::Align2::CENTER_CENTER,
"Loading…",
egui::FontId::proportional(12.0),
ui.visuals().strong_text_color(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(label: &str) -> Option<Frame> {
Some(Frame::new(
2,
2,
vec![0.0, 1.0, 2.0, 3.0],
Some(label.to_string()),
))
}
fn nav(frames: Vec<Option<Frame>>) -> FrameNav {
let mut n = FrameNav::default();
n.set_frames(frames);
n
}
#[test]
fn frame_colormap_autoscales_to_frame_data() {
let base = Colormap::new(ColormapName::Gray, 0.0, 1.0);
let cm = frame_colormap(&base, &[10.0, 20.0, 30.0, 40.0]);
assert_eq!((cm.vmin, cm.vmax), (10.0, 40.0));
assert_eq!(cm.lut, base.lut);
}
#[test]
fn frame_colormap_tracks_data_not_stored_range() {
let base = Colormap::new(ColormapName::Gray, 0.0, 1.0);
let cm = frame_colormap(&base, &[7.0, 7.0, 7.0, 7.0]);
assert_eq!((cm.vmin, cm.vmax), (7.0, 7.0));
}
#[test]
fn displayable_matches_dims() {
assert!(Frame::new(2, 2, vec![0.0; 4], None).is_displayable());
}
#[test]
fn displayable_false_on_length_mismatch() {
assert!(!Frame::new(2, 2, vec![0.0; 3], None).is_displayable());
}
#[test]
fn displayable_false_on_zero_dims() {
assert!(!Frame::new(0, 0, vec![], None).is_displayable());
assert!(!Frame::new(2, 0, vec![], None).is_displayable());
}
#[test]
fn set_current_clamps_to_last() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
n.set_current(99);
assert_eq!(n.current, 2); }
#[test]
fn set_current_in_range_is_exact() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
n.set_current(1);
assert_eq!(n.current, 1);
}
#[test]
fn empty_stack_set_current_is_noop() {
let mut n = nav(vec![]);
n.set_current(5);
assert_eq!(n.current, 0);
assert_eq!(n.len(), 0);
}
#[test]
fn next_at_last_stays() {
let mut n = nav(vec![frame("a"), frame("b")]);
n.set_current(1);
n.next_frame();
assert_eq!(n.current, 1); }
#[test]
fn prev_at_first_stays() {
let mut n = nav(vec![frame("a"), frame("b")]);
assert_eq!(n.current, 0);
n.prev_frame();
assert_eq!(n.current, 0); }
#[test]
fn next_then_prev_round_trips() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
n.next_frame();
assert_eq!(n.current, 1);
n.next_frame();
assert_eq!(n.current, 2);
n.prev_frame();
assert_eq!(n.current, 1);
}
#[test]
fn empty_stack_next_prev_are_noops() {
let mut n = nav(vec![]);
n.next_frame();
n.prev_frame();
assert_eq!(n.current, 0);
}
#[test]
fn first_and_last_jump_to_ends() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c"), frame("d")]);
n.last_frame();
assert_eq!(n.current, 3);
n.first_frame();
assert_eq!(n.current, 0);
}
#[test]
fn last_on_empty_is_noop() {
let mut n = nav(vec![]);
n.last_frame();
assert_eq!(n.current, 0);
}
#[test]
fn visibility_defaults_true_and_toggles() {
let mut n = nav(vec![frame("a"), frame("b")]);
assert!(n.is_visible(0));
assert!(n.is_visible(1));
n.set_visible(0, false);
assert!(!n.is_visible(0));
assert!(n.is_visible(1));
}
#[test]
fn visibility_out_of_range_is_false_and_noop() {
let mut n = nav(vec![frame("a")]);
assert!(!n.is_visible(7));
n.set_visible(7, true); assert_eq!(n.visible, vec![true]);
}
#[test]
fn hidden_current_frame_is_not_displayable() {
let mut n = nav(vec![frame("a")]);
assert!(n.current_is_displayable());
n.set_visible(0, false);
assert!(!n.current_is_displayable());
assert!(n.current_frame().is_none());
}
#[test]
fn empty_slot_is_not_displayable() {
let n = nav(vec![None]);
assert!(n.is_visible(0));
assert!(!n.current_is_displayable());
assert!(n.current_frame().is_none());
}
#[test]
fn invalid_length_frame_is_not_displayable() {
let n = nav(vec![Some(Frame::new(2, 2, vec![0.0; 3], None))]);
assert!(!n.current_is_displayable());
assert!(n.current_frame().is_none());
}
#[test]
fn current_frame_returns_visible_displayable_slot() {
let n = nav(vec![frame("a")]);
let f = n.current_frame().expect("displayable");
assert_eq!(f.label.as_deref(), Some("a"));
}
#[test]
fn remove_before_current_shifts_index_down() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
n.set_current(2); n.dirty = false;
n.remove(0); assert_eq!(n.len(), 2);
assert_eq!(n.current, 1); assert!(!n.dirty); assert_eq!(n.frame_label(1), "c");
}
#[test]
fn remove_current_points_at_next_then_clamps() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
n.set_current(1); n.dirty = false;
n.remove(1); assert_eq!(n.current, 1); assert_eq!(n.frame_label(1), "c");
assert!(n.dirty); n.dirty = false;
n.remove(1); assert_eq!(n.current, 0);
assert_eq!(n.frame_label(0), "a");
assert!(n.dirty);
}
#[test]
fn remove_after_current_is_index_neutral() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
n.set_current(0);
n.dirty = false;
n.remove(2); assert_eq!(n.current, 0);
assert!(!n.dirty);
assert_eq!(n.len(), 2);
}
#[test]
fn remove_last_remaining_resets() {
let mut n = nav(vec![frame("a")]);
n.remove(0);
assert!(n.is_empty());
assert_eq!(n.current, 0);
}
#[test]
fn remove_out_of_range_is_noop() {
let mut n = nav(vec![frame("a"), frame("b")]);
n.remove(9);
assert_eq!(n.len(), 2);
}
#[test]
fn schedule_remove_drops_the_slot() {
let mut s = LoadSchedule::default();
s.reset(3);
s.mark_loaded(0);
s.mark_failed(2);
s.remove(0); assert_eq!(s.state.len(), 2);
assert!(s.needs_load(0)); assert!(!s.needs_load(1)); s.remove(9); assert_eq!(s.state.len(), 2);
}
#[test]
fn set_frames_marks_dirty() {
let n = nav(vec![frame("a")]);
assert!(n.dirty);
}
#[test]
fn redundant_set_current_does_not_mark_dirty() {
let mut n = nav(vec![frame("a"), frame("b")]);
n.dirty = false;
n.set_current(0); assert!(!n.dirty);
n.set_current(1); assert!(n.dirty);
}
#[test]
fn hiding_non_current_frame_does_not_mark_dirty() {
let mut n = nav(vec![frame("a"), frame("b")]);
n.dirty = false;
n.set_visible(1, false); assert!(!n.dirty);
n.set_visible(0, false); assert!(n.dirty);
}
#[test]
fn schedule_reset_marks_all_not_requested() {
let mut s = LoadSchedule::default();
s.reset(3);
assert!(s.needs_load(0));
assert!(s.needs_load(1));
assert!(s.needs_load(2));
assert!(!s.needs_load(3)); }
#[test]
fn schedule_in_flight_and_loaded_are_not_redispatched() {
let mut s = LoadSchedule::default();
s.reset(2);
s.mark_in_flight(0);
s.mark_loaded(1);
assert!(!s.needs_load(0)); assert!(!s.needs_load(1)); assert_eq!(s.to_dispatch([0, 1]), Vec::<usize>::new());
}
#[test]
fn schedule_failed_is_terminal_not_retried() {
let mut s = LoadSchedule::default();
s.reset(1);
s.mark_in_flight(0);
s.mark_failed(0);
assert!(!s.needs_load(0));
assert_eq!(s.to_dispatch([0]), Vec::<usize>::new());
}
#[test]
fn schedule_to_dispatch_filters_and_dedups() {
let mut s = LoadSchedule::default();
s.reset(4);
s.mark_loaded(1);
assert_eq!(s.to_dispatch([0, 1, 2, 2, 5]), vec![0, 2]);
}
#[test]
fn prefetch_window_in_the_middle_is_current_then_next_then_prev() {
assert_eq!(prefetch_candidates(5, 10, 2), vec![5, 6, 7, 4, 3]);
}
#[test]
fn prefetch_window_clamps_at_both_ends() {
assert_eq!(prefetch_candidates(0, 4, 2), vec![0, 1, 2]);
assert_eq!(prefetch_candidates(3, 4, 2), vec![3, 2, 1]);
}
#[test]
fn prefetch_window_zero_radius_is_just_current() {
assert_eq!(prefetch_candidates(2, 5, 0), vec![2]);
}
#[test]
fn prefetch_window_empty_stack_is_empty() {
assert_eq!(prefetch_candidates(0, 0, 3), Vec::<usize>::new());
}
#[test]
fn prefetch_window_radius_exceeds_length() {
assert_eq!(prefetch_candidates(1, 3, 10), vec![1, 2, 0]);
}
fn temp_h5(tag: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"rsplot_image_stack_h5_{}_{}.h5",
tag,
std::process::id()
));
let _ = std::fs::remove_file(&path);
path
}
fn seed_dataset<T: rust_hdf5::types::H5Type>(
path: &std::path::Path,
name: &str,
shape: &[usize],
data: &[T],
) {
use rust_hdf5::H5File;
let file = if path.exists() {
H5File::open_rw(path).unwrap()
} else {
H5File::create(path).unwrap()
};
let ds = file.new_dataset::<T>().shape(shape).create(name).unwrap();
ds.write_raw(data).unwrap();
file.close().unwrap();
}
#[test]
fn hdf5_loader_reads_a_2d_dataset() {
let path = temp_h5("loader_2d");
seed_dataset(&path, "/img", &[2, 3], &[0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0]);
let source = format!("{}::/img", path.display());
let frame = Hdf5FrameLoader.load(&source).expect("2D frame loads");
assert_eq!((frame.width, frame.height), (3, 2)); assert_eq!(frame.data, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(frame.is_displayable());
let _ = std::fs::remove_file(&path);
}
#[test]
fn hdf5_loader_reads_a_3d_slice() {
let path = temp_h5("loader_3d");
seed_dataset(&path, "/stack", &[2, 1, 2], &[0.0f32, 1.0, 10.0, 11.0]);
let frame = Hdf5FrameLoader
.load(&format!("{}::/stack::1", path.display()))
.expect("3D slice loads");
assert_eq!((frame.width, frame.height), (2, 1));
assert_eq!(frame.data, vec![10.0, 11.0]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn hdf5_loader_returns_none_on_bad_source() {
assert!(Hdf5FrameLoader.load("no-separator").is_none());
assert!(
Hdf5FrameLoader
.load("/tmp/x.h5::/stack::notanumber")
.is_none()
);
assert!(
Hdf5FrameLoader
.load("/nonexistent/rsplot_missing.h5::/img")
.is_none()
);
}
#[test]
fn frame_label_uses_label_then_index_then_empty_marker() {
let n = nav(vec![
frame("custom"),
Some(Frame::new(1, 1, vec![0.0], None)),
None,
]);
assert_eq!(n.frame_label(0), "custom");
assert_eq!(n.frame_label(1), "Frame 2"); assert_eq!(n.frame_label(2), "Frame 3 (empty)");
assert_eq!(n.frame_label(99), ""); }
fn capture_toolbar_rects(
ctx: &egui::Context,
current: usize,
len: usize,
) -> (egui::Rect, egui::Rect, egui::Rect, egui::Rect) {
let mut first = egui::Rect::NOTHING;
let mut prev = egui::Rect::NOTHING;
let mut next = egui::Rect::NOTHING;
let mut last = egui::Rect::NOTHING;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.horizontal(|ui| {
first = ui.button("⏮").rect;
prev = ui.button("◀").rect;
let mut idx = current;
let _ = ui.add(egui::Slider::new(&mut idx, 0..=len - 1).text("frame"));
next = ui.button("▶").rect;
last = ui.button("⏭").rect;
let _ = ui.label(format!("{}/{}", current + 1, len));
});
});
(first, prev, next, last)
}
fn run_toolbar(
ctx: &egui::Context,
current: usize,
len: usize,
raw: egui::RawInput,
) -> Option<NavAction> {
let mut action = None;
let _ = ctx.run_ui(raw, |ui| {
action = navigation_toolbar_ui(ui, current, len);
});
action
}
fn click_at(point: egui::Pos2) -> egui::RawInput {
egui::RawInput {
events: vec![
egui::Event::PointerMoved(point),
egui::Event::PointerButton {
pos: point,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
egui::Event::PointerButton {
pos: point,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
},
],
..Default::default()
}
}
#[test]
fn empty_toolbar_renders_nothing_and_returns_none() {
let ctx = egui::Context::default();
assert_eq!(run_toolbar(&ctx, 0, 0, egui::RawInput::default()), None);
}
#[test]
fn next_button_click_yields_next_action() {
let ctx = egui::Context::default();
let (_first, _prev, next, _last) = capture_toolbar_rects(&ctx, 0, 3);
let _ = run_toolbar(&ctx, 0, 3, egui::RawInput::default());
let action = run_toolbar(&ctx, 0, 3, click_at(next.center()));
assert_eq!(action, Some(NavAction::Next));
}
#[test]
fn prev_first_last_button_clicks_yield_their_actions() {
let ctx = egui::Context::default();
let (first, prev, _next, last) = capture_toolbar_rects(&ctx, 1, 3);
let _ = run_toolbar(&ctx, 1, 3, egui::RawInput::default());
assert_eq!(
run_toolbar(&ctx, 1, 3, click_at(prev.center())),
Some(NavAction::Previous)
);
let _ = run_toolbar(&ctx, 1, 3, egui::RawInput::default());
assert_eq!(
run_toolbar(&ctx, 1, 3, click_at(first.center())),
Some(NavAction::First)
);
let _ = run_toolbar(&ctx, 1, 3, egui::RawInput::default());
assert_eq!(
run_toolbar(&ctx, 1, 3, click_at(last.center())),
Some(NavAction::Last)
);
}
#[test]
fn toolbar_actions_keep_index_in_bounds_when_applied() {
let mut n = nav(vec![frame("a"), frame("b"), frame("c")]);
NavAction::Next.apply(&mut n);
assert_eq!(n.current, 1);
NavAction::Last.apply(&mut n);
assert_eq!(n.current, 2);
NavAction::Next.apply(&mut n); assert_eq!(n.current, 2);
NavAction::Goto(99).apply(&mut n); assert_eq!(n.current, 2);
NavAction::First.apply(&mut n);
assert_eq!(n.current, 0);
NavAction::Previous.apply(&mut n); assert_eq!(n.current, 0);
NavAction::Goto(1).apply(&mut n);
assert_eq!(n.current, 1);
}
}