dicom_viewer/ui/mod.rs
1//! UI composition.
2//!
3//! The egui side of the viewer. [`draw`] is called once per frame from
4//! [`DicomViewerApp::update`](crate::app::DicomViewerApp). It paints the
5//! panels in this order:
6//!
7//! 1. `menu_bar` — top.
8//! 2. `toolbar` — top, beneath the menu bar.
9//! 3. `status_bar` — bottom.
10//! 4. `study_browser` — left side panel (optional).
11//! 5. `metadata_panel` — right side panel (optional).
12//! 6. `viewport` — central area, multi-cell grid.
13//! 7. Modal dialogs (`disclaimer`, `about`).
14//!
15//! ## Drag-and-drop quirks (egui 0.29)
16//!
17//! The sidebar uses `dnd_drag_source`. The inner `selectable_label`'s
18//! response carries click sense (`inner.inner.clicked()`); the outer
19//! response only has hover + drag. The viewport reads the payload via
20//! `DragAndDrop::payload::<SeriesDragPayload>` and consumes it with
21//! `clear_payload` on release. **Do not wrap cells in `dnd_drop_zone`** —
22//! painted-not-allocated content makes the zone collapse.
23
24mod dialogs;
25mod menu_bar;
26mod status_bar;
27mod study_browser;
28pub mod theme;
29mod toolbar;
30mod viewport;
31
32use crate::app::DicomViewerApp;
33use crate::config::Config;
34use egui::Context;
35
36/// Transient UI state — dialog visibility, active tool, panel visibility,
37/// in-progress measurement. Persists across frames but not across runs
38/// (use [`crate::config::UiConfig`] for the latter).
39pub struct UiState {
40 /// Show the "Not for diagnostic use" modal on this run.
41 pub show_disclaimer: bool,
42 /// Show the About modal on this run.
43 pub show_about: bool,
44 /// Show the right-hand metadata panel.
45 pub show_metadata_panel: bool,
46 /// Show the left-hand study browser.
47 pub show_study_browser: bool,
48 /// Toolbar selection.
49 pub active_tool: ActiveTool,
50 /// When `true`, draw existing annotations over the image.
51 pub annotations_visible: bool,
52 /// Mid-construction annotation (e.g. first click of a length measurement).
53 pub in_progress: Option<InProgress>,
54}
55
56/// Click- or drag-based annotation under construction. All coords are in
57/// displayed-image pixels (post-decimation).
58///
59/// Length and Angle are click-step state machines; Rect and Ellipse are
60/// drag-state with continuously-updated `cur`.
61#[derive(Debug, Clone, Copy)]
62pub enum InProgress {
63 /// First click of a Length measurement placed.
64 LengthP1([f32; 2]),
65 /// First click of an Angle measurement placed (p1).
66 AngleP1([f32; 2]),
67 /// First two clicks of an Angle measurement placed (p1, vertex).
68 AngleP1V([f32; 2], [f32; 2]),
69 /// Rectangle ROI being dragged.
70 RectDrag {
71 /// Where the drag started.
72 start: [f32; 2],
73 /// Current cursor position.
74 cur: [f32; 2],
75 },
76 /// Ellipse ROI being dragged.
77 EllipseDrag {
78 /// Where the drag started.
79 start: [f32; 2],
80 /// Current cursor position.
81 cur: [f32; 2],
82 },
83}
84
85/// Which toolbar button is currently active. Drives mouse-drag behaviour
86/// in the viewport. Window/Level, Pan, and Zoom are mutually exclusive
87/// with the measurement tools; middle-mouse pan works in all modes.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum ActiveTool {
90 /// Drag to adjust window centre / width (default).
91 WindowLevel,
92 /// Drag to pan the image.
93 Pan,
94 /// Drag to zoom (down = in, up = out).
95 Zoom,
96 /// Two-click distance measurement.
97 Length,
98 /// Three-click angle measurement.
99 Angle,
100 /// Drag-to-place rectangle ROI.
101 RectRoi,
102 /// Drag-to-place ellipse ROI.
103 EllipseRoi,
104}
105
106impl ActiveTool {
107 /// `true` for Length / Angle / RectRoi / EllipseRoi — the tools that
108 /// produce annotations.
109 pub fn is_measurement(self) -> bool {
110 matches!(
111 self,
112 Self::Length | Self::Angle | Self::RectRoi | Self::EllipseRoi
113 )
114 }
115}
116
117/// Viewport grid layout — number of columns × rows of cells.
118///
119/// The viewer supports layouts from 1×1 up to 4×4. Switching layouts
120/// preserves existing [`crate::app::CellState`] entries in order and
121/// truncates extras.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum GridLayout {
124 /// Single cell.
125 OneByOne,
126 /// Two cells side by side.
127 OneByTwo,
128 /// Two cells stacked.
129 TwoByOne,
130 /// 2×2 — the MG hanging-protocol layout.
131 TwoByTwo,
132 /// Three cells side by side.
133 OneByThree,
134 /// Three cells stacked.
135 ThreeByOne,
136 /// 3 columns × 2 rows.
137 TwoByThree,
138 /// 2 columns × 3 rows.
139 ThreeByTwo,
140 /// 3×3 — useful for CT slabs.
141 ThreeByThree,
142 /// 4 columns × 2 rows.
143 TwoByFour,
144 /// 2 columns × 4 rows.
145 FourByTwo,
146 /// 4×4 — maximum supported.
147 FourByFour,
148}
149
150impl GridLayout {
151 /// Returns `(cols, rows)`.
152 pub fn dims(self) -> (usize, usize) {
153 match self {
154 Self::OneByOne => (1, 1),
155 Self::OneByTwo => (2, 1),
156 Self::TwoByOne => (1, 2),
157 Self::TwoByTwo => (2, 2),
158 Self::OneByThree => (3, 1),
159 Self::ThreeByOne => (1, 3),
160 Self::TwoByThree => (3, 2),
161 Self::ThreeByTwo => (2, 3),
162 Self::ThreeByThree => (3, 3),
163 Self::TwoByFour => (4, 2),
164 Self::FourByTwo => (2, 4),
165 Self::FourByFour => (4, 4),
166 }
167 }
168 /// Total cell count = `cols * rows`.
169 pub fn cell_count(self) -> usize {
170 let (c, r) = self.dims();
171 c * r
172 }
173 /// Short label for the toolbar dropdown (e.g. `"2×2"`).
174 pub fn label(self) -> &'static str {
175 match self {
176 Self::OneByOne => "1×1",
177 Self::OneByTwo => "1×2",
178 Self::TwoByOne => "2×1",
179 Self::TwoByTwo => "2×2",
180 Self::OneByThree => "1×3",
181 Self::ThreeByOne => "3×1",
182 Self::TwoByThree => "2×3",
183 Self::ThreeByTwo => "3×2",
184 Self::ThreeByThree => "3×3",
185 Self::TwoByFour => "2×4",
186 Self::FourByTwo => "4×2",
187 Self::FourByFour => "4×4",
188 }
189 }
190 /// Every variant, in toolbar-display order.
191 pub const ALL: [GridLayout; 12] = [
192 Self::OneByOne,
193 Self::OneByTwo,
194 Self::TwoByOne,
195 Self::TwoByTwo,
196 Self::OneByThree,
197 Self::ThreeByOne,
198 Self::TwoByThree,
199 Self::ThreeByTwo,
200 Self::ThreeByThree,
201 Self::TwoByFour,
202 Self::FourByTwo,
203 Self::FourByFour,
204 ];
205}
206
207impl UiState {
208 /// Construct initial UI state from the persisted [`Config`] and a
209 /// disclaimer flag. The disclaimer modal is shown once per machine
210 /// (until acknowledged).
211 pub fn new(show_disclaimer: bool, cfg: &Config) -> Self {
212 Self {
213 show_disclaimer,
214 show_about: false,
215 show_metadata_panel: cfg.ui.show_metadata_panel,
216 show_study_browser: cfg.ui.show_study_browser,
217 active_tool: ActiveTool::WindowLevel,
218 annotations_visible: true,
219 in_progress: None,
220 }
221 }
222}
223
224/// Paint every UI panel for the current frame. Called once per frame
225/// from the app's `update` method.
226pub fn draw(ctx: &Context, app: &mut DicomViewerApp) {
227 menu_bar::draw(ctx, app);
228 toolbar::draw(ctx, app);
229 status_bar::draw(ctx, app);
230
231 if app.ui_state.show_study_browser {
232 study_browser::draw(ctx, app);
233 }
234 if app.ui_state.show_metadata_panel {
235 dialogs::metadata_panel::draw(ctx, app);
236 }
237
238 viewport::draw(ctx, app);
239
240 dialogs::disclaimer::draw(ctx, app);
241 dialogs::about::draw(ctx, app);
242}