Skip to main content

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}