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 console on one monitor, audience fullscreen on another.
17    Dual { presenter_monitor: MonitorInfo, 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/// Runtime presenter viewport placement for a specific monitor.
93#[derive(Debug, Clone, Copy)]
94pub struct PresenterViewportPlacement {
95    /// Outer window position.
96    pub position: egui::Pos2,
97    /// Inner window size.
98    pub inner_size: egui::Vec2,
99}
100
101/// Determine the initial display mode from CLI hints, config, and detected monitors.
102pub fn determine_display_mode(
103    hints: DisplayHints,
104    config: &Config,
105    monitor_mgr: &dyn MonitorManager,
106) -> DisplayModeResult {
107    let mut warnings = Vec::new();
108
109    // CLI flags take absolute precedence
110    if hints.force_single {
111        tracing::info!("Single mode requested via --single flag");
112        return DisplayModeResult {
113            mode: DisplayMode::Single,
114            warnings,
115            audience_reassignment: None,
116        };
117    }
118    if hints.force_screen_share {
119        tracing::info!("Screen-share mode requested via --screen-share flag");
120        return DisplayModeResult {
121            mode: DisplayMode::ScreenShare,
122            warnings,
123            audience_reassignment: None,
124        };
125    }
126
127    // Config-based mode preference
128    let config_mode = config.display.mode.to_lowercase();
129
130    let monitors = monitor_mgr.available_monitors();
131    log_monitor_topology(&monitors);
132
133    let mut audience_reassignment = None;
134    let mode = match config_mode.as_str() {
135        "single" => {
136            tracing::info!("Single mode set in config");
137            DisplayMode::Single
138        }
139        "screen-share" | "screenshare" | "screen_share" => {
140            tracing::info!("Screen-share mode set in config");
141            DisplayMode::ScreenShare
142        }
143        // "dual" or "auto" (default) — try to find a secondary monitor
144        _ => {
145            let (mode, prompt) = resolve_dual_mode(config, &monitors, monitor_mgr, &mut warnings);
146            audience_reassignment = prompt;
147            mode
148        }
149    };
150
151    DisplayModeResult { mode, warnings, audience_reassignment }
152}
153
154/// Attempt to resolve dual mode, falling back gracefully.
155fn resolve_dual_mode(
156    config: &Config,
157    monitors: &[MonitorInfo],
158    monitor_mgr: &dyn MonitorManager,
159    warnings: &mut Vec<String>,
160) -> (DisplayMode, Option<AudienceReassignmentPrompt>) {
161    let audience_name = &config.display.audience_monitor;
162    if audience_name != "auto" && !audience_name.is_empty() {
163        if let Some(mon) = monitor_mgr.find_by_selector(audience_name) {
164            let presenter = resolve_presenter_monitor(config, monitors, monitor_mgr, &mon);
165            tracing::info!(
166                "Using configured audience monitor '{}' -> {} '{}'",
167                audience_name,
168                mon.id,
169                mon.name
170            );
171            return (
172                DisplayMode::Dual { presenter_monitor: presenter, audience_monitor: mon },
173                None,
174            );
175        }
176        let available = monitors.iter().map(|m| m.name.as_str()).collect::<Vec<_>>().join(", ");
177        let msg = format!(
178            "Configured audience monitor '{audience_name}' not found. Available: {available}",
179        );
180        tracing::warn!("{msg}");
181        warnings.push(msg);
182
183        let attempted_fallback = monitor_mgr.secondary_monitor();
184        let available_monitors = monitors.to_vec();
185        let prompt = Some(AudienceReassignmentPrompt {
186            missing_selector: audience_name.clone(),
187            attempted_fallback: attempted_fallback.clone(),
188            available_monitors,
189        });
190
191        if let Some(secondary) = attempted_fallback {
192            let presenter = resolve_presenter_monitor(config, monitors, monitor_mgr, &secondary);
193            tracing::info!(
194                "Dual mode fallback: audience on '{}' ({}x{} @ {:?})",
195                secondary.name,
196                secondary.size.0,
197                secondary.size.1,
198                secondary.position
199            );
200            return (
201                DisplayMode::Dual { presenter_monitor: presenter, audience_monitor: secondary },
202                prompt,
203            );
204        }
205
206        let msg = "Single monitor detected — expected dual. Using single mode.".to_string();
207        tracing::info!("{msg}");
208        warnings.push(msg);
209        return (DisplayMode::Single, prompt);
210    }
211
212    if let Some(secondary) = monitor_mgr.secondary_monitor() {
213        let presenter = resolve_presenter_monitor(config, monitors, monitor_mgr, &secondary);
214        tracing::info!(
215            "Dual mode: audience on '{}' ({}x{} @ {:?})",
216            secondary.name,
217            secondary.size.0,
218            secondary.size.1,
219            secondary.position
220        );
221        return (
222            DisplayMode::Dual { presenter_monitor: presenter, audience_monitor: secondary },
223            None,
224        );
225    }
226
227    let msg = "Single monitor detected — expected dual. Using single mode.".to_string();
228    tracing::info!("{msg}");
229    warnings.push(msg);
230    (DisplayMode::Single, None)
231}
232
233fn resolve_presenter_monitor(
234    config: &Config,
235    monitors: &[MonitorInfo],
236    monitor_mgr: &dyn MonitorManager,
237    audience_monitor: &MonitorInfo,
238) -> MonitorInfo {
239    let presenter_selector = config.display.presenter_monitor.trim();
240    let configured = if presenter_selector.is_empty() || presenter_selector == "auto" {
241        None
242    } else {
243        monitor_mgr.find_by_selector(presenter_selector)
244    };
245
246    configured
247        .or_else(|| monitor_mgr.primary_monitor())
248        .filter(|monitor| monitor.id != audience_monitor.id)
249        .or_else(|| monitors.iter().find(|monitor| monitor.id != audience_monitor.id).cloned())
250        .unwrap_or_else(|| audience_monitor.clone())
251}
252
253/// Return the active dual-mode monitor assignment after applying a runtime swap.
254pub fn effective_display_mode(mode: &DisplayMode, displays_swapped: bool) -> DisplayMode {
255    match mode {
256        DisplayMode::Dual { presenter_monitor, audience_monitor } if displays_swapped => {
257            DisplayMode::Dual {
258                presenter_monitor: audience_monitor.clone(),
259                audience_monitor: presenter_monitor.clone(),
260            }
261        }
262        _ => mode.clone(),
263    }
264}
265
266/// Build the audience viewport builder for the given display mode.
267#[allow(clippy::cast_precision_loss)]
268pub fn audience_viewport_builder(mode: &DisplayMode) -> egui::ViewportBuilder {
269    match mode {
270        DisplayMode::Dual { audience_monitor, .. } => {
271            tracing::debug!(
272                "Audience viewport: fullscreen on '{}' at ({}, {})",
273                audience_monitor.name,
274                audience_monitor.position.0,
275                audience_monitor.position.1,
276            );
277            with_app_icon(egui::ViewportBuilder::default())
278                .with_title("Dais — Audience")
279                .with_fullscreen(true)
280                .with_position(egui::pos2(
281                    audience_monitor.position.0 as f32,
282                    audience_monitor.position.1 as f32,
283                ))
284        }
285        DisplayMode::Single => {
286            // Single mode doesn't spawn an audience viewport — this is a fallback
287            with_app_icon(egui::ViewportBuilder::default())
288                .with_title("Dais — Audience")
289                .with_inner_size(egui::vec2(1280.0, 720.0))
290        }
291        DisplayMode::ScreenShare => with_app_icon(egui::ViewportBuilder::default())
292            .with_title("Dais — Audience")
293            .with_inner_size(egui::vec2(1280.0, 720.0)),
294    }
295}
296
297/// Build the presenter viewport builder.
298///
299/// The presenter window opens centered horizontally on the configured presenter
300/// monitor and positioned in the upper portion of that monitor's usable work
301/// area so it avoids overlapping OS taskbars/docks. If no explicit presenter
302/// monitor is configured, the OS primary monitor is used. If monitor data is
303/// unavailable, we fall back to a normal titled window.
304#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
305pub fn presenter_viewport_builder(
306    config: &Config,
307    monitor_mgr: &dyn MonitorManager,
308    window_size: egui::Vec2,
309) -> egui::ViewportBuilder {
310    let presenter_selector = config.display.presenter_monitor.trim();
311    let monitor = if presenter_selector.is_empty() || presenter_selector == "auto" {
312        monitor_mgr.primary_monitor()
313    } else {
314        monitor_mgr.find_by_selector(presenter_selector).or_else(|| monitor_mgr.primary_monitor())
315    };
316
317    match monitor {
318        Some(monitor) => presenter_viewport_builder_for_monitor(&monitor, window_size),
319        None => presenter_viewport_builder_without_monitor(),
320    }
321}
322
323fn presenter_viewport_builder_without_monitor() -> egui::ViewportBuilder {
324    with_app_icon(egui::ViewportBuilder::default())
325        .with_title("Dais — Presenter Console")
326        .with_resizable(true)
327        .with_maximized(true)
328}
329
330/// Build the presenter viewport on a specific monitor.
331#[allow(clippy::cast_precision_loss)]
332pub fn presenter_viewport_builder_for_monitor(
333    monitor: &MonitorInfo,
334    window_size: egui::Vec2,
335) -> egui::ViewportBuilder {
336    let builder = presenter_viewport_builder_without_monitor();
337
338    let Some(placement) = presenter_viewport_placement(monitor, window_size) else {
339        return builder;
340    };
341
342    builder.with_inner_size(placement.inner_size).with_position(placement.position)
343}
344
345/// Calculate presenter viewport placement for a specific monitor.
346#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
347pub fn presenter_viewport_placement(
348    monitor: &MonitorInfo,
349    window_size: egui::Vec2,
350) -> Option<PresenterViewportPlacement> {
351    if monitor.size.0 == 0 || monitor.size.1 == 0 {
352        return None;
353    }
354    let (_logical_work_x, _logical_work_y, logical_work_w, logical_work_h) =
355        monitor.logical_work_area();
356    let (logical_monitor_w, logical_monitor_h) = monitor.logical_size();
357    let usable_w = if monitor.work_area.2 > 0 { logical_work_w } else { logical_monitor_w };
358    let usable_h = if monitor.work_area.3 > 0 { logical_work_h } else { logical_monitor_h };
359
360    let max_w = (usable_w as f32 - 20.0).max(640.0);
361    let max_h = (usable_h as f32 - 60.0).max(480.0);
362    let target_w = window_size.x.min(max_w);
363    let target_h = window_size.y.min(max_h);
364
365    let work_x = if monitor.work_area.2 > 0 {
366        monitor.work_area.0 as f32 / monitor.scale_factor as f32
367    } else {
368        monitor.position.0 as f32 / monitor.scale_factor as f32
369    };
370    let work_y = if monitor.work_area.3 > 0 {
371        monitor.work_area.1 as f32 / monitor.scale_factor as f32
372    } else {
373        monitor.position.1 as f32 / monitor.scale_factor as f32
374    };
375    let x = work_x + ((usable_w as f32 - target_w) / 2.0).max(0.0);
376    let top_margin: f32 = 24.0;
377    let y = work_y + top_margin.min((usable_h as f32 - target_h).max(0.0));
378
379    Some(PresenterViewportPlacement {
380        position: egui::pos2(x, y),
381        inner_size: egui::vec2(target_w, target_h),
382    })
383}
384
385/// Determine the audience render size from the selected display mode.
386///
387/// In dual-monitor mode we use the detected audience monitor's physical pixel
388/// size when available. If the platform backend cannot provide a usable size,
389/// we fall back to the fixed render size.
390pub fn audience_render_size(mode: &DisplayMode) -> RenderSize {
391    match mode {
392        DisplayMode::Dual { audience_monitor, .. }
393            if audience_monitor.size.0 > 0 && audience_monitor.size.1 > 0 =>
394        {
395            RenderSize { width: audience_monitor.size.0, height: audience_monitor.size.1 }
396        }
397        _ => FALLBACK_RENDER_SIZE,
398    }
399}
400
401/// Log detected monitor information.
402fn log_monitor_topology(monitors: &[MonitorInfo]) {
403    tracing::info!("Detected {} monitor(s):", monitors.len());
404    for m in monitors {
405        tracing::info!(
406            "  {} '{}' — {}x{} @ ({},{}) scale={:.2} {}",
407            m.id,
408            m.name,
409            m.size.0,
410            m.size.1,
411            m.position.0,
412            m.position.1,
413            m.scale_factor,
414            if m.is_primary { "[primary]" } else { "" },
415        );
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    struct MockMonitorManager {
424        monitors: Vec<MonitorInfo>,
425    }
426
427    impl MonitorManager for MockMonitorManager {
428        fn available_monitors(&self) -> Vec<MonitorInfo> {
429            self.monitors.clone()
430        }
431    }
432
433    fn single_monitor() -> MockMonitorManager {
434        MockMonitorManager {
435            monitors: vec![MonitorInfo {
436                id: "m1".into(),
437                name: "Primary".into(),
438                position: (0, 0),
439                size: (1920, 1080),
440                work_area: (0, 0, 1920, 1040),
441                scale_factor: 1.0,
442                is_primary: true,
443            }],
444        }
445    }
446
447    fn dual_monitors() -> MockMonitorManager {
448        MockMonitorManager {
449            monitors: vec![
450                MonitorInfo {
451                    id: "m1".into(),
452                    name: "Primary".into(),
453                    position: (0, 0),
454                    size: (1920, 1080),
455                    work_area: (0, 0, 1920, 1040),
456                    scale_factor: 1.0,
457                    is_primary: true,
458                },
459                MonitorInfo {
460                    id: "m2".into(),
461                    name: "DELL U2718Q".into(),
462                    position: (1920, 0),
463                    size: (3840, 2160),
464                    work_area: (1920, 0, 3840, 2120),
465                    scale_factor: 2.0,
466                    is_primary: false,
467                },
468            ],
469        }
470    }
471
472    #[test]
473    fn cli_single_overrides_everything() {
474        let hints = DisplayHints { force_single: true, force_screen_share: false };
475        let config = Config::default();
476        let mgr = dual_monitors();
477        let result = determine_display_mode(hints, &config, &mgr);
478        assert!(matches!(result.mode, DisplayMode::Single));
479        assert!(result.audience_reassignment.is_none());
480    }
481
482    #[test]
483    fn cli_screen_share_overrides_everything() {
484        let hints = DisplayHints { force_single: false, force_screen_share: true };
485        let config = Config::default();
486        let mgr = dual_monitors();
487        let result = determine_display_mode(hints, &config, &mgr);
488        assert!(matches!(result.mode, DisplayMode::ScreenShare));
489        assert!(result.audience_reassignment.is_none());
490    }
491
492    #[test]
493    fn auto_dual_with_two_monitors() {
494        let hints = DisplayHints { force_single: false, force_screen_share: false };
495        let config = Config::default();
496        let mgr = dual_monitors();
497        let result = determine_display_mode(hints, &config, &mgr);
498        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
499        assert!(result.audience_reassignment.is_none());
500        if let DisplayMode::Dual { audience_monitor, .. } = result.mode {
501            assert_eq!(audience_monitor.name, "DELL U2718Q");
502        }
503    }
504
505    #[test]
506    fn auto_falls_back_to_single_with_one_monitor() {
507        let hints = DisplayHints { force_single: false, force_screen_share: false };
508        let config = Config::default();
509        let mgr = single_monitor();
510        let result = determine_display_mode(hints, &config, &mgr);
511        assert!(matches!(result.mode, DisplayMode::Single));
512        assert!(!result.warnings.is_empty());
513        assert!(result.audience_reassignment.is_none());
514    }
515
516    #[test]
517    fn configured_monitor_name_matches() {
518        let hints = DisplayHints { force_single: false, force_screen_share: false };
519        let mut config = Config::default();
520        config.display.audience_monitor = "DELL U2718Q".to_string();
521        let mgr = dual_monitors();
522        let result = determine_display_mode(hints, &config, &mgr);
523        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
524        assert!(result.audience_reassignment.is_none());
525    }
526
527    #[test]
528    fn configured_monitor_numeric_selector_matches() {
529        let hints = DisplayHints { force_single: false, force_screen_share: false };
530        let mut config = Config::default();
531        config.display.audience_monitor = "2".to_string();
532        let mgr = dual_monitors();
533        let result = determine_display_mode(hints, &config, &mgr);
534        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
535        assert!(result.audience_reassignment.is_none());
536        if let DisplayMode::Dual { audience_monitor, .. } = result.mode {
537            assert_eq!(audience_monitor.name, "DELL U2718Q");
538        }
539    }
540
541    #[test]
542    fn configured_monitor_name_mismatch_falls_back() {
543        let hints = DisplayHints { force_single: false, force_screen_share: false };
544        let mut config = Config::default();
545        config.display.audience_monitor = "NONEXISTENT".to_string();
546        let mgr = dual_monitors();
547        let result = determine_display_mode(hints, &config, &mgr);
548        // Should still find the secondary via auto-detection
549        assert!(matches!(result.mode, DisplayMode::Dual { .. }));
550        assert!(!result.warnings.is_empty()); // warns about mismatch
551        let prompt = result.audience_reassignment.expect("missing reassignment prompt");
552        assert_eq!(prompt.missing_selector, "NONEXISTENT");
553        assert!(prompt.attempted_fallback.is_some());
554        assert_eq!(prompt.available_monitors.len(), 2);
555    }
556
557    #[test]
558    fn configured_monitor_mismatch_on_one_monitor_can_reassign_to_primary() {
559        let hints = DisplayHints { force_single: false, force_screen_share: false };
560        let mut config = Config::default();
561        config.display.audience_monitor = "NONEXISTENT".to_string();
562        let mgr = single_monitor();
563        let result = determine_display_mode(hints, &config, &mgr);
564
565        assert!(matches!(result.mode, DisplayMode::Single));
566        let prompt = result.audience_reassignment.expect("missing reassignment prompt");
567        assert!(prompt.attempted_fallback.is_none());
568        assert_eq!(prompt.available_monitors.len(), 1);
569        assert!(prompt.available_monitors[0].is_primary);
570    }
571
572    #[test]
573    fn config_screen_share_mode() {
574        let hints = DisplayHints { force_single: false, force_screen_share: false };
575        let mut config = Config::default();
576        config.display.mode = "screen-share".to_string();
577        let mgr = dual_monitors();
578        let result = determine_display_mode(hints, &config, &mgr);
579        assert!(matches!(result.mode, DisplayMode::ScreenShare));
580        assert!(result.audience_reassignment.is_none());
581    }
582
583    #[test]
584    fn audience_render_size_uses_monitor_size_when_available() {
585        let mgr = dual_monitors();
586        let mode = DisplayMode::Dual {
587            presenter_monitor: mgr.monitors[0].clone(),
588            audience_monitor: mgr.monitors[1].clone(),
589        };
590        let size = audience_render_size(&mode);
591        assert_eq!(size.width, 3840);
592        assert_eq!(size.height, 2160);
593    }
594
595    #[test]
596    fn effective_display_mode_swaps_dual_monitors() {
597        let mgr = dual_monitors();
598        let mode = DisplayMode::Dual {
599            presenter_monitor: mgr.monitors[0].clone(),
600            audience_monitor: mgr.monitors[1].clone(),
601        };
602
603        let swapped = effective_display_mode(&mode, true);
604
605        if let DisplayMode::Dual { presenter_monitor, audience_monitor } = swapped {
606            assert_eq!(presenter_monitor.id, "m2");
607            assert_eq!(audience_monitor.id, "m1");
608        } else {
609            panic!("dual mode should remain dual after swapping");
610        }
611    }
612
613    #[test]
614    fn audience_render_size_falls_back_when_unavailable() {
615        let mode = DisplayMode::ScreenShare;
616        let size = audience_render_size(&mode);
617        assert_eq!(size.width, FALLBACK_RENDER_SIZE.width);
618        assert_eq!(size.height, FALLBACK_RENDER_SIZE.height);
619    }
620
621    #[test]
622    fn presenter_viewport_uses_primary_monitor_by_default() {
623        let config = Config::default();
624        let mgr = dual_monitors();
625        let builder = presenter_viewport_builder(&config, &mgr, egui::vec2(1400.0, 900.0));
626        let debug = format!("{builder:?}");
627        assert!(debug.contains("Presenter Console"));
628    }
629}