Skip to main content

dais_ui/
app.rs

1//! Top-level eframe application and window lifecycle.
2//!
3//! Manages the presentation engine, document source, page cache, and both
4//! presenter and audience windows.  Rendering is offloaded to a background
5//! pipeline so the UI thread never blocks on hayro.
6
7use 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
26/// The main Dais application, implementing `eframe::App`.
27pub 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    /// Create a new Dais application.
46    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        // Tick the engine — processes commands, updates timer, broadcasts state
91        let should_quit = self.engine.tick();
92        if should_quit {
93            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
94            return;
95        }
96
97        // Collect completed background renders into the cache
98        self.pipeline.poll_results(&mut self.cache);
99
100        // Read state snapshot for this frame
101        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        // Submit render requests for pages we need
110        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        // Audience page (may differ if frozen)
120        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        // When overview is visible, request renders for all logical slide thumbnails
124        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        // Request periodic repaints while timers are active or renders are pending.
133        if state.timer.running {
134            ctx.request_repaint_after(std::time::Duration::from_millis(100));
135        } else {
136            // The per-slide timer updates every second, while the render pipeline
137            // still benefits from a modest polling cadence.
138            ctx.request_repaint_after(std::time::Duration::from_millis(250));
139        }
140
141        // In Single mode, F5 (presentation_mode) flips between the two views.
142        // The configured single_monitor_view is the default; F5 shows the other.
143        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        // Read runtime screen-share toggle
174        let is_runtime_screen_share = state.screen_share_mode;
175
176        // Render the presenter console in the main viewport
177        self.presenter.show(ctx, &state, &mut self.cache, &self.sender);
178
179        // Choose audience viewport builder
180        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    // Render a denser source image while zoom is active so the cropped audience
320    // view has more real pixels to work with. We cap the output size to keep
321    // render cost bounded on extreme zoom factors.
322    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}