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 { 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
92pub 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 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 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 _ => {
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
145fn 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#[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 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#[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
304pub 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
320fn 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 assert!(matches!(result.mode, DisplayMode::Dual { .. }));
469 assert!(!result.warnings.is_empty()); 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}