use egui_wgpu::RenderState;
use crate::core::backend::ItemHandle;
use crate::core::colormap::Colormap;
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
}
}
#[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 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,
}
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);
Self {
plot,
nav: FrameNav::default(),
colormap: Colormap::viridis(0.0, 1.0),
image_handle: None,
auto_reset_zoom: true,
show_table: true,
}
}
pub fn set_frames(&mut self, frames: Vec<Option<Frame>>) {
self.nav.set_frames(frames);
self.image_handle = None;
self.plot.clear_images();
}
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 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 = self.colormap.clone();
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);
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;
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);
}
let label = self.nav.frame_label(index);
if ui.selectable_label(index == current, label).clicked() {
self.nav.set_current(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 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 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 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);
}
}