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;
18use dais_remote::RemoteStatusHandle;
19
20use crate::audience::AudienceWindow;
21use crate::display_mode::{self, AudienceReassignmentPrompt, DisplayMode, SingleMonitorView};
22use crate::input::InputHandler;
23use crate::presenter::PresenterConsole;
24use crate::presenter::hud::HudOverlay;
25use crate::widgets::ToastManager;
26
27/// The main Dais application, implementing `eframe::App`.
28pub struct DaisApp {
29    engine: PresentationEngine,
30    shared_state: Arc<RwLock<PresentationState>>,
31    cache: PageCache,
32    pipeline: RenderPipeline,
33    presenter: PresenterConsole,
34    hud: HudOverlay,
35    audience: AudienceWindow,
36    sender: CommandSender,
37    display_mode: DisplayMode,
38    single_monitor_view: SingleMonitorView,
39    audience_reassignment: Option<AudienceReassignmentPrompt>,
40    toast_manager: ToastManager,
41    remote: Option<RemoteUiInfo>,
42    presenter_window_size: egui::Vec2,
43    applied_presenter_monitor_id: Option<String>,
44}
45
46const MAX_ZOOM_RENDER_DIMENSION: u32 = 4320;
47
48#[derive(Clone)]
49pub struct RemoteUiInfo {
50    pub urls: Vec<String>,
51    pub token: String,
52    pub requires_token: bool,
53    pub status: RemoteStatusHandle,
54}
55
56impl DaisApp {
57    /// Create a new Dais application.
58    pub fn new(
59        engine: PresentationEngine,
60        shared_state: Arc<RwLock<PresentationState>>,
61        doc: Arc<dyn DocumentSource>,
62        sender: CommandSender,
63        config: &Config,
64        display_mode: DisplayMode,
65    ) -> Self {
66        let keybindings = KeybindingMap::from_full_config(config);
67        let input = InputHandler::new(sender.clone(), keybindings);
68        let presenter = PresenterConsole::new(input);
69        let audience = AudienceWindow::new();
70        let cache = PageCache::new(64);
71        let pipeline = RenderPipeline::new(doc, 2);
72        let presenter_window_size = egui::vec2(1400.0, 850.0);
73
74        Self {
75            engine,
76            shared_state,
77            cache,
78            pipeline,
79            presenter,
80            hud: HudOverlay::new(),
81            audience,
82            sender,
83            display_mode,
84            single_monitor_view: SingleMonitorView::from_config(
85                &config.display.single_monitor_view,
86            ),
87            audience_reassignment: None,
88            toast_manager: ToastManager::new(),
89            remote: None,
90            presenter_window_size,
91            applied_presenter_monitor_id: None,
92        }
93    }
94
95    pub fn toast_manager_mut(&mut self) -> &mut ToastManager {
96        &mut self.toast_manager
97    }
98
99    pub fn set_audience_reassignment(&mut self, prompt: Option<AudienceReassignmentPrompt>) {
100        self.audience_reassignment = prompt;
101    }
102
103    pub fn set_remote_info(&mut self, remote: Option<RemoteUiInfo>) {
104        self.remote = remote;
105    }
106}
107
108impl eframe::App for DaisApp {
109    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
110        // Tick the engine — processes commands, updates timer, broadcasts state
111        let should_quit = self.engine.tick();
112        if should_quit {
113            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
114            return;
115        }
116
117        // Collect completed background renders into the cache
118        self.pipeline.poll_results(&mut self.cache);
119
120        // Read state snapshot for this frame
121        let state = self.shared_state.read().map_or_else(
122            |e| {
123                tracing::error!("Failed to read state: {e}");
124                PresentationState::new(0, Vec::new())
125            },
126            |s| s.clone(),
127        );
128
129        // Submit render requests for pages we need
130        let presenter_size = FALLBACK_RENDER_SIZE;
131        let effective_display_mode =
132            display_mode::effective_display_mode(&self.display_mode, state.displays_swapped);
133        self.apply_presenter_viewport(ctx, &effective_display_mode);
134        let base_audience_size = display_mode::audience_render_size(&effective_display_mode);
135        let audience_size = effective_audience_render_size(&state, base_audience_size);
136        self.pipeline.prefetch_neighborhood(
137            state.current_page,
138            state.total_pages,
139            presenter_size,
140            &mut self.cache,
141        );
142        // Audience page (may differ if frozen)
143        self.pipeline.ensure_rendered(state.audience_page(), base_audience_size, &mut self.cache);
144        self.pipeline.ensure_rendered(state.audience_page(), audience_size, &mut self.cache);
145
146        // When overview is visible, request renders for all logical slide thumbnails
147        if state.overview_visible {
148            for group in &state.slide_groups {
149                if let Some(&first_page) = group.pages.first() {
150                    self.pipeline.ensure_rendered(first_page, presenter_size, &mut self.cache);
151                }
152            }
153        }
154
155        // Request periodic repaints while timers are active or renders are pending.
156        if state.timer.running {
157            ctx.request_repaint_after(std::time::Duration::from_millis(100));
158        } else {
159            // The per-slide timer updates every second, while the render pipeline
160            // still benefits from a modest polling cadence.
161            ctx.request_repaint_after(std::time::Duration::from_millis(250));
162        }
163
164        // In Single mode, F5 (presentation_mode) flips between the two views.
165        // The configured single_monitor_view is the default; F5 shows the other.
166        if matches!(self.display_mode, DisplayMode::Single) {
167            let render_size = effective_audience_render_size(&state, FALLBACK_RENDER_SIZE);
168            let active_view = if state.presentation_mode {
169                match self.single_monitor_view {
170                    SingleMonitorView::Hud => SingleMonitorView::Split,
171                    SingleMonitorView::Split => SingleMonitorView::Hud,
172                }
173            } else {
174                self.single_monitor_view
175            };
176            match active_view {
177                SingleMonitorView::Hud => {
178                    let input = self.presenter.input_mut();
179                    self.hud.show(ctx, &state, &mut self.cache, &self.sender, input, render_size);
180                }
181                SingleMonitorView::Split => {
182                    self.presenter.show_single_monitor_split(
183                        ctx,
184                        &state,
185                        &mut self.cache,
186                        &self.sender,
187                        render_size,
188                        self.remote.as_ref(),
189                    );
190                }
191            }
192            self.show_audience_reassignment_prompt(ctx);
193            self.toast_manager.show(ctx);
194            return;
195        }
196
197        // Read runtime screen-share toggle
198        let is_runtime_screen_share = state.screen_share_mode;
199
200        // Render the presenter console in the main viewport
201        self.presenter.show(ctx, &state, &mut self.cache, &self.sender, self.remote.as_ref());
202
203        // Choose audience viewport builder
204        let viewport_builder = if is_runtime_screen_share {
205            display_mode::with_app_icon(egui::ViewportBuilder::default())
206                .with_title("Dais — Audience")
207                .with_inner_size(egui::vec2(1280.0, 720.0))
208                .with_fullscreen(false)
209                .with_resizable(true)
210        } else {
211            display_mode::audience_viewport_builder(&effective_display_mode)
212        };
213
214        let shared = self.shared_state.clone();
215        let audience = &mut self.audience;
216        let cache = &mut self.cache;
217        let shared_ref = &shared;
218
219        ctx.show_viewport_immediate(
220            egui::ViewportId::from_hash_of("audience"),
221            viewport_builder,
222            |ctx, _class| {
223                audience.show(ctx, shared_ref, cache, audience_size);
224            },
225        );
226
227        self.show_audience_reassignment_prompt(ctx);
228        self.toast_manager.show(ctx);
229    }
230}
231
232impl DaisApp {
233    fn apply_presenter_viewport(&mut self, ctx: &egui::Context, mode: &DisplayMode) {
234        let DisplayMode::Dual { presenter_monitor, .. } = mode else {
235            self.applied_presenter_monitor_id = None;
236            return;
237        };
238
239        if self.applied_presenter_monitor_id.as_deref() == Some(presenter_monitor.id.as_str()) {
240            return;
241        }
242
243        let Some(placement) = display_mode::presenter_viewport_placement(
244            presenter_monitor,
245            self.presenter_window_size,
246        ) else {
247            return;
248        };
249        ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
250        ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(false));
251        ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(placement.inner_size));
252        ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(placement.position));
253        ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
254        self.applied_presenter_monitor_id = Some(presenter_monitor.id.clone());
255    }
256
257    fn current_presenter_monitor(&self) -> Option<dais_core::monitor::MonitorInfo> {
258        match &self.display_mode {
259            DisplayMode::Dual { presenter_monitor, .. } => Some(presenter_monitor.clone()),
260            DisplayMode::Single | DisplayMode::ScreenShare => None,
261        }
262    }
263
264    fn presenter_for_audience(
265        &self,
266        audience_monitor: &dais_core::monitor::MonitorInfo,
267        available_monitors: &[dais_core::monitor::MonitorInfo],
268    ) -> dais_core::monitor::MonitorInfo {
269        self.current_presenter_monitor()
270            .filter(|presenter| presenter.id != audience_monitor.id)
271            .or_else(|| {
272                available_monitors
273                    .iter()
274                    .find(|candidate| candidate.id != audience_monitor.id)
275                    .cloned()
276            })
277            .unwrap_or_else(|| audience_monitor.clone())
278    }
279
280    fn show_audience_reassignment_prompt(&mut self, ctx: &egui::Context) {
281        let Some(prompt) = self.audience_reassignment.clone() else {
282            return;
283        };
284
285        egui::Window::new("Audience Monitor Changed")
286            .collapsible(false)
287            .resizable(false)
288            .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
289            .show(ctx, |ui| {
290                ui.set_min_width(420.0);
291                ui.label(format!(
292                    "Configured audience monitor '{}' is not available.",
293                    prompt.missing_selector
294                ));
295
296                if let Some(fallback) = &prompt.attempted_fallback {
297                    ui.label(format!(
298                        "Dais is currently using '{}' as a fallback for this session.",
299                        fallback.name
300                    ));
301                } else {
302                    ui.label("No audience monitor fallback was available, so Dais switched to single-monitor mode.");
303                }
304
305                ui.separator();
306                ui.label("Choose the audience output for this session:");
307
308                let mut dismiss = false;
309                let fallback_id = prompt.attempted_fallback.as_ref().map(|monitor| monitor.id.as_str());
310                let alternative_monitors = prompt
311                    .available_monitors
312                    .iter()
313                    .filter(|monitor| Some(monitor.id.as_str()) != fallback_id)
314                    .cloned()
315                    .collect::<Vec<_>>();
316
317                if alternative_monitors.is_empty() {
318                    ui.label(
319                        egui::RichText::new("No alternate audience monitors were detected.")
320                            .small()
321                            .color(egui::Color32::GRAY),
322                    );
323                }
324
325                for monitor in &alternative_monitors {
326                    let primary = if monitor.is_primary { ", primary" } else { "" };
327                    let label = format!("Use {} ({}{primary})", monitor.name, monitor.id);
328                    if ui.button(label).clicked() {
329                        let presenter_monitor =
330                            self.presenter_for_audience(monitor, &prompt.available_monitors);
331                        self.display_mode = DisplayMode::Dual {
332                            presenter_monitor,
333                            audience_monitor: monitor.clone(),
334                        };
335                        self.toast_manager.push(
336                            crate::widgets::toast::ToastLevel::Info,
337                            format!("Audience moved to '{}'", monitor.name),
338                        );
339                        dismiss = true;
340                    }
341                }
342
343                ui.horizontal(|ui| {
344                    if let Some(fallback) = &prompt.attempted_fallback {
345                        if ui.button("Keep current fallback").clicked() {
346                            let presenter_monitor =
347                                self.presenter_for_audience(fallback, &prompt.available_monitors);
348                            self.display_mode = DisplayMode::Dual {
349                                presenter_monitor,
350                                audience_monitor: fallback.clone(),
351                            };
352                            self.toast_manager.push(
353                                crate::widgets::toast::ToastLevel::Info,
354                                format!("Keeping fallback audience monitor '{}'", fallback.name),
355                            );
356                            dismiss = true;
357                        }
358
359                        if ui.button("Use single-monitor mode").clicked() {
360                            self.display_mode = DisplayMode::Single;
361                            self.toast_manager.push(
362                                crate::widgets::toast::ToastLevel::Info,
363                                "Audience reassigned to single-monitor mode",
364                            );
365                            dismiss = true;
366                        }
367                    } else if ui.button("Stay in single-monitor mode").clicked() {
368                        self.display_mode = DisplayMode::Single;
369                        self.toast_manager.push(
370                            crate::widgets::toast::ToastLevel::Info,
371                            "Keeping single-monitor mode",
372                        );
373                        dismiss = true;
374                    }
375                });
376
377                ui.label(
378                    egui::RichText::new(
379                        "This updates the current session only. You can make it permanent in `dais.toml` later.",
380                    )
381                    .small()
382                    .color(egui::Color32::GRAY),
383                );
384
385                if dismiss {
386                    self.audience_reassignment = None;
387                }
388            });
389    }
390}
391
392#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]
393fn effective_audience_render_size(state: &PresentationState, base_size: RenderSize) -> RenderSize {
394    let Some(region) = state.zoom_region.as_ref().filter(|_| state.zoom_active) else {
395        return base_size;
396    };
397
398    // Render a denser source image while zoom is active so the cropped audience
399    // view has more real pixels to work with. We cap the output size to keep
400    // render cost bounded on extreme zoom factors.
401    let multiplier = region.factor.clamp(1.0, 3.0);
402    let width = ((base_size.width as f32) * multiplier).round() as u32;
403    let height = ((base_size.height as f32) * multiplier).round() as u32;
404
405    RenderSize {
406        width: width.clamp(base_size.width, MAX_ZOOM_RENDER_DIMENSION),
407        height: height.clamp(base_size.height, MAX_ZOOM_RENDER_DIMENSION),
408    }
409}