1use 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#[derive(Debug, Clone)]
15pub enum DisplayMode {
16 Dual { presenter_monitor: MonitorInfo, audience_monitor: MonitorInfo },
18 Single,
20 ScreenShare,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SingleMonitorView {
27 Hud,
29 Split,
31}
32
33impl SingleMonitorView {
34 pub fn from_config(value: &str) -> Self {
38 if value.eq_ignore_ascii_case("split") { Self::Split } else { Self::Hud }
39 }
40}
41
42#[derive(Debug, Clone, Copy)]
44pub struct DisplayHints {
45 pub force_single: bool,
47 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
66pub fn with_app_icon(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
68 if let Some(icon) = app_icon() { builder.with_icon(icon) } else { builder }
69}
70
71pub struct DisplayModeResult {
73 pub mode: DisplayMode,
75 pub warnings: Vec<String>,
77 pub audience_reassignment: Option<AudienceReassignmentPrompt>,
79}
80
81#[derive(Debug, Clone)]
83pub struct AudienceReassignmentPrompt {
84 pub missing_selector: String,
86 pub attempted_fallback: Option<MonitorInfo>,
88 pub available_monitors: Vec<MonitorInfo>,
90}
91
92#[derive(Debug, Clone, Copy)]
94pub struct PresenterViewportPlacement {
95 pub position: egui::Pos2,
97 pub inner_size: egui::Vec2,
99}
100
101pub 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 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 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 _ => {
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
154fn 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
253pub 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#[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 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#[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#[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#[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
385pub 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
401fn 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 assert!(matches!(result.mode, DisplayMode::Dual { .. }));
550 assert!(!result.warnings.is_empty()); 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}