1use std::sync::{Arc, RwLock};
8
9use dais_core::bus::CommandSender;
10use dais_core::config::Config;
11use dais_core::keybindings::KeybindingMap;
12use dais_core::state::PresentationState;
13use dais_document::cache::PageCache;
14use dais_document::page::RenderSize;
15use dais_document::render_pipeline::{FALLBACK_RENDER_SIZE, RenderPipeline};
16use dais_document::source::DocumentSource;
17use dais_engine::engine::PresentationEngine;
18
19use crate::audience::AudienceWindow;
20use crate::display_mode::{self, AudienceReassignmentPrompt, DisplayMode, SingleMonitorView};
21use crate::input::InputHandler;
22use crate::presenter::PresenterConsole;
23use crate::presenter::hud::HudOverlay;
24use crate::widgets::ToastManager;
25
26pub struct DaisApp {
28 engine: PresentationEngine,
29 shared_state: Arc<RwLock<PresentationState>>,
30 cache: PageCache,
31 pipeline: RenderPipeline,
32 presenter: PresenterConsole,
33 hud: HudOverlay,
34 audience: AudienceWindow,
35 sender: CommandSender,
36 display_mode: DisplayMode,
37 single_monitor_view: SingleMonitorView,
38 audience_reassignment: Option<AudienceReassignmentPrompt>,
39 toast_manager: ToastManager,
40}
41
42const MAX_ZOOM_RENDER_DIMENSION: u32 = 4320;
43
44impl DaisApp {
45 pub fn new(
47 engine: PresentationEngine,
48 shared_state: Arc<RwLock<PresentationState>>,
49 doc: Arc<dyn DocumentSource>,
50 sender: CommandSender,
51 config: &Config,
52 display_mode: DisplayMode,
53 ) -> Self {
54 let keybindings = KeybindingMap::from_full_config(config);
55 let input = InputHandler::new(sender.clone(), keybindings);
56 let presenter = PresenterConsole::new(input);
57 let audience = AudienceWindow::new();
58 let cache = PageCache::new(64);
59 let pipeline = RenderPipeline::new(doc, 2);
60
61 Self {
62 engine,
63 shared_state,
64 cache,
65 pipeline,
66 presenter,
67 hud: HudOverlay::new(),
68 audience,
69 sender,
70 display_mode,
71 single_monitor_view: SingleMonitorView::from_config(
72 &config.display.single_monitor_view,
73 ),
74 audience_reassignment: None,
75 toast_manager: ToastManager::new(),
76 }
77 }
78
79 pub fn toast_manager_mut(&mut self) -> &mut ToastManager {
80 &mut self.toast_manager
81 }
82
83 pub fn set_audience_reassignment(&mut self, prompt: Option<AudienceReassignmentPrompt>) {
84 self.audience_reassignment = prompt;
85 }
86}
87
88impl eframe::App for DaisApp {
89 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
90 let should_quit = self.engine.tick();
92 if should_quit {
93 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
94 return;
95 }
96
97 self.pipeline.poll_results(&mut self.cache);
99
100 let state = self.shared_state.read().map_or_else(
102 |e| {
103 tracing::error!("Failed to read state: {e}");
104 PresentationState::new(0, Vec::new())
105 },
106 |s| s.clone(),
107 );
108
109 let presenter_size = FALLBACK_RENDER_SIZE;
111 let base_audience_size = display_mode::audience_render_size(&self.display_mode);
112 let audience_size = effective_audience_render_size(&state, base_audience_size);
113 self.pipeline.prefetch_neighborhood(
114 state.current_page,
115 state.total_pages,
116 presenter_size,
117 &mut self.cache,
118 );
119 self.pipeline.ensure_rendered(state.audience_page(), base_audience_size, &mut self.cache);
121 self.pipeline.ensure_rendered(state.audience_page(), audience_size, &mut self.cache);
122
123 if state.overview_visible {
125 for group in &state.slide_groups {
126 if let Some(&first_page) = group.pages.first() {
127 self.pipeline.ensure_rendered(first_page, presenter_size, &mut self.cache);
128 }
129 }
130 }
131
132 if state.timer.running {
134 ctx.request_repaint_after(std::time::Duration::from_millis(100));
135 } else {
136 ctx.request_repaint_after(std::time::Duration::from_millis(250));
139 }
140
141 if matches!(self.display_mode, DisplayMode::Single) {
144 let render_size = effective_audience_render_size(&state, FALLBACK_RENDER_SIZE);
145 let active_view = if state.presentation_mode {
146 match self.single_monitor_view {
147 SingleMonitorView::Hud => SingleMonitorView::Split,
148 SingleMonitorView::Split => SingleMonitorView::Hud,
149 }
150 } else {
151 self.single_monitor_view
152 };
153 match active_view {
154 SingleMonitorView::Hud => {
155 let input = self.presenter.input_mut();
156 self.hud.show(ctx, &state, &mut self.cache, &self.sender, input, render_size);
157 }
158 SingleMonitorView::Split => {
159 self.presenter.show_single_monitor_split(
160 ctx,
161 &state,
162 &mut self.cache,
163 &self.sender,
164 render_size,
165 );
166 }
167 }
168 self.show_audience_reassignment_prompt(ctx);
169 self.toast_manager.show(ctx);
170 return;
171 }
172
173 let is_runtime_screen_share = state.screen_share_mode;
175
176 self.presenter.show(ctx, &state, &mut self.cache, &self.sender);
178
179 let viewport_builder = if is_runtime_screen_share {
181 display_mode::with_app_icon(egui::ViewportBuilder::default())
182 .with_title("Dais — Audience")
183 .with_inner_size(egui::vec2(1280.0, 720.0))
184 .with_fullscreen(false)
185 .with_resizable(true)
186 } else {
187 display_mode::audience_viewport_builder(&self.display_mode)
188 };
189
190 let shared = self.shared_state.clone();
191 let audience = &mut self.audience;
192 let cache = &mut self.cache;
193 let shared_ref = &shared;
194
195 ctx.show_viewport_immediate(
196 egui::ViewportId::from_hash_of("audience"),
197 viewport_builder,
198 |ctx, _class| {
199 audience.show(ctx, shared_ref, cache, audience_size);
200 },
201 );
202
203 self.show_audience_reassignment_prompt(ctx);
204 self.toast_manager.show(ctx);
205 }
206}
207
208impl DaisApp {
209 fn show_audience_reassignment_prompt(&mut self, ctx: &egui::Context) {
210 let Some(prompt) = self.audience_reassignment.clone() else {
211 return;
212 };
213
214 egui::Window::new("Audience Monitor Changed")
215 .collapsible(false)
216 .resizable(false)
217 .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
218 .show(ctx, |ui| {
219 ui.set_min_width(420.0);
220 ui.label(format!(
221 "Configured audience monitor '{}' is not available.",
222 prompt.missing_selector
223 ));
224
225 if let Some(fallback) = &prompt.attempted_fallback {
226 ui.label(format!(
227 "Dais is currently using '{}' as a fallback for this session.",
228 fallback.name
229 ));
230 } else {
231 ui.label("No audience monitor fallback was available, so Dais switched to single-monitor mode.");
232 }
233
234 ui.separator();
235 ui.label("Choose the audience output for this session:");
236
237 let mut dismiss = false;
238 let fallback_id = prompt.attempted_fallback.as_ref().map(|monitor| monitor.id.as_str());
239 let alternative_monitors = prompt
240 .available_monitors
241 .iter()
242 .filter(|monitor| Some(monitor.id.as_str()) != fallback_id)
243 .cloned()
244 .collect::<Vec<_>>();
245
246 if alternative_monitors.is_empty() {
247 ui.label(
248 egui::RichText::new("No alternate audience monitors were detected.")
249 .small()
250 .color(egui::Color32::GRAY),
251 );
252 }
253
254 for monitor in &alternative_monitors {
255 let primary = if monitor.is_primary { ", primary" } else { "" };
256 let label = format!("Use {} ({}{primary})", monitor.name, monitor.id);
257 if ui.button(label).clicked() {
258 self.display_mode =
259 DisplayMode::Dual { audience_monitor: monitor.clone() };
260 self.toast_manager.push(
261 crate::widgets::toast::ToastLevel::Info,
262 format!("Audience moved to '{}'", monitor.name),
263 );
264 dismiss = true;
265 }
266 }
267
268 ui.horizontal(|ui| {
269 if let Some(fallback) = &prompt.attempted_fallback {
270 if ui.button("Keep current fallback").clicked() {
271 self.display_mode =
272 DisplayMode::Dual { audience_monitor: fallback.clone() };
273 self.toast_manager.push(
274 crate::widgets::toast::ToastLevel::Info,
275 format!("Keeping fallback audience monitor '{}'", fallback.name),
276 );
277 dismiss = true;
278 }
279
280 if ui.button("Use single-monitor mode").clicked() {
281 self.display_mode = DisplayMode::Single;
282 self.toast_manager.push(
283 crate::widgets::toast::ToastLevel::Info,
284 "Audience reassigned to single-monitor mode",
285 );
286 dismiss = true;
287 }
288 } else if ui.button("Stay in single-monitor mode").clicked() {
289 self.display_mode = DisplayMode::Single;
290 self.toast_manager.push(
291 crate::widgets::toast::ToastLevel::Info,
292 "Keeping single-monitor mode",
293 );
294 dismiss = true;
295 }
296 });
297
298 ui.label(
299 egui::RichText::new(
300 "This updates the current session only. You can make it permanent in `dais.toml` later.",
301 )
302 .small()
303 .color(egui::Color32::GRAY),
304 );
305
306 if dismiss {
307 self.audience_reassignment = None;
308 }
309 });
310 }
311}
312
313#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]
314fn effective_audience_render_size(state: &PresentationState, base_size: RenderSize) -> RenderSize {
315 let Some(region) = state.zoom_region.as_ref().filter(|_| state.zoom_active) else {
316 return base_size;
317 };
318
319 let multiplier = region.factor.clamp(1.0, 3.0);
323 let width = ((base_size.width as f32) * multiplier).round() as u32;
324 let height = ((base_size.height as f32) * multiplier).round() as u32;
325
326 RenderSize {
327 width: width.clamp(base_size.width, MAX_ZOOM_RENDER_DIMENSION),
328 height: height.clamp(base_size.height, MAX_ZOOM_RENDER_DIMENSION),
329 }
330}