Skip to main content

dais_ui/
display_mode.rs

1//! Display mode management — intelligent window placement based on monitor topology.
2//!
3//! Determines how the presenter and audience windows should be positioned
4//! based on CLI flags, configuration, and detected monitors.
5
6use std::sync::{Arc, OnceLock};
7
8use dais_core::config::Config;
9use dais_core::monitor::{MonitorInfo, MonitorManager};
10use dais_document::page::RenderSize;
11use dais_document::render_pipeline::FALLBACK_RENDER_SIZE;
12
13/// How the application should lay out presenter and audience windows.
14#[derive(Debug, Clone)]
15pub enum DisplayMode {
16    /// Dual monitor: presenter on primary, audience fullscreen on secondary.
17    Dual { audience_monitor: MonitorInfo },
18    /// Single window with presenter only (no audience viewport).
19    Single,
20    /// Audience as a normal resizable window (for screen sharing).
21    ScreenShare,
22}
23
24/// Which presentation surface to use in single-monitor mode.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SingleMonitorView {
27    /// Fullscreen slide with hoverable notes bar.
28    Hud,
29    /// Audience slide beside a compact presenter strip.
30    Split,
31}
32
33impl SingleMonitorView {
34    /// Parse a config value into a single-monitor view mode.
35    ///
36    /// Unknown values fall back to [`SingleMonitorView::Hud`].
37    pub fn from_config(value: &str) -> Self {
38        if value.eq_ignore_ascii_case("split") { Self::Split } else { Self::Hud }
39    }
40}
41
42/// CLI-level display hints (from `--single`, `--screen-share`, etc.).
43#[derive(Debug, Clone, Copy)]
44pub struct DisplayHints {
45    /// `--single` was passed.
46    pub force_single: bool,
47    /// `--screen-share` was passed.
48    pub force_screen_share: bool,
49}
50
51fn app_icon() -> Option<Arc<egui::IconData>> {
52    static ICON: OnceLock<Option<Arc<egui::IconData>>> = OnceLock::new();
53
54    ICON.get_or_init(|| {
55        match eframe::icon_data::from_png_bytes(include_bytes!("../assets/dais.png")) {
56            Ok(icon) => Some(Arc::new(icon)),
57            Err(err) => {
58                tracing::warn!("Failed to load app icon from bundled assets/dais.png: {err}");
59                None
60            }
61        }
62    })
63    .clone()
64}
65
66/// Attach the bundled Dais icon to an egui viewport builder when it can be decoded.
67pub fn with_app_icon(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
68    if let Some(icon) = app_icon() { builder.with_icon(icon) } else { builder }
69}
70
71/// Result of display mode determination, including any warnings.
72pub struct DisplayModeResult {
73    /// Selected display mode.
74    pub mode: DisplayMode,
75    /// Human-readable warnings for recoverable monitor/config issues.
76    pub warnings: Vec<String>,
77    /// Recovery data when the configured audience monitor was unavailable.
78    pub audience_reassignment: Option<AudienceReassignmentPrompt>,
79}
80
81/// Interactive recovery data when the configured audience monitor is unavailable.
82#[derive(Debug, Clone)]
83pub struct AudienceReassignmentPrompt {
84    /// The configured monitor selector that could not be resolved.
85    pub missing_selector: String,
86    /// Non-primary monitor chosen as a temporary fallback, if any.
87    pub attempted_fallback: Option<MonitorInfo>,
88    /// Available monitors that the user could choose from.
89    pub available_monitors: Vec<MonitorInfo>,
90}
91
92/// Determine the initial display mode from CLI hints, config, and detected monitors.
93pub fn determine_display_mode(
94    hints: DisplayHints,
95    config: &Config,
96    monitor_mgr: &dyn MonitorManager,
97) -> DisplayModeResult {
98    let mut warnings = Vec::new();
99
100    // CLI flags take absolute precedence
101    if hints.force_single {
102        tracing::info!("Single mode requested via --single flag");
103        return DisplayModeResult {
104            mode: DisplayMode::Single,
105            warnings,
106            audience_reassignment: None,
107        };
108    }
109    if hints.force_screen_share {
110        tracing::info!("Screen-share mode requested via --screen-share flag");
111        return DisplayModeResult {
112            mode: DisplayMode::ScreenShare,
113            warnings,
114            audience_reassignment: None,
115        };
116    }
117
118    // Config-based mode preference
119    let config_mode = config.display.mode.to_lowercase();
120
121    let monitors = monitor_mgr.available_monitors();
122    log_monitor_topology(&monitors);
123
124    let mut audience_reassignment = None;
125    let mode = match config_mode.as_str() {
126        "single" => {
127            tracing::info!("Single mode set in config");
128            DisplayMode::Single
129        }
130        "screen-share" | "screenshare" | "screen_share" => {
131            tracing::info!("Screen-share mode set in config");
132            DisplayMode::ScreenShare
133        }
134        // "dual" or "auto" (default) — try to find a secondary monitor
135        _ => {
136            let (mode, prompt) = resolve_dual_mode(config, &monitors, monitor_mgr, &mut warnings);
137            audience_reassignment = prompt;
138            mode
139        }
140    };
141
142    DisplayModeResult { mode, warnings, audience_reassignment }
143}
144
145/// Attempt to resolve dual mode, falling back gracefully.
146fn resolve_dual_mode(
147    config: &Config,
148    monitors: &[MonitorInfo],
149    monitor_mgr: &dyn MonitorManager,
150    warnings: &mut Vec<String>,
151) -> (DisplayMode, Option<AudienceReassignmentPrompt>) {
152    let audience_name = &config.display.audience_monitor;
153    if audience_name != "auto" && !audience_name.is_empty() {
154        if let Some(mon) = monitor_mgr.find_by_selector(audience_name) {
155            tracing::info!(
156                "Using configured audience monitor '{}' -> {} '{}'",
157                audience_name,
158                mon.id,
159                mon.name
160            );
161            return (DisplayMode::Dual { audience_monitor: mon }, None);
162        }
163        let available = monitors.iter().map(|m| m.name.as_str()).collect::<Vec<_>>().join(", ");
164        let msg = format!(
165            "Configured audience monitor '{audience_name}' not found. Available: {available}",
166        );
167        tracing::warn!("{msg}");
168        warnings.push(msg);
169
170        let attempted_fallback = monitor_mgr.secondary_monitor();
171        let available_monitors = monitors.to_vec();
172        let prompt = Some(AudienceReassignmentPrompt {
173            missing_selector: audience_name.clone(),
174            attempted_fallback: attempted_fallback.clone(),
175            available_monitors,
176        });
177
178        if let Some(secondary) = attempted_fallback {
179            tracing::info!(
180                "Dual mode fallback: audience on '{}' ({}x{} @ {:?})",
181                secondary.name,
182                secondary.size.0,
183                secondary.size.1,
184                secondary.position
185            );
186            return (DisplayMode::Dual { audience_monitor: secondary }, prompt);
187        }
188
189        let msg = "Single monitor detected — expected dual. Using single mode.".to_string();
190        tracing::info!("{msg}");
191        warnings.push(msg);
192        return (DisplayMode::Single, prompt);
193    }
194
195    if let Some(secondary) = monitor_mgr.secondary_monitor() {
196        tracing::info!(
197            "Dual mode: audience on '{}' ({}x{} @ {:?})",
198            secondary.name,
199            secondary.size.0,
200            secondary.size.1,
201            secondary.position
202        );
203        return (DisplayMode::Dual { audience_monitor: secondary }, None);
204    }
205
206    let msg = "Single monitor detected — expected dual. Using single mode.".to_string();
207    tracing::info!("{msg}");
208    warnings.push(msg);
209    (DisplayMode::Single, None)
210}
211
212/// Build the audience viewport builder for the given display mode.
213#[allow(clippy::cast_precision_loss)]
214pub fn audience_viewport_builder(mode: &DisplayMode) -> egui::ViewportBuilder {
215    match mode {
216        DisplayMode::Dual { audience_monitor } => {
217            tracing::debug!(
218                "Audience viewport: fullscreen on '{}' at ({}, {})",
219                audience_monitor.name,
220                audience_monitor.position.0,
221                audience_monitor.position.1,
222            );
223            with_app_icon(egui::ViewportBuilder::default())
224                .with_title("Dais — Audience")
225                .with_fullscreen(true)
226                .with_position(egui::pos2(
227                    audience_monitor.position.0 as f32,
228                    audience_monitor.position.1 as f32,
229                ))
230        }
231        DisplayMode::Single => {
232            // Single mode doesn't spawn an audience viewport — this is a fallback
233            with_app_icon(egui::ViewportBuilder::default())
234                .with_title("Dais — Audience")
235                .with_inner_size(egui::vec2(1280.0, 720.0))
236        }
237        DisplayMode::ScreenShare => with_app_icon(egui::ViewportBuilder::default())
238            .with_title("Dais — Audience")
239            .with_inner_size(egui::vec2(1280.0, 720.0)),
240    }
241}
242
243/// Build the presenter viewport builder.
244///
245/// The presenter window opens centered horizontally on the configured presenter
246/// monitor and positioned in the upper portion of that monitor's usable work
247/// area so it avoids overlapping OS taskbars/docks. If no explicit presenter
248/// monitor is configured, the OS primary monitor is used. If monitor data is
249/// unavailable, we fall back to a normal titled window.
250#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
251pub fn presenter_viewport_builder(
252    config: &Config,
253    monitor_mgr: &dyn MonitorManager,
254    window_size: egui::Vec2,
255) -> egui::ViewportBuilder {
256    let presenter_selector = config.display.presenter_monitor.trim();
257    let monitor = if presenter_selector.is_empty() || presenter_selector == "auto" {
258        monitor_mgr.primary_monitor()
259    } else {
260        monitor_mgr.find_by_selector(presenter_selector).or_else(|| monitor_mgr.primary_monitor())
261    };
262
263    let builder = with_app_icon(egui::ViewportBuilder::default())
264        .with_title("Dais — Presenter Console")
265        .with_resizable(true)
266        .with_maximized(true);
267
268    let Some(monitor) = monitor else {
269        return builder;
270    };
271
272    if monitor.size.0 == 0 || monitor.size.1 == 0 {
273        return builder;
274    }
275
276    let (_logical_work_x, _logical_work_y, logical_work_w, logical_work_h) =
277        monitor.logical_work_area();
278    let (logical_monitor_w, logical_monitor_h) = monitor.logical_size();
279    let usable_w = if monitor.work_area.2 > 0 { logical_work_w } else { logical_monitor_w };
280    let usable_h = if monitor.work_area.3 > 0 { logical_work_h } else { logical_monitor_h };
281
282    let max_w = (usable_w as f32 - 20.0).max(640.0);
283    let max_h = (usable_h as f32 - 60.0).max(480.0);
284    let target_w = window_size.x.min(max_w);
285    let target_h = window_size.y.min(max_h);
286
287    let work_x = if monitor.work_area.2 > 0 {
288        monitor.work_area.0 as f32 / monitor.scale_factor as f32
289    } else {
290        monitor.position.0 as f32 / monitor.scale_factor as f32
291    };
292    let work_y = if monitor.work_area.3 > 0 {
293        monitor.work_area.1 as f32 / monitor.scale_factor as f32
294    } else {
295        monitor.position.1 as f32 / monitor.scale_factor as f32
296    };
297    let x = work_x + ((usable_w as f32 - target_w) / 2.0).max(0.0);
298    let top_margin: f32 = 24.0;
299    let y = work_y + top_margin.min((usable_h as f32 - target_h).max(0.0));
300
301    builder.with_inner_size(egui::vec2(target_w, target_h)).with_position(egui::pos2(x, y))
302}
303
304/// Determine the audience render size from the selected display mode.
305///
306/// In dual-monitor mode we use the detected audience monitor's physical pixel
307/// size when available. If the platform backend cannot provide a usable size,
308/// we fall back to the fixed render size.
309pub fn audience_render_size(mode: &DisplayMode) -> RenderSize {
310    match mode {
311        DisplayMode::Dual { audience_monitor }
312            if audience_monitor.size.0 > 0 && audience_monitor.size.1 > 0 =>
313        {
314            RenderSize { width: audience_monitor.size.0, height: audience_monitor.size.1 }
315        }
316        _ => FALLBACK_RENDER_SIZE,
317    }
318}
319
320/// Log detected monitor information.
321fn log_monitor_topology(monitors: &[MonitorInfo]) {
322    tracing::info!("Detected {} monitor(s):", monitors.len());
323    for m in monitors {
324        tracing::info!(
325            "  {} '{}' — {}x{} @ ({},{}) scale={:.2} {}",
326            m.id,
327            m.name,
328            m.size.0,
329            m.size.1,
330            m.position.0,
331            m.position.1,
332            m.scale_factor,
333            if m.is_primary { "[primary]" } else { "" },
334        );
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    struct MockMonitorManager {
343        monitors: Vec<MonitorInfo>,
344    }
345
346    impl MonitorManager for MockMonitorManager {
347        fn available_monitors(&self) -> Vec<MonitorInfo> {
348            self.monitors.clone()
349        }
350    }
351
352    fn single_monitor() -> MockMonitorManager {
353        MockMonitorManager {
354            monitors: vec![MonitorInfo {
355                id: "m1".into(),
356                name: "Primary".into(),
357                position: (0, 0),
358                size: (1920, 1080),
359                work_area: (0, 0, 1920, 1040),
360                scale_factor: 1.0,
361                is_primary: true,
362            }],
363        }
364    }
365
366    fn dual_monitors() -> MockMonitorManager {
367        MockMonitorManager {
368            monitors: vec![
369                MonitorInfo {
370                    id: "m1".into(),
371                    name: "Primary".into(),
372                    position: (0, 0),
373                    size: (1920, 1080),
374                    work_area: (0, 0, 1920, 1040),
375                    scale_factor: 1.0,
376                    is_primary: true,
377                },
378                MonitorInfo {
379                    id: "m2".into(),
380                    name: "DELL U2718Q".into(),
381                    position: (1920, 0),
382                    size: (3840, 2160),
383                    work_area: (1920, 0, 3840, 2120),
384                    scale_factor: 2.0,
385                    is_primary: false,
386                },
387            ],
388        }
389    }
390
391    #[test]
392    fn cli_single_overrides_everything() {
393        let hints = DisplayHints { force_single: true, force_screen_share: false };
394        let config = Config::default();
395        let mgr = dual_monitors();
396        let result = determine_display_mode(hints, &config, &mgr);
397        assert!(matches!(result.mode, DisplayMode::Single));
398        assert!(result.audience_reassignment.is_none());
399    }
400
401    #[test]
402    fn cli_screen_share_overrides_everything() {
403        let hints = DisplayHints { force_single: false, force_screen_share: true };
404        let config = Config::default();
405        let mgr = dual_monitors();
406        let result = determine_display_mode(hints, &config, &mgr);
407        assert!(matches!(result.mode, DisplayMode::ScreenShare));
408        assert!(result.audience_reassignment.is_none());
409    }
410
411    #[test]
412    fn auto_dual_with_two_monitors() {
413        let hints = DisplayHints { force_single: false, force_screen_share: false };
414        let config = Config::default();
415        let mgr = dual_monitors();
416        let result = determine_display_mode(hints, &config, &mgr);
417        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
418        assert!(result.audience_reassignment.is_none());
419        if let DisplayMode::Dual { audience_monitor } = result.mode {
420            assert_eq!(audience_monitor.name, "DELL U2718Q");
421        }
422    }
423
424    #[test]
425    fn auto_falls_back_to_single_with_one_monitor() {
426        let hints = DisplayHints { force_single: false, force_screen_share: false };
427        let config = Config::default();
428        let mgr = single_monitor();
429        let result = determine_display_mode(hints, &config, &mgr);
430        assert!(matches!(result.mode, DisplayMode::Single));
431        assert!(!result.warnings.is_empty());
432        assert!(result.audience_reassignment.is_none());
433    }
434
435    #[test]
436    fn configured_monitor_name_matches() {
437        let hints = DisplayHints { force_single: false, force_screen_share: false };
438        let mut config = Config::default();
439        config.display.audience_monitor = "DELL U2718Q".to_string();
440        let mgr = dual_monitors();
441        let result = determine_display_mode(hints, &config, &mgr);
442        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
443        assert!(result.audience_reassignment.is_none());
444    }
445
446    #[test]
447    fn configured_monitor_numeric_selector_matches() {
448        let hints = DisplayHints { force_single: false, force_screen_share: false };
449        let mut config = Config::default();
450        config.display.audience_monitor = "2".to_string();
451        let mgr = dual_monitors();
452        let result = determine_display_mode(hints, &config, &mgr);
453        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
454        assert!(result.audience_reassignment.is_none());
455        if let DisplayMode::Dual { audience_monitor } = result.mode {
456            assert_eq!(audience_monitor.name, "DELL U2718Q");
457        }
458    }
459
460    #[test]
461    fn configured_monitor_name_mismatch_falls_back() {
462        let hints = DisplayHints { force_single: false, force_screen_share: false };
463        let mut config = Config::default();
464        config.display.audience_monitor = "NONEXISTENT".to_string();
465        let mgr = dual_monitors();
466        let result = determine_display_mode(hints, &config, &mgr);
467        // Should still find the secondary via auto-detection
468        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
469        assert!(!result.warnings.is_empty()); // warns about mismatch
470        let prompt = result.audience_reassignment.expect("missing reassignment prompt");
471        assert_eq!(prompt.missing_selector, "NONEXISTENT");
472        assert!(prompt.attempted_fallback.is_some());
473        assert_eq!(prompt.available_monitors.len(), 2);
474    }
475
476    #[test]
477    fn configured_monitor_mismatch_on_one_monitor_can_reassign_to_primary() {
478        let hints = DisplayHints { force_single: false, force_screen_share: false };
479        let mut config = Config::default();
480        config.display.audience_monitor = "NONEXISTENT".to_string();
481        let mgr = single_monitor();
482        let result = determine_display_mode(hints, &config, &mgr);
483
484        assert!(matches!(result.mode, DisplayMode::Single));
485        let prompt = result.audience_reassignment.expect("missing reassignment prompt");
486        assert!(prompt.attempted_fallback.is_none());
487        assert_eq!(prompt.available_monitors.len(), 1);
488        assert!(prompt.available_monitors[0].is_primary);
489    }
490
491    #[test]
492    fn config_screen_share_mode() {
493        let hints = DisplayHints { force_single: false, force_screen_share: false };
494        let mut config = Config::default();
495        config.display.mode = "screen-share".to_string();
496        let mgr = dual_monitors();
497        let result = determine_display_mode(hints, &config, &mgr);
498        assert!(matches!(result.mode, DisplayMode::ScreenShare));
499        assert!(result.audience_reassignment.is_none());
500    }
501
502    #[test]
503    fn audience_render_size_uses_monitor_size_when_available() {
504        let mgr = dual_monitors();
505        let mode = DisplayMode::Dual { audience_monitor: mgr.monitors[1].clone() };
506        let size = audience_render_size(&mode);
507        assert_eq!(size.width, 3840);
508        assert_eq!(size.height, 2160);
509    }
510
511    #[test]
512    fn audience_render_size_falls_back_when_unavailable() {
513        let mode = DisplayMode::ScreenShare;
514        let size = audience_render_size(&mode);
515        assert_eq!(size.width, FALLBACK_RENDER_SIZE.width);
516        assert_eq!(size.height, FALLBACK_RENDER_SIZE.height);
517    }
518
519    #[test]
520    fn presenter_viewport_uses_primary_monitor_by_default() {
521        let config = Config::default();
522        let mgr = dual_monitors();
523        let builder = presenter_viewport_builder(&config, &mgr, egui::vec2(1400.0, 900.0));
524        let debug = format!("{builder:?}");
525        assert!(debug.contains("Presenter Console"));
526    }
527}