Skip to content

Commit ba81b01

Browse files
authored
Add full metadata fields popup (#60)
1 parent 5856401 commit ba81b01

10 files changed

Lines changed: 1038 additions & 46 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jpeg2000 = ["dicom-pixeldata/openjp2"]
1818

1919
[dependencies]
2020
anyhow = "1.0"
21+
dicom-core = "0.8.2"
2122
dicom-object = "0.8.2"
2223
dicom-pixeldata = "0.8.2"
2324
eframe = "0.30"
@@ -27,7 +28,6 @@ rfd = "0.15"
2728
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
2829

2930
[dev-dependencies]
30-
dicom-core = "0.8.2"
3131
dicom-encoding = "0.8.2"
3232
dicom-transfer-syntax-registry = "0.8.2"
3333

DESIGN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Its primary purpose is consistency during development, not full architecture cov
1919
- `src/renderer.rs`: pixel buffer to `egui::ColorImage` rendering helpers.
2020
- `src/logging.rs`: logging setup and log-level configuration.
2121
- `src/app.rs`: UI, application state, interactions, and worker orchestration.
22+
- `src/app/metadata.rs`: metadata overlay, metadata popup, and active-object metadata presentation.
2223
- `src/app/overlay.rs`: overlay reconciliation, authoritative overlay snapshots, and overlay availability/navigation.
2324
- `src/app/load.rs`: launch/open/load orchestration and DICOMweb/local load pipelines.
2425
- `src/app/history.rs`: history management and preload/orchestration.
@@ -47,6 +48,7 @@ Its primary purpose is consistency during development, not full architecture cov
4748
19. If the user switches away from a streaming DICOMweb active group, remaining active-group work MUST continue staging into history and MUST NOT clear, replace, or visually mask the currently displayed study.
4849
20. Multi-frame images with per-frame `ImagePositionPatient` MUST expose frames in logical patient-position order; if the dominant per-frame patient-position progression increases across stored frames, display and cine MUST reverse with it, and GSPS/SR frame lookups MUST translate the displayed frame back to the referenced stored DICOM frame.
4950
21. DICOM content inside the viewer MUST use explicit `DicomSource` ownership; DICOMweb bytes MUST be represented as `DicomSource::Memory`, not temp files or a global backing store.
51+
22. Visible metadata field settings MUST apply only to the summary overlay; the full metadata popup MUST ignore that filter and show all extracted fields for the active object.
5052

5153
## Change Rules
5254

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Perspecta DICOM Viewer is an open-source Rust desktop DICOM viewer (`egui`/`efra
3535
- Structured Report (SR) DICOM support with a dedicated text/document view.
3636
- Mouse-wheel zoom + drag pan in single-image and multi-view (`1x2` / `1x3` / `2x2` / `2x4`) mammo views.
3737
- Typical DICOM mouse conventions (single modifier): `Shift + wheel` for frame navigation and `Shift + drag` for window/level in multi-view layouts.
38-
- Metadata side panel for quick inspection.
38+
- Metadata side panel for quick inspection, with a full-field popup for the active object (`V`).
3939
- Launch through a custom URL scheme (`perspecta://...`).
4040
- Launch directly from DICOMweb (study/series/instance aware).
4141

@@ -151,6 +151,7 @@ This writes a desktop entry under `~/.local/share/applications`.
151151
- `C`: toggle cine mode
152152
- `G`: toggle image overlay (GSPS, Mammography CAD SR, or a matching Parametric Map, when available)
153153
- `N`: jump to the next image/frame with an overlay
154+
- `V`: open or close the full metadata field popup for the active object
154155
- `Tab`: next history item
155156
- `Shift+Tab`: previous history item
156157
- `Cmd/Ctrl+W`: close the active study/group; if the window is already empty, close the window

src/app.rs

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::env;
33
use std::fs;
44
use std::path::{Path, PathBuf};
55
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
6+
use std::sync::Arc;
67
use std::thread;
78
use std::time::{Duration, Instant};
89

@@ -13,9 +14,9 @@ use eframe::egui::{
1314
use crate::dicom::{
1415
classify_dicom_path, load_dicom, load_gsps_overlays, load_mammography_cad_sr_overlays,
1516
load_parametric_map, load_parametric_map_overlays, load_structured_report,
16-
read_sop_instance_uid, DicomImage, DicomPathKind, DicomSource, DicomSourceMeta, GspsGraphic,
17-
GspsOverlay, GspsUnits, ParametricMapOverlay, SrOverlay, StructuredReportDocument,
18-
StructuredReportNode, METADATA_FIELD_NAMES,
17+
read_sop_instance_uid, DicomImage, DicomPathKind, DicomSource, DicomSourceMeta,
18+
FullMetadataField, GspsGraphic, GspsOverlay, GspsUnits, ParametricMapOverlay, SrOverlay,
19+
StructuredReportDocument, StructuredReportNode, METADATA_FIELD_NAMES,
1920
};
2021
use crate::dicomweb::{
2122
download_dicomweb_group_request, download_dicomweb_request, DicomWebDownloadResult,
@@ -27,6 +28,7 @@ use crate::renderer::{blend_rgba_overlay, render_rgb, render_window_level};
2728

2829
mod history;
2930
mod load;
31+
mod metadata;
3032
mod overlay;
3133

3234
#[cfg(test)]
@@ -42,6 +44,7 @@ use self::load::{PendingLoad, PendingSingleLoad, PreparedLoadPaths};
4244

4345
const APP_TITLE: &str = "Perspecta Viewer";
4446
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
47+
const TITLE_TEXT_SIZE: f32 = 14.0;
4548
const HISTORY_MAX_ENTRIES: usize = 24;
4649
const HISTORY_THUMB_MAX_DIM: usize = 96;
4750
const HISTORY_LIST_THUMB_MAX_DIM: f32 = 56.0;
@@ -100,6 +103,16 @@ struct ActiveViewportState {
100103
current_frame: usize,
101104
}
102105

106+
enum FullMetadataLoadResult {
107+
Loaded {
108+
source: DicomSource,
109+
metadata: Arc<[FullMetadataField]>,
110+
},
111+
Failed {
112+
source: DicomSource,
113+
},
114+
}
115+
103116
pub struct DicomViewerApp {
104117
image: Option<DicomImage>,
105118
report: Option<StructuredReportDocument>,
@@ -109,6 +122,7 @@ pub struct DicomViewerApp {
109122
mammo_selected_index: usize,
110123
history_entries: Vec<HistoryEntry>,
111124
visible_metadata_fields: HashSet<String>,
125+
full_metadata_popup_open: bool,
112126
settings_path: Option<PathBuf>,
113127
history_nonce: u64,
114128
pending_history_open_id: Option<String>,
@@ -122,6 +136,8 @@ pub struct DicomViewerApp {
122136
dicomweb_active_group_paths: Vec<DicomSourceMeta>,
123137
dicomweb_completed_background_groups: HashSet<usize>,
124138
dicomweb_active_pending_paths: VecDeque<DicomSource>,
139+
full_metadata_receiver: Option<Receiver<FullMetadataLoadResult>>,
140+
full_metadata_sender: Option<Sender<FullMetadataLoadResult>>,
125141
single_load_receiver: Option<Receiver<Result<PendingSingleLoad, String>>>,
126142
mammo_load_receiver: Option<Receiver<Result<PendingLoad, String>>>,
127143
mammo_load_sender: Option<Sender<Result<PendingLoad, String>>>,
@@ -158,6 +174,7 @@ impl Default for DicomViewerApp {
158174
impl DicomViewerApp {
159175
pub fn new(initial_request: Option<LaunchRequest>) -> Self {
160176
let settings_path = metadata_settings_file_path();
177+
let (full_metadata_sender, full_metadata_receiver) = mpsc::channel();
161178
let visible_metadata_fields = settings_path
162179
.as_deref()
163180
.and_then(load_visible_metadata_fields)
@@ -172,6 +189,7 @@ impl DicomViewerApp {
172189
mammo_selected_index: 0,
173190
history_entries: Vec::new(),
174191
visible_metadata_fields,
192+
full_metadata_popup_open: false,
175193
settings_path,
176194
history_nonce: 0,
177195
pending_history_open_id: None,
@@ -185,6 +203,8 @@ impl DicomViewerApp {
185203
dicomweb_active_group_paths: Vec::new(),
186204
dicomweb_completed_background_groups: HashSet::new(),
187205
dicomweb_active_pending_paths: VecDeque::new(),
206+
full_metadata_receiver: Some(full_metadata_receiver),
207+
full_metadata_sender: Some(full_metadata_sender),
188208
single_load_receiver: None,
189209
mammo_load_receiver: None,
190210
mammo_load_sender: None,
@@ -976,6 +996,15 @@ impl DicomViewerApp {
976996
}
977997
}
978998

999+
fn active_image_mut(&mut self) -> Option<&mut DicomImage> {
1000+
if self.image.is_some() {
1001+
self.image.as_mut()
1002+
} else {
1003+
self.selected_mammo_viewport_mut()
1004+
.map(|viewport| &mut viewport.image)
1005+
}
1006+
}
1007+
9791008
fn active_metadata(&self) -> Option<&[(String, String)]> {
9801009
if let Some(image) = self.active_image() {
9811010
Some(image.metadata.as_slice())
@@ -1855,6 +1884,7 @@ impl eframe::App for DicomViewerApp {
18551884
self.poll_dicomweb_active_paths(ctx);
18561885
self.poll_dicomweb_download(ctx);
18571886
self.poll_history_preload(ctx);
1887+
self.poll_full_metadata_load(ctx);
18581888
self.poll_single_load(ctx);
18591889
self.poll_mammo_group_load(ctx);
18601890
if self.frame_wait_pending && !self.cine_mode {
@@ -1878,6 +1908,8 @@ impl eframe::App for DicomViewerApp {
18781908
let mut c_pressed = false;
18791909
let mut g_pressed = false;
18801910
let mut n_pressed = false;
1911+
let mut v_pressed = false;
1912+
let mut escape_pressed = false;
18811913
ctx.input_mut(|input| {
18821914
if input.consume_key(
18831915
egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
@@ -1894,6 +1926,12 @@ impl eframe::App for DicomViewerApp {
18941926
c_pressed = input.consume_key(egui::Modifiers::NONE, egui::Key::C);
18951927
g_pressed = input.consume_key(egui::Modifiers::NONE, egui::Key::G);
18961928
n_pressed = input.consume_key(egui::Modifiers::NONE, egui::Key::N);
1929+
if self.can_toggle_full_metadata_popup() {
1930+
v_pressed = input.consume_key(egui::Modifiers::NONE, egui::Key::V);
1931+
}
1932+
if self.full_metadata_popup_open {
1933+
escape_pressed = input.consume_key(egui::Modifiers::NONE, egui::Key::Escape);
1934+
}
18971935
});
18981936
if close_app_requested {
18991937
ctx.send_viewport_cmd(ViewportCommand::Close);
@@ -1919,6 +1957,12 @@ impl eframe::App for DicomViewerApp {
19191957
if n_pressed && !history_transition_pending {
19201958
self.jump_to_next_overlay(ctx);
19211959
}
1960+
if v_pressed {
1961+
self.toggle_full_metadata_popup();
1962+
}
1963+
if escape_pressed {
1964+
self.close_full_metadata_popup();
1965+
}
19221966

19231967
let mut open_dicoms_clicked = false;
19241968
let hovered_files = ctx.input(|input| input.raw.hovered_files.clone());
@@ -1994,7 +2038,7 @@ impl eframe::App for DicomViewerApp {
19942038
window_centered_title_pos,
19952039
egui::Align2::CENTER_CENTER,
19962040
&title_text,
1997-
egui::FontId::proportional(14.0),
2041+
egui::FontId::proportional(TITLE_TEXT_SIZE),
19982042
ui.visuals().text_color(),
19992043
);
20002044

@@ -2592,35 +2636,7 @@ impl eframe::App for DicomViewerApp {
25922636
}
25932637
});
25942638

2595-
if let Some(metadata) = self.active_metadata() {
2596-
let overlay_height = (ctx.screen_rect().height() * 0.62).max(180.0);
2597-
egui::Area::new(egui::Id::new("metadata-overlay-left"))
2598-
.order(egui::Order::Foreground)
2599-
.anchor(egui::Align2::LEFT_TOP, egui::vec2(10.0, 36.0))
2600-
.show(ctx, |ui| {
2601-
ui.set_min_width(300.0);
2602-
ui.set_max_width(300.0);
2603-
ui.set_max_height(overlay_height);
2604-
egui::ScrollArea::vertical()
2605-
.id_salt("metadata-overlay-scroll")
2606-
.show(ui, |ui| {
2607-
let mut shown_count = 0usize;
2608-
for (key, value) in metadata {
2609-
if !self.visible_metadata_fields.contains(key.as_str()) {
2610-
continue;
2611-
}
2612-
shown_count = shown_count.saturating_add(1);
2613-
ui.horizontal_wrapped(|ui| {
2614-
ui.monospace(key);
2615-
ui.label(value);
2616-
});
2617-
}
2618-
if shown_count == 0 {
2619-
ui.label("No metadata fields selected.");
2620-
}
2621-
});
2622-
});
2623-
}
2639+
self.show_metadata_ui(ctx);
26242640

26252641
if has_history {
26262642
let overlay_height = (ctx.screen_rect().height() * 0.62).max(160.0);
@@ -5854,7 +5870,7 @@ mod tests {
58545870
let (tx, rx) = mpsc::channel::<Result<PendingSingleLoad, String>>();
58555871
tx.send(Ok(PendingSingleLoad::StructuredReport {
58565872
path: test_source("report.dcm"),
5857-
report: StructuredReportDocument::test_stub(),
5873+
report: Box::new(StructuredReportDocument::test_stub()),
58585874
}))
58595875
.expect("report should send");
58605876

src/app/load.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub(super) enum PendingSingleLoad {
3131
Image(Box<PendingLoad>),
3232
StructuredReport {
3333
path: DicomSource,
34-
report: StructuredReportDocument,
34+
report: Box<StructuredReportDocument>,
3535
},
3636
}
3737

@@ -837,7 +837,7 @@ impl DicomViewerApp {
837837
self.clear_load_error();
838838
}
839839
Ok(PendingSingleLoad::StructuredReport { path, report }) => {
840-
self.apply_loaded_structured_report(path, report, ctx);
840+
self.apply_loaded_structured_report(path, *report, ctx);
841841
self.clear_load_error();
842842
}
843843
Err(err) => {
@@ -1113,7 +1113,10 @@ impl DicomViewerApp {
11131113
let (tx, rx) = mpsc::channel::<Result<PendingSingleLoad, String>>();
11141114
thread::spawn(move || {
11151115
let result = match load_structured_report(&path) {
1116-
Ok(report) => Ok(PendingSingleLoad::StructuredReport { path, report }),
1116+
Ok(report) => Ok(PendingSingleLoad::StructuredReport {
1117+
path,
1118+
report: Box::new(report),
1119+
}),
11171120
Err(err) => Err(format!("Error opening selected Structured Report: {err:#}")),
11181121
};
11191122
let _ = tx.send(result);

0 commit comments

Comments
 (0)