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}