Skip to main content

dicom_viewer/
app.rs

1//! Top-level egui application: holds shared state and drives the UI.
2//!
3//! [`DicomViewerApp`] is the single [`eframe::App`] for the viewer.
4//! Everything UI-visible — loaded studies, decoded pixel caches, the
5//! viewport grid, annotations, transient error/info banners — lives on
6//! this struct. The application is single-threaded; decode runs on the UI
7//! thread and we lean on lazy caching plus per-frame work limits
8//! (e.g. `pump_thumbnails`) to keep frame times low.
9
10use crate::config::{Config, Paths};
11use crate::dcm::annotation::{Annotation, AnnotationStore};
12use crate::dcm::{self, metadata::TagRow, RawImage, Study};
13use crate::ui::{self, GridLayout};
14use eframe::CreationContext;
15use egui::{Context, TextureHandle, Vec2};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20/// The single eframe application.
21///
22/// Created by [`Self::new`] from [`main`](../main/index.html), then handed
23/// to [`eframe::run_native`]. Every per-frame operation goes through
24/// the `eframe::App::update` method below.
25pub struct DicomViewerApp {
26    /// Resolved data/log/exe paths. See [`Paths::resolve`].
27    pub paths: Paths,
28    /// Loaded `config.toml` (or defaults on first run).
29    pub config: Config,
30    /// Transient UI state — disclaimer/about dialogs, active tool, panel
31    /// visibility, in-progress measurement, etc.
32    pub ui_state: ui::UiState,
33
34    /// All loaded studies (metadata only; pixels live in [`Self::raw_cache`]).
35    pub studies: Vec<Study>,
36    /// Decoded pixel buffers, keyed by SOP Instance UID. Filled lazily by
37    /// [`Self::raw_for`]; cleared on [`Self::open_folder`].
38    pub raw_cache: HashMap<String, Arc<RawImage>>,
39    /// SOP UIDs whose decode failed and the reason — so we don't retry
40    /// every frame.
41    pub raw_failures: HashMap<String, String>,
42    /// Flattened DICOM tag rows for the metadata panel, keyed by SOP UID.
43    /// Lazily populated by [`Self::metadata_for`].
44    pub metadata_cache: HashMap<String, Arc<Vec<TagRow>>>,
45    /// Substring filter applied to the metadata panel.
46    pub metadata_filter: String,
47    /// Sidebar previews. Lazily populated one-per-frame so the UI never
48    /// stalls behind a chain of pixel decodes.
49    pub thumbnails: HashMap<String, ThumbnailState>,
50
51    /// Active grid layout (1×1 up to 4×4).
52    pub grid: GridLayout,
53    /// One [`CellState`] per visible viewport cell. Length matches
54    /// `grid.cell_count()`.
55    pub cells: Vec<CellState>,
56    /// Index into [`Self::cells`] receiving toolbar actions and keyboard
57    /// input.
58    pub active_cell: usize,
59
60    /// Per-SOP-UID annotation list, persisted to a JSON sidecar.
61    pub annotation_store: AnnotationStore,
62    /// Where [`Self::annotation_store`] is loaded from / saved to.
63    pub annotations_path: PathBuf,
64
65    /// Last error message — shown in the status bar.
66    pub last_error: Option<String>,
67    /// Last informational message — shown in the status bar.
68    pub last_info: Option<String>,
69
70    /// Drag-drop drops are deferred until the *next* frame. Applying them
71    /// inline during `viewport::draw` would free the cell's TextureHandle
72    /// while shapes referencing that texture are still in the current
73    /// frame's command buffer — wgpu panics with
74    /// "Texture … has been destroyed".
75    pending_drops: Vec<(usize, (usize, usize))>,
76
77    /// Folder to load on the first update tick. Set when the binary is
78    /// launched with a CLI arg, with `DICOM_VIEWER_DATA`, or from a CD
79    /// where a sibling `DICOM/` directory exists. Deferred so the window
80    /// paints once before the (potentially slow) folder scan runs.
81    pending_startup: Option<PathBuf>,
82}
83
84/// All viewport state belongs to a cell, so transforms persist when
85/// switching layouts and so dragging a different series onto a cell
86/// resets only that cell.
87///
88/// **Coordinate spaces:** [`Self::pan`] is in screen pixels;
89/// [`Self::zoom`] is a unitless scale; rotation is in quarter turns.
90/// Window/level applies to the rescaled pixel values held in
91/// [`RawImage::values`](crate::dcm::pixel::RawImage::values).
92#[derive(Clone)]
93pub struct CellState {
94    /// `(study_idx, series_idx)` into [`DicomViewerApp::studies`]. `None`
95    /// for an empty cell.
96    pub series_ref: Option<(usize, usize)>,
97    /// Index into the series' `instances` for the slice on screen.
98    pub slice: usize,
99    /// Pan offset in screen pixels.
100    pub pan: Vec2,
101    /// Display zoom (1.0 = fit to cell).
102    pub zoom: f32,
103    /// Quarter-turn rotation count (0, 1, 2, 3).
104    pub rotation_quarter: u8,
105    /// Horizontal flip.
106    pub flip_h: bool,
107    /// Vertical flip.
108    pub flip_v: bool,
109    /// Photometric inversion *requested by the user*. XOR'd with the
110    /// instance's intrinsic invert (MONOCHROME1) at render time.
111    pub invert: bool,
112    /// `(center, width)` override. `None` = use the auto window.
113    pub window: Option<(f64, f64)>,
114    /// Decode/render error for this cell, surfaced in the cell overlay.
115    pub error: Option<String>,
116    /// Cached uploaded texture, keyed by `(tex_uid, tex_window, tex_invert)`.
117    pub tex: Option<TextureHandle>,
118    /// SOP UID the cached texture was rendered from.
119    pub tex_uid: Option<String>,
120    /// Window/level the cached texture was rendered with.
121    pub tex_window: Option<(f64, f64)>,
122    /// Whether the cached texture has user-invert applied.
123    pub tex_invert: bool,
124}
125
126impl Default for CellState {
127    fn default() -> Self {
128        Self {
129            series_ref: None,
130            slice: 0,
131            pan: Vec2::ZERO,
132            zoom: 1.0,
133            rotation_quarter: 0,
134            flip_h: false,
135            flip_v: false,
136            invert: false,
137            window: None,
138            error: None,
139            tex: None,
140            tex_uid: None,
141            tex_window: None,
142            tex_invert: false,
143        }
144    }
145}
146
147impl CellState {
148    /// Reset pan/zoom/rotation/flip/invert/window to defaults. The cached
149    /// texture is left in place; the renderer notices the mismatch via
150    /// the `(tex_uid, tex_window, tex_invert)` comparison and regenerates.
151    pub fn reset_view(&mut self) {
152        self.pan = Vec2::ZERO;
153        self.zoom = 1.0;
154        self.rotation_quarter = 0;
155        self.flip_h = false;
156        self.flip_v = false;
157        self.invert = false;
158        self.window = None;
159        // Texture stays cached unless invert/window changed; the renderer
160        // notices the mismatch and regenerates.
161    }
162
163    /// Bind this cell to a series and slice, discarding all previous
164    /// transform/window state. Used when a series is dragged onto a cell.
165    pub fn assign(&mut self, series_ref: (usize, usize), slice: usize) {
166        *self = CellState::default();
167        self.series_ref = Some(series_ref);
168        self.slice = slice;
169    }
170}
171
172impl DicomViewerApp {
173    /// Build the app, install the theme, and load annotations from
174    /// `<data_dir>/annotations.json`.
175    ///
176    /// `startup_folder` defers the actual folder scan to the first
177    /// [`update`](eframe::App::update) tick so the window paints before
178    /// the (potentially CD-slow) scan begins.
179    pub fn new(
180        cc: &CreationContext<'_>,
181        paths: Paths,
182        config: Config,
183        startup_folder: Option<PathBuf>,
184    ) -> Self {
185        apply_visuals(&cc.egui_ctx, config.ui.dark_mode);
186        let needs_disclaimer = !config.disclaimer_acknowledged;
187        let ui_state = ui::UiState::new(needs_disclaimer, &config);
188        let annotations_path = paths.data_dir.join("annotations.json");
189        let annotation_store = AnnotationStore::load(&annotations_path);
190        Self {
191            paths,
192            config,
193            ui_state,
194            studies: Vec::new(),
195            raw_cache: HashMap::new(),
196            raw_failures: HashMap::new(),
197            metadata_cache: HashMap::new(),
198            metadata_filter: String::new(),
199            thumbnails: HashMap::new(),
200            grid: GridLayout::OneByOne,
201            cells: vec![CellState::default()],
202            active_cell: 0,
203            annotation_store,
204            annotations_path,
205            last_error: None,
206            last_info: None,
207            pending_drops: Vec::new(),
208            pending_startup: startup_folder,
209        }
210    }
211
212    /// Persist [`Self::annotation_store`] to [`Self::annotations_path`].
213    /// Failures are logged but not propagated — losing one annotation save
214    /// shouldn't crash the viewer.
215    pub fn save_annotations(&self) {
216        if let Err(e) = self.annotation_store.save(&self.annotations_path) {
217            tracing::warn!(error = %e, "annotation save failed");
218        }
219    }
220
221    /// Append `ann` to the list for `sop_uid` and immediately save.
222    pub fn push_annotation(&mut self, sop_uid: &str, ann: Annotation) {
223        self.annotation_store.push(sop_uid, ann);
224        self.save_annotations();
225    }
226
227    /// Drop every annotation on the instance currently shown by the active
228    /// cell and immediately save.
229    pub fn clear_annotations_for_active(&mut self) {
230        if let Some(inst) = self.active_instance().cloned() {
231            self.annotation_store.clear_instance(&inst.sop_instance_uid);
232            self.save_annotations();
233        }
234    }
235
236    /// Remove the most recently pushed annotation on the active cell's
237    /// instance and immediately save.
238    pub fn undo_last_annotation_for_active(&mut self) {
239        if let Some(inst) = self.active_instance().cloned() {
240            self.annotation_store.pop_last(&inst.sop_instance_uid);
241            self.save_annotations();
242        }
243    }
244
245    /// Switch grid layout, resizing [`Self::cells`] to match. Existing
246    /// cells are preserved in array order; extras (when shrinking) are
247    /// truncated. Resets [`Self::active_cell`] if it now falls out of range.
248    pub fn set_grid(&mut self, g: GridLayout) {
249        let n = g.cell_count();
250        self.grid = g;
251        while self.cells.len() < n {
252            self.cells.push(CellState::default());
253        }
254        self.cells.truncate(n);
255        if self.active_cell >= n {
256            self.active_cell = 0;
257        }
258    }
259
260    /// Scan a folder for DICOM files, replace [`Self::studies`], and
261    /// auto-arrange the first one. Clears every per-instance cache (raw
262    /// pixels, metadata rows, thumbnails) and every [`CellState`]. Errors
263    /// are surfaced via [`Self::last_error`] for the status bar — they're
264    /// not propagated to the caller.
265    pub fn open_folder(&mut self, folder: &Path) {
266        self.last_error = None;
267        match dcm::loader::load_folder(folder) {
268            Ok(studies) if studies.is_empty() => {
269                self.last_error = Some(format!("No DICOM files found in {}", folder.display()));
270                tracing::warn!(path = %folder.display(), "no DICOM files");
271            }
272            Ok(studies) => {
273                self.studies = studies;
274                self.raw_cache.clear();
275                self.raw_failures.clear();
276                self.metadata_cache.clear();
277                self.thumbnails.clear();
278                for c in &mut self.cells {
279                    *c = CellState::default();
280                }
281                // Auto-arrange: try MG hanging protocol across the whole
282                // study first (handles 4-series-of-1-instance layouts that
283                // the per-series ≥4 check in `select_series` would miss),
284                // otherwise drop the first series into the first cell.
285                if !self.studies.is_empty()
286                    && !self.try_arrange_mg_study(0)
287                    && !self.studies[0].series.is_empty()
288                {
289                    self.select_series(0, 0);
290                }
291                tracing::info!(studies = self.studies.len(), "loaded folder");
292            }
293            Err(e) => {
294                tracing::error!(error = %e, "load_folder failed");
295                self.last_error = Some(format!("{e:#}"));
296            }
297        }
298    }
299
300    /// Convenience: open the parent folder of a single file. We always
301    /// load a *folder* — there is no single-file mode.
302    pub fn open_file(&mut self, file: &Path) {
303        if let Some(parent) = file.parent() {
304            self.open_folder(parent);
305        }
306    }
307
308    /// Click on a series in the sidebar. First tries the MG hanging
309    /// protocol across the whole study (covers the 4-series-of-1-instance
310    /// case). If that doesn't apply, falls back to single-series MG (≥4
311    /// instances in this series) or assigning to the active cell.
312    pub fn select_series(&mut self, study_idx: usize, series_idx: usize) {
313        if self.is_mg_study(study_idx) && self.try_arrange_mg_study(study_idx) {
314            return;
315        }
316        let (is_mg, order) = match self
317            .studies
318            .get(study_idx)
319            .and_then(|s| s.series.get(series_idx))
320        {
321            Some(series) => (
322                series.is_mammography() && series.instances.len() >= 4,
323                mg_slice_order(series),
324            ),
325            None => return,
326        };
327        if is_mg {
328            self.set_grid(GridLayout::TwoByTwo);
329            for (cell_idx, slice_idx) in order.into_iter().enumerate().take(4) {
330                self.cells[cell_idx].assign((study_idx, series_idx), slice_idx);
331            }
332            self.active_cell = 0;
333        } else {
334            let cell = self.active_cell.min(self.cells.len().saturating_sub(1));
335            self.cells[cell].assign((study_idx, series_idx), 0);
336        }
337    }
338
339    /// Does this study contain any MG-modality series?
340    fn is_mg_study(&self, study_idx: usize) -> bool {
341        self.studies
342            .get(study_idx)
343            .map(|s| s.series.iter().any(|se| se.is_mammography()))
344            .unwrap_or(false)
345    }
346
347    /// Look across every MG series in the study and assemble a 2×2 layout
348    /// when there's at least one right-breast and one left-breast view.
349    ///
350    /// Placement priority:
351    /// 1. Strict — RCC, LCC, RMLO, LMLO all present: place in that order.
352    /// 2. View-known — at least 2 R and 2 L with `ViewPosition` set: sort
353    ///    each side's queue by view (CC before MLO) so the top row is the
354    ///    cranio-caudal pair.
355    /// 3. Laterality-only — at least 2 R and 2 L but `ViewPosition` is
356    ///    missing (common in some PACS exports): pair in series/instance
357    ///    order.
358    pub fn try_arrange_mg_study(&mut self, study_idx: usize) -> bool {
359        let Some(study) = self.studies.get(study_idx) else {
360            return false;
361        };
362
363        // (view_priority, series_idx, instance_idx). view_priority sorts
364        // CC < MLO < unknown so CCs end up on the top row when known.
365        let mut rights: Vec<(u8, usize, usize)> = Vec::new();
366        let mut lefts: Vec<(u8, usize, usize)> = Vec::new();
367        for (si, series) in study.series.iter().enumerate() {
368            if !series.is_mammography() {
369                continue;
370            }
371            for (ii, inst) in series.instances.iter().enumerate() {
372                let side = match inst.image_laterality.as_deref() {
373                    Some("R") => &mut rights,
374                    Some("L") => &mut lefts,
375                    _ => continue,
376                };
377                let vp = match inst.view_position.as_deref() {
378                    Some("CC") => 0u8,
379                    Some("MLO") => 1u8,
380                    _ => 2u8,
381                };
382                side.push((vp, si, ii));
383            }
384        }
385        if rights.len() < 2 || lefts.len() < 2 {
386            return false;
387        }
388        rights.sort_by_key(|&(vp, si, ii)| (vp, si, ii));
389        lefts.sort_by_key(|&(vp, si, ii)| (vp, si, ii));
390
391        self.set_grid(GridLayout::TwoByTwo);
392        let pick = |v: &[(u8, usize, usize)], idx: usize| (v[idx].1, v[idx].2);
393        let (r0_si, r0_ii) = pick(&rights, 0);
394        let (l0_si, l0_ii) = pick(&lefts, 0);
395        let (r1_si, r1_ii) = pick(&rights, 1);
396        let (l1_si, l1_ii) = pick(&lefts, 1);
397
398        self.cells[0].assign((study_idx, r0_si), r0_ii);
399        self.cells[1].assign((study_idx, l0_si), l0_ii);
400        self.cells[2].assign((study_idx, r1_si), r1_ii);
401        self.cells[3].assign((study_idx, l1_si), l1_ii);
402        self.active_cell = 0;
403        tracing::info!(
404            study_idx,
405            r = rights.len(),
406            l = lefts.len(),
407            "applied MG hanging protocol (study-level)"
408        );
409        true
410    }
411
412    /// Queue a drag-drop assignment. Validated now (so we don't push
413    /// garbage), applied next frame (so the cell's current TextureHandle
414    /// survives the rest of this frame's GPU submission).
415    pub fn drop_series_onto_cell(&mut self, cell_idx: usize, series_ref: (usize, usize)) {
416        if cell_idx >= self.cells.len() {
417            tracing::warn!(cell_idx, len = self.cells.len(), "drop: cell out of range");
418            return;
419        }
420        let (si, se) = series_ref;
421        let valid = self
422            .studies
423            .get(si)
424            .and_then(|s| s.series.get(se))
425            .is_some_and(|s| !s.instances.is_empty());
426        if !valid {
427            tracing::warn!(si, se, "drop: invalid or empty series");
428            return;
429        }
430        tracing::info!(cell_idx, si, se, "queue drop series onto cell");
431        // Replace any earlier-queued drop for the same cell — last one wins.
432        self.pending_drops.retain(|(c, _)| *c != cell_idx);
433        self.pending_drops.push((cell_idx, series_ref));
434    }
435
436    /// Apply queued drops. Called at the *start* of each frame, before
437    /// any rendering, so dropping a CellState's old TextureHandle is safe.
438    fn flush_pending_drops(&mut self) {
439        if self.pending_drops.is_empty() {
440            return;
441        }
442        let drops = std::mem::take(&mut self.pending_drops);
443        for (cell_idx, series_ref) in drops {
444            if cell_idx >= self.cells.len() {
445                continue;
446            }
447            self.cells[cell_idx].assign(series_ref, 0);
448            self.active_cell = cell_idx;
449        }
450    }
451
452    /// Get the decoded [`RawImage`] for an instance, decoding on first
453    /// miss. Returns `None` (and remembers the failure in
454    /// [`Self::raw_failures`]) when decode fails, so the viewport doesn't
455    /// re-attempt every frame.
456    pub fn raw_for(&mut self, sop_uid: &str, path: &Path) -> Option<Arc<RawImage>> {
457        if let Some(r) = self.raw_cache.get(sop_uid) {
458            return Some(r.clone());
459        }
460        if self.raw_failures.contains_key(sop_uid) {
461            return None;
462        }
463        match dcm::load_raw(path) {
464            Ok(r) => {
465                let arc = Arc::new(r);
466                self.raw_cache.insert(sop_uid.to_string(), arc.clone());
467                Some(arc)
468            }
469            Err(e) => {
470                tracing::warn!(path = %path.display(), error = %e, "raw load failed");
471                self.raw_failures
472                    .insert(sop_uid.to_string(), format!("{e:#}"));
473                None
474            }
475        }
476    }
477
478    /// Pick up files dropped onto the egui window. A dropped folder
479    /// triggers [`Self::open_folder`]; a dropped file triggers
480    /// [`Self::open_file`].
481    pub fn handle_dropped_files(&mut self, ctx: &Context) {
482        let dropped = ctx.input(|i| i.raw.dropped_files.clone());
483        if dropped.is_empty() {
484            return;
485        }
486        for f in dropped {
487            if let Some(path) = f.path {
488                if path.is_dir() {
489                    self.open_folder(&path);
490                } else {
491                    self.open_file(&path);
492                }
493                break;
494            }
495        }
496    }
497
498    /// Apply [`CellState::reset_view`] to the active cell.
499    pub fn reset_active_cell(&mut self) {
500        if let Some(c) = self.cells.get_mut(self.active_cell) {
501            c.reset_view();
502        }
503    }
504
505    /// Return the instance the active cell is currently showing, if any.
506    pub fn active_instance(&self) -> Option<&crate::dcm::Instance> {
507        let cell = self.cells.get(self.active_cell)?;
508        let (si, se) = cell.series_ref?;
509        let series = self.studies.get(si)?.series.get(se)?;
510        series.instances.get(cell.slice)
511    }
512
513    /// Sidebar asks per row: "do you have a thumbnail for this series?"
514    /// We register a `Pending` entry on first miss; the per-frame pump in
515    /// [`Self::pump_thumbnails`] decodes one at a time.
516    pub fn thumbnail_for(&mut self, series_uid: &str) -> Option<TextureHandle> {
517        match self.thumbnails.get(series_uid) {
518            Some(ThumbnailState::Ready(t)) => Some(t.clone()),
519            Some(_) => None,
520            None => {
521                self.thumbnails
522                    .insert(series_uid.to_string(), ThumbnailState::Pending);
523                None
524            }
525        }
526    }
527
528    /// Decode at most one pending sidebar thumbnail per frame, so the UI
529    /// thread isn't blocked by a long chain of MG-sized decodes.
530    pub fn pump_thumbnails(&mut self, ctx: &Context) {
531        let next: Option<(String, PathBuf)> = self
532            .thumbnails
533            .iter()
534            .find_map(|(uid, state)| matches!(state, ThumbnailState::Pending).then(|| uid.clone()))
535            .and_then(|uid| {
536                self.studies
537                    .iter()
538                    .flat_map(|s| s.series.iter())
539                    .find(|se| se.series_instance_uid == uid)
540                    .and_then(|s| s.instances.first())
541                    .map(|inst| (uid, inst.path.clone()))
542            });
543        let Some((uid, path)) = next else {
544            return;
545        };
546        match crate::dcm::thumbnail::load_thumbnail(&path, 96) {
547            Ok(t) => {
548                let img = egui::ColorImage::from_rgba_unmultiplied(
549                    [t.width as usize, t.height as usize],
550                    &t.rgba,
551                );
552                let tex =
553                    ctx.load_texture(format!("thumb-{uid}"), img, egui::TextureOptions::LINEAR);
554                self.thumbnails.insert(uid, ThumbnailState::Ready(tex));
555            }
556            Err(e) => {
557                tracing::warn!(path = %path.display(), error = %e, "thumbnail failed");
558                self.thumbnails.insert(uid, ThumbnailState::Failed);
559            }
560        }
561        ctx.request_repaint();
562    }
563
564    /// Get the flattened metadata rows for an instance, parsing on first
565    /// miss. Failures are logged but not cached, since metadata parsing is
566    /// cheap and unlikely to repeat-fail.
567    pub fn metadata_for(&mut self, sop_uid: &str, path: &Path) -> Option<Arc<Vec<TagRow>>> {
568        if let Some(rows) = self.metadata_cache.get(sop_uid) {
569            return Some(rows.clone());
570        }
571        match dcm::metadata::collect_tags(path) {
572            Ok(rows) => {
573                let arc = Arc::new(rows);
574                self.metadata_cache.insert(sop_uid.to_string(), arc.clone());
575                Some(arc)
576            }
577            Err(e) => {
578                tracing::warn!(path = %path.display(), error = %e, "metadata load failed");
579                None
580            }
581        }
582    }
583}
584
585/// Standard mammography panel order: RCC, LCC, RMLO, LMLO.
586fn mg_slice_order(series: &crate::dcm::Series) -> Vec<usize> {
587    let key = |inst: &crate::dcm::Instance| -> u8 {
588        let lat = inst.image_laterality.as_deref().unwrap_or("");
589        let view = inst.view_position.as_deref().unwrap_or("");
590        match (lat, view) {
591            ("R", "CC") => 0,
592            ("L", "CC") => 1,
593            ("R", "MLO") => 2,
594            ("L", "MLO") => 3,
595            _ => 4,
596        }
597    };
598    let mut indexed: Vec<(u8, usize)> = series
599        .instances
600        .iter()
601        .enumerate()
602        .map(|(i, inst)| (key(inst), i))
603        .collect();
604    indexed.sort_by_key(|(k, _)| *k);
605    indexed.into_iter().map(|(_, i)| i).collect()
606}
607
608fn apply_visuals(ctx: &Context, _dark: bool) {
609    // The Workstation Noir theme is dark-only by design — light mode would
610    // break the radiograph-glow accent that anchors the whole palette.
611    crate::ui::theme::install(ctx);
612}
613
614/// Lifecycle of a sidebar series thumbnail.
615///
616/// New entries land as [`Self::Pending`] when the sidebar first asks for a
617/// thumbnail. [`DicomViewerApp::pump_thumbnails`] decodes at most one per
618/// frame, transitioning to [`Self::Ready`] or [`Self::Failed`].
619pub enum ThumbnailState {
620    /// Queued; the per-frame pump will decode this next.
621    Pending,
622    /// Decoded and uploaded as a GPU texture.
623    Ready(TextureHandle),
624    /// Decode failed; don't retry.
625    Failed,
626}
627
628impl eframe::App for DicomViewerApp {
629    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
630        // CRITICAL: apply drops queued by last frame *before* rendering.
631        // See `pending_drops` doc comment — dropping a TextureHandle
632        // mid-frame triggers a wgpu "texture destroyed" panic.
633        self.flush_pending_drops();
634        self.handle_dropped_files(ctx);
635        // Auto-load on first frame so the empty UI shows for one tick
636        // before the (potentially CD-slow) folder scan begins.
637        if let Some(folder) = self.pending_startup.take() {
638            self.last_info = Some(format!("Loading {} …", folder.display()));
639            tracing::info!(path = %folder.display(), "autoload startup folder");
640            self.open_folder(&folder);
641            ctx.request_repaint();
642        }
643        ui::draw(ctx, self);
644        // After the UI registered any new Pending thumbnails, decode at
645        // most one this frame and request a repaint if there's more work.
646        self.pump_thumbnails(ctx);
647    }
648
649    fn on_exit(&mut self) {
650        if let Err(e) = self.config.save(&self.paths) {
651            tracing::warn!(error = %e, "failed to save config on exit");
652        }
653    }
654}