Skip to main content

dais_ui/presenter/
mod.rs

1//! Presenter console layout and panels.
2//!
3//! Composes the current slide, next preview, notes, timer, and overview
4//! into the presenter console window.
5
6pub mod current_slide;
7pub mod hud;
8pub mod layout;
9pub mod next_preview;
10pub mod notes_panel;
11pub mod overview;
12pub mod timer;
13
14use dais_core::bus::CommandSender;
15use dais_core::state::{DrawTool, PresentationState};
16use dais_document::cache::PageCache;
17use dais_document::render_pipeline::FALLBACK_RENDER_SIZE;
18use dais_document::typst_renderer::TextBoxRenderCache;
19
20use self::current_slide::CurrentSlidePanel;
21use self::layout::PresenterLayout;
22use self::next_preview::NextPreviewPanel;
23use self::notes_panel::{NotesPanel, NotesPanelView};
24use self::overview::OverviewGrid;
25use crate::app::RemoteUiInfo;
26use crate::audience::display::AudienceDisplay;
27
28use crate::input::{InputHandler, UiModes};
29use crate::widgets::{HelpOverlay, TextBoxTextureCache};
30use qrcode::QrCode;
31
32const MIN_LEFT_FRACTION: f32 = 0.35;
33const MAX_LEFT_FRACTION: f32 = 0.8;
34const MIN_TOP_FRACTION: f32 = 0.2;
35const MAX_TOP_FRACTION: f32 = 0.8;
36const SPLITTER_COLOR: egui::Color32 = egui::Color32::from_gray(70);
37const SPLITTER_HOVER: egui::Color32 = egui::Color32::from_rgb(124, 178, 255);
38const QR_DARK: egui::Color32 = egui::Color32::from_gray(20);
39const QR_LIGHT: egui::Color32 = egui::Color32::from_gray(245);
40
41/// The presenter console window — composes all sub-panels.
42pub struct PresenterConsole {
43    audience_display: AudienceDisplay,
44    current_slide: CurrentSlidePanel,
45    next_preview: NextPreviewPanel,
46    notes: NotesPanel,
47    overview: OverviewGrid,
48    input: InputHandler,
49    help: HelpOverlay,
50    tb_cache: TextBoxRenderCache,
51    tb_texture_cache: TextBoxTextureCache,
52    left_fraction: f32,
53    top_fraction: f32,
54    single_left_fraction: f32,
55    single_top_fraction: f32,
56    remote_panel_open: bool,
57}
58
59impl PresenterConsole {
60    pub fn new(input: InputHandler) -> Self {
61        Self {
62            audience_display: AudienceDisplay::new(),
63            current_slide: CurrentSlidePanel::new(),
64            next_preview: NextPreviewPanel::new(),
65            notes: NotesPanel::new(),
66            overview: OverviewGrid::new(),
67            input,
68            help: HelpOverlay::new(),
69            tb_cache: TextBoxRenderCache::new(),
70            tb_texture_cache: TextBoxTextureCache::default(),
71            left_fraction: 0.60,
72            top_fraction: 0.50,
73            single_left_fraction: 0.72,
74            single_top_fraction: 0.40,
75            remote_panel_open: false,
76        }
77    }
78
79    /// Access the input handler (e.g. to share it with HUD mode).
80    pub fn input_mut(&mut self) -> &mut InputHandler {
81        &mut self.input
82    }
83
84    /// Render the presenter console in the given egui context.
85    ///
86    /// All page textures come from the cache (populated by the background
87    /// render pipeline).  If a page isn't cached yet we simply skip it —
88    /// the pipeline will deliver it on a subsequent frame.
89    #[allow(clippy::too_many_lines)]
90    pub fn show(
91        &mut self,
92        ctx: &egui::Context,
93        state: &PresentationState,
94        cache: &mut PageCache,
95        sender: &CommandSender,
96        remote: Option<&RemoteUiInfo>,
97    ) {
98        // Help overlay intercepts input when visible.
99        let help_consumed = self.help.show(ctx, self.input.keybindings());
100
101        // Toggle help on `?` — but not while notes are being edited (? should type normally).
102        let notes_editing = state.notes_editing;
103        if !help_consumed && !notes_editing && Self::question_mark_pressed(ctx) {
104            self.help.toggle();
105        }
106
107        // Skip normal input while help is showing.
108        if !self.help.visible {
109            self.input.handle_input(
110                ctx,
111                UiModes {
112                    overview_visible: state.overview_visible,
113                    ink_active: state.ink_active,
114                    laser_active: state.laser_active,
115                    notes_editing: state.notes_editing,
116                    text_box_mode: state.text_box_mode,
117                    text_box_editing: state.text_box_editing,
118                    selected_text_box: state.selected_text_box,
119                },
120            );
121        }
122
123        // Update textures from cache (single canonical size)
124        let size = FALLBACK_RENDER_SIZE;
125        let current_page = state.current_page;
126
127        if let Some(page) = cache.get(current_page, size) {
128            let page = page.clone();
129            self.current_slide.update(ctx, &page, current_page);
130        }
131
132        let next_page =
133            if current_page + 1 < state.total_pages { Some(current_page + 1) } else { None };
134        if let Some(np) = next_page
135            && let Some(page) = cache.get(np, size)
136        {
137            let page = page.clone();
138            self.next_preview.update(ctx, &page, np);
139        }
140
141        egui::CentralPanel::default()
142            .frame(egui::Frame::new().fill(egui::Color32::from_gray(30)))
143            .show(ctx, |ui| {
144                let available = ui.available_rect_before_wrap();
145                let layout =
146                    PresenterLayout::compute(available, self.left_fraction, self.top_fraction);
147
148                self.handle_splitters(ui, available, &layout);
149
150                // Current slide
151                let slide_sense = if state.text_box_mode {
152                    egui::Sense::hover()
153                } else {
154                    egui::Sense::click_and_drag()
155                };
156                let (response, image_rect) =
157                    self.current_slide.show_with_sense(ui, layout.current_slide, slide_sense);
158
159                // Handle mouse on current slide
160                self.input.handle_slide_mouse(
161                    &response,
162                    image_rect,
163                    crate::input::ActiveAids {
164                        ink: state.ink_active,
165                        eraser: state.draw_tool == DrawTool::Eraser,
166                        eraser_radius: state.eraser_radius,
167                        laser: state.laser_active,
168                        spotlight: state.spotlight_active,
169                        zoom: state.zoom_active,
170                    },
171                    state.zoom_region.as_ref().map(|region| region.factor),
172                );
173
174                // Draw ink strokes on presenter view
175                if state.whiteboard_active {
176                    // White canvas over the current slide
177                    ui.painter().rect_filled(image_rect, 0.0, egui::Color32::WHITE);
178                    if !state.whiteboard_strokes.is_empty() {
179                        crate::widgets::draw_ink_strokes(ui, image_rect, &state.whiteboard_strokes);
180                    }
181                } else {
182                    let page_ink = state.current_page_ink();
183                    if !page_ink.is_empty() {
184                        crate::widgets::draw_ink_strokes(ui, image_rect, page_ink);
185                    }
186                }
187
188                if !state.whiteboard_active {
189                    // Draw text boxes on presenter view (interactive in text box mode)
190                    let editing_id =
191                        if state.text_box_editing { state.selected_text_box } else { None };
192                    let tb_cmds = crate::widgets::draw_text_boxes(
193                        ui,
194                        state.current_page_text_boxes(),
195                        state.selected_text_box,
196                        editing_id,
197                        state.text_box_mode,
198                        image_rect,
199                        &mut self.tb_cache,
200                        &mut self.tb_texture_cache,
201                    );
202                    for cmd in tb_cmds {
203                        let _ = sender.send(cmd);
204                    }
205                }
206
207                // Draw laser dot on presenter view
208                if state.laser_active
209                    && let Some((px, py)) = state.pointer_position
210                {
211                    let appearance = state.current_pointer_appearance();
212                    crate::audience::overlays::draw_laser_overlay(
213                        ui,
214                        image_rect,
215                        px,
216                        py,
217                        appearance.color,
218                        appearance.size,
219                        state.pointer_style,
220                    );
221                }
222
223                // Draw spotlight on presenter view
224                if state.spotlight_active
225                    && let Some((sx, sy)) = state.spotlight_position
226                {
227                    crate::audience::overlays::draw_spotlight_overlay(
228                        ui,
229                        image_rect,
230                        sx,
231                        sy,
232                        state.spotlight_radius,
233                        state.spotlight_dim_opacity,
234                    );
235                }
236
237                // Draw zoom target on presenter view so the operator can steer
238                // the audience zoom region. This is also the natural place to
239                // add future mouse-wheel zoom-factor control.
240                if state.zoom_active
241                    && let Some(ref region) = state.zoom_region
242                {
243                    crate::audience::overlays::draw_zoom_indicator(
244                        ui,
245                        image_rect,
246                        region.center,
247                        region.factor,
248                    );
249                }
250
251                // Next preview
252                if let Some(np) = next_page {
253                    let _ = np;
254                    self.next_preview.show(ui, layout.next_preview);
255                } else {
256                    self.next_preview.show_empty(ui, layout.next_preview);
257                }
258
259                // Notes panel
260                self.notes.show(
261                    ui,
262                    layout.notes_panel,
263                    &NotesPanelView {
264                        notes: state.current_notes.as_deref(),
265                        font_size: state.notes_font_size,
266                        visible: state.notes_visible,
267                        editing: state.notes_editing,
268                    },
269                    sender,
270                );
271
272                // Status bar
273                if self.show_status_bar(ui, layout.status_bar, state, sender, remote) {
274                    self.help.toggle();
275                }
276
277                self.show_remote_panel(ctx, remote);
278
279                // Slide overview (modal overlay)
280                if state.overview_visible {
281                    self.overview.show(ctx, ui, state, cache, sender);
282                }
283
284                Self::show_quit_dialog(ui, state);
285            });
286    }
287
288    /// Render a single-monitor split view with the audience slide on the left
289    /// and the full presenter console on the right.
290    #[allow(clippy::too_many_lines)]
291    pub fn show_single_monitor_split(
292        &mut self,
293        ctx: &egui::Context,
294        state: &PresentationState,
295        cache: &mut PageCache,
296        sender: &CommandSender,
297        audience_render_size: dais_document::page::RenderSize,
298        remote: Option<&RemoteUiInfo>,
299    ) {
300        // Help overlay intercepts input when visible.
301        let help_consumed = self.help.show(ctx, self.input.keybindings());
302
303        if !help_consumed && !state.notes_editing && Self::question_mark_pressed(ctx) {
304            self.help.toggle();
305        }
306
307        if !self.help.visible {
308            self.input.handle_input(
309                ctx,
310                UiModes {
311                    overview_visible: state.overview_visible,
312                    ink_active: state.ink_active,
313                    laser_active: state.laser_active,
314                    notes_editing: state.notes_editing,
315                    text_box_mode: state.text_box_mode,
316                    text_box_editing: state.text_box_editing,
317                    selected_text_box: state.selected_text_box,
318                },
319            );
320        }
321
322        let presenter_size = FALLBACK_RENDER_SIZE;
323        let current_page = state.current_page;
324
325        if let Some(page) = cache.get(current_page, presenter_size) {
326            let page = page.clone();
327            self.current_slide.update(ctx, &page, current_page);
328        }
329
330        let next_page =
331            if current_page + 1 < state.total_pages { Some(current_page + 1) } else { None };
332        if let Some(next_index) = next_page
333            && let Some(page) = cache.get(next_index, presenter_size)
334        {
335            let page = page.clone();
336            self.next_preview.update(ctx, &page, next_index);
337        }
338
339        let audience_page = state.audience_page();
340        if let Some(page) = cache.get(audience_page, audience_render_size) {
341            let page = page.clone();
342            self.audience_display.update(ctx, &page, audience_page);
343        }
344
345        egui::CentralPanel::default()
346            .frame(egui::Frame::new().fill(egui::Color32::from_gray(30)))
347            .show(ctx, |ui| {
348                const VSPLIT: f32 = 8.0;
349                const STATUS_H: f32 = 40.0;
350                const HSPLIT: f32 = 8.0;
351                let available = ui.available_rect_before_wrap();
352
353                // Left/right split
354                let left_w = (available.width() * self.single_left_fraction - VSPLIT * 0.5)
355                    .clamp(200.0, available.width() - 200.0 - VSPLIT);
356                let left_rect = egui::Rect::from_min_max(
357                    available.min,
358                    egui::pos2(available.min.x + left_w, available.max.y),
359                );
360                let vsplit_rect = egui::Rect::from_min_max(
361                    egui::pos2(left_rect.max.x, available.min.y),
362                    egui::pos2(left_rect.max.x + VSPLIT, available.max.y),
363                );
364                let right_rect = egui::Rect::from_min_max(
365                    egui::pos2(vsplit_rect.max.x, available.min.y),
366                    available.max,
367                );
368
369                // Vertical splitter (audience | presenter strip)
370                let vsplit_id = ui.make_persistent_id("sm_vsplit");
371                let vsplit_resp =
372                    ui.interact(vsplit_rect, vsplit_id, egui::Sense::click_and_drag());
373                if vsplit_resp.dragged()
374                    && let Some(ptr) = vsplit_resp.interact_pointer_pos()
375                {
376                    self.single_left_fraction =
377                        ((ptr.x - available.left()) / available.width()).clamp(0.3, 0.85);
378                    ui.ctx().request_repaint();
379                }
380                if vsplit_resp.hovered() || vsplit_resp.dragged() {
381                    ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
382                }
383                ui.painter().rect_filled(
384                    vsplit_rect,
385                    2.0,
386                    if vsplit_resp.hovered() || vsplit_resp.dragged() {
387                        SPLITTER_HOVER
388                    } else {
389                        SPLITTER_COLOR
390                    },
391                );
392
393                // Left: audience slide (black, letterboxed)
394                ui.painter().rect_filled(left_rect, 0.0, egui::Color32::BLACK);
395                let zoom_region = if state.zoom_active {
396                    state.zoom_region.as_ref().map(|r| (r.center, r.factor))
397                } else {
398                    None
399                };
400                let mut left_ui = ui.new_child(egui::UiBuilder::new().max_rect(left_rect));
401                let aud_rect = self.audience_display.show(&mut left_ui, zoom_region);
402                let aud_response = left_ui.interact(
403                    aud_rect,
404                    egui::Id::new("sm_split_audience"),
405                    egui::Sense::click_and_drag(),
406                );
407                self.input.handle_slide_mouse(
408                    &aud_response,
409                    aud_rect,
410                    crate::input::ActiveAids {
411                        ink: state.ink_active,
412                        eraser: state.draw_tool == DrawTool::Eraser,
413                        eraser_radius: state.eraser_radius,
414                        laser: state.laser_active,
415                        spotlight: state.spotlight_active,
416                        zoom: state.zoom_active,
417                    },
418                    state.zoom_region.as_ref().map(|r| r.factor),
419                );
420                if !state.whiteboard_active {
421                    let editing_id =
422                        if state.text_box_editing { state.selected_text_box } else { None };
423                    let text_box_rect = zoom_region.map_or(aud_rect, |(center, factor)| {
424                        crate::audience::overlays::zoomed_overlay_rect(aud_rect, center, factor)
425                    });
426                    let mut text_box_ui =
427                        left_ui.new_child(egui::UiBuilder::new().max_rect(left_rect));
428                    text_box_ui.set_clip_rect(aud_rect);
429                    let tb_cmds = crate::widgets::draw_text_boxes(
430                        &mut text_box_ui,
431                        state.current_page_text_boxes(),
432                        state.selected_text_box,
433                        editing_id,
434                        state.text_box_mode,
435                        text_box_rect,
436                        &mut self.tb_cache,
437                        &mut self.tb_texture_cache,
438                    );
439                    for cmd in tb_cmds {
440                        let _ = sender.send(cmd);
441                    }
442                }
443                crate::audience::overlays::draw_overlays(
444                    &mut left_ui,
445                    crate::audience::overlays::OverlayGeometry {
446                        viewport_rect: left_rect,
447                        image_rect: aud_rect,
448                        zoom_region,
449                    },
450                    state,
451                    &mut self.tb_cache,
452                    &mut self.tb_texture_cache,
453                    false,
454                );
455
456                // Right: next preview | [hsplit] | notes | status bar
457                let content_h = (right_rect.height() - STATUS_H).max(0.0);
458                let next_h = (content_h * self.single_top_fraction - HSPLIT * 0.5).max(60.0);
459                let notes_h = (content_h - next_h - HSPLIT).max(40.0);
460
461                let next_rect = egui::Rect::from_min_size(
462                    right_rect.min,
463                    egui::vec2(right_rect.width(), next_h),
464                );
465                let hsplit_rect = egui::Rect::from_min_size(
466                    egui::pos2(right_rect.min.x, next_rect.max.y),
467                    egui::vec2(right_rect.width(), HSPLIT),
468                );
469                let notes_rect = egui::Rect::from_min_size(
470                    egui::pos2(right_rect.min.x, hsplit_rect.max.y),
471                    egui::vec2(right_rect.width(), notes_h),
472                );
473                let status_rect = egui::Rect::from_min_size(
474                    egui::pos2(right_rect.min.x, right_rect.max.y - STATUS_H),
475                    egui::vec2(right_rect.width(), STATUS_H),
476                );
477
478                // Horizontal splitter (next preview | notes)
479                let hsplit_id = ui.make_persistent_id("sm_hsplit");
480                let hsplit_resp =
481                    ui.interact(hsplit_rect, hsplit_id, egui::Sense::click_and_drag());
482                if hsplit_resp.dragged()
483                    && let Some(ptr) = hsplit_resp.interact_pointer_pos()
484                {
485                    self.single_top_fraction =
486                        ((ptr.y - right_rect.top()) / content_h.max(1.0)).clamp(0.15, 0.75);
487                    ui.ctx().request_repaint();
488                }
489                if hsplit_resp.hovered() || hsplit_resp.dragged() {
490                    ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
491                }
492                ui.painter().rect_filled(
493                    hsplit_rect,
494                    2.0,
495                    if hsplit_resp.hovered() || hsplit_resp.dragged() {
496                        SPLITTER_HOVER
497                    } else {
498                        SPLITTER_COLOR
499                    },
500                );
501
502                if let Some(_np) = next_page {
503                    self.next_preview.show(ui, next_rect);
504                } else {
505                    self.next_preview.show_empty(ui, next_rect);
506                }
507
508                self.notes.show(
509                    ui,
510                    notes_rect,
511                    &NotesPanelView {
512                        notes: state.current_notes.as_deref(),
513                        font_size: state.notes_font_size,
514                        visible: state.notes_visible,
515                        editing: state.notes_editing,
516                    },
517                    sender,
518                );
519
520                if self.show_status_bar(ui, status_rect, state, sender, remote) {
521                    self.help.toggle();
522                }
523
524                self.show_remote_panel(ctx, remote);
525
526                if state.overview_visible {
527                    self.overview.show(ctx, ui, state, cache, sender);
528                }
529
530                Self::show_quit_dialog(ui, state);
531            });
532    }
533
534    fn show_quit_dialog(ui: &mut egui::Ui, state: &PresentationState) {
535        if !state.quit_requested {
536            return;
537        }
538
539        let screen = ui.max_rect();
540        ui.painter().rect_filled(screen, 0.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180));
541
542        let dialog_size = egui::vec2(320.0, 120.0);
543        let dialog_rect = egui::Rect::from_center_size(screen.center(), dialog_size);
544        ui.painter().rect_filled(dialog_rect, 8.0, egui::Color32::from_gray(50));
545        ui.painter().rect_stroke(
546            dialog_rect,
547            8.0,
548            egui::Stroke::new(1.0_f32, egui::Color32::GRAY),
549            egui::StrokeKind::Outside,
550        );
551
552        ui.painter().text(
553            dialog_rect.center_top() + egui::vec2(0.0, 25.0),
554            egui::Align2::CENTER_CENTER,
555            "Quit presentation?",
556            egui::FontId::proportional(18.0),
557            egui::Color32::WHITE,
558        );
559        ui.painter().text(
560            dialog_rect.center_top() + egui::vec2(0.0, 50.0),
561            egui::Align2::CENTER_CENTER,
562            "Press q or Escape to confirm",
563            egui::FontId::proportional(13.0),
564            egui::Color32::LIGHT_GRAY,
565        );
566        ui.painter().text(
567            dialog_rect.center_top() + egui::vec2(0.0, 75.0),
568            egui::Align2::CENTER_CENTER,
569            "Press any other key to cancel",
570            egui::FontId::proportional(12.0),
571            egui::Color32::from_gray(160),
572        );
573    }
574
575    fn handle_splitters(
576        &mut self,
577        ui: &mut egui::Ui,
578        available: egui::Rect,
579        layout: &PresenterLayout,
580    ) {
581        let vertical_id = ui.make_persistent_id("presenter_vertical_splitter");
582        let vertical_response =
583            ui.interact(layout.vertical_splitter, vertical_id, egui::Sense::click_and_drag());
584        if vertical_response.dragged()
585            && let Some(pointer) = vertical_response.interact_pointer_pos()
586        {
587            self.left_fraction = ((pointer.x - available.left()) / available.width())
588                .clamp(MIN_LEFT_FRACTION, MAX_LEFT_FRACTION);
589            ui.ctx().request_repaint();
590        }
591        if vertical_response.hovered() || vertical_response.dragged() {
592            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
593        }
594        ui.painter().rect_filled(
595            layout.vertical_splitter,
596            2.0,
597            if vertical_response.hovered() || vertical_response.dragged() {
598                SPLITTER_HOVER
599            } else {
600                SPLITTER_COLOR
601            },
602        );
603
604        let horizontal_id = ui.make_persistent_id("presenter_horizontal_splitter");
605        let horizontal_response =
606            ui.interact(layout.horizontal_splitter, horizontal_id, egui::Sense::click_and_drag());
607        if horizontal_response.dragged()
608            && let Some(pointer) = horizontal_response.interact_pointer_pos()
609        {
610            self.top_fraction = ((pointer.y - available.top())
611                / (available.height() - 40.0).max(1.0))
612            .clamp(MIN_TOP_FRACTION, MAX_TOP_FRACTION);
613            ui.ctx().request_repaint();
614        }
615        if horizontal_response.hovered() || horizontal_response.dragged() {
616            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
617        }
618        ui.painter().rect_filled(
619            layout.horizontal_splitter,
620            2.0,
621            if horizontal_response.hovered() || horizontal_response.dragged() {
622                SPLITTER_HOVER
623            } else {
624                SPLITTER_COLOR
625            },
626        );
627    }
628
629    #[allow(clippy::too_many_lines)]
630    fn show_status_bar(
631        &mut self,
632        ui: &mut egui::Ui,
633        area: egui::Rect,
634        state: &PresentationState,
635        sender: &CommandSender,
636        remote: Option<&RemoteUiInfo>,
637    ) -> bool {
638        let mut help_clicked = false;
639        let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(area));
640
641        // Background
642        child_ui.painter().rect_filled(area, 0.0, egui::Color32::from_gray(20));
643
644        child_ui.scope_builder(egui::UiBuilder::new().max_rect(area.shrink(4.0)), |ui| {
645            ui.horizontal(|ui| {
646                // Slide position
647                let slide_text = format!(
648                    "Slide {}/{}",
649                    state.current_logical_slide + 1,
650                    state.total_logical_slides,
651                );
652
653                let group = state.slide_groups.get(state.current_logical_slide);
654                let overlay_text = if let Some(g) = group {
655                    if g.pages.len() > 1 {
656                        format!(
657                            " (step {}/{})",
658                            state.current_overlay_within_group + 1,
659                            g.pages.len()
660                        )
661                    } else {
662                        String::new()
663                    }
664                } else {
665                    String::new()
666                };
667
668                ui.label(
669                    egui::RichText::new(format!("{slide_text}{overlay_text}"))
670                        .size(14.0)
671                        .color(egui::Color32::WHITE),
672                );
673
674                ui.separator();
675
676                // Timer (clickable)
677                if timer::show_timer(ui, &state.timer) {
678                    let _ = sender.send(dais_core::commands::Command::ToggleTimer);
679                }
680
681                ui.separator();
682
683                let target = state
684                    .slide_target_durations
685                    .get(state.current_logical_slide)
686                    .copied()
687                    .flatten();
688                timer::show_slide_timer(ui, state.slide_elapsed, target);
689
690                ui.separator();
691
692                // Mode indicators
693                let mut indicators = Vec::new();
694                if state.frozen {
695                    indicators.push(("[F]rozen", egui::Color32::LIGHT_BLUE));
696                }
697                if state.blacked_out {
698                    indicators.push(("[B]lack", egui::Color32::YELLOW));
699                }
700                if state.screen_share_mode {
701                    indicators.push(("[S]creen-share", egui::Color32::LIGHT_GREEN));
702                }
703                if state.laser_active {
704                    indicators.push(("[L]aser", egui::Color32::RED));
705                }
706                if state.ink_active {
707                    indicators.push(("[D]raw", egui::Color32::from_rgb(255, 165, 0)));
708                }
709
710                if let Some(remote) = remote {
711                    let status = remote.status.snapshot();
712                    let label = format!(
713                        "Remote: {} client{}{}",
714                        status.active_event_clients,
715                        if status.active_event_clients == 1 { "" } else { "s" },
716                        status
717                            .last_command
718                            .as_ref()
719                            .map(|cmd| format!(" · last {cmd}"))
720                            .unwrap_or_default()
721                    );
722                    if ui
723                        .button(egui::RichText::new(label).size(12.0))
724                        .on_hover_text("Show remote pairing")
725                        .clicked()
726                    {
727                        self.remote_panel_open = true;
728                    }
729                }
730                if state.spotlight_active {
731                    indicators.push(("Spotlight", egui::Color32::LIGHT_YELLOW));
732                }
733                if state.zoom_active {
734                    indicators.push(("[Z]oom", egui::Color32::LIGHT_GREEN));
735                }
736                if state.text_box_mode {
737                    indicators.push(("[X]Text", egui::Color32::from_rgb(180, 130, 255)));
738                }
739
740                for (text, color) in indicators {
741                    ui.colored_label(color, egui::RichText::new(text).size(12.0));
742                }
743                if state.ink_active {
744                    crate::widgets::show_ink_toolbar(ui, state, sender);
745                }
746
747                // Jump mode indicator
748                if self.input.mode() == crate::input::InputMode::JumpToSlide {
749                    let buf = self.input.jump_buffer();
750                    ui.colored_label(
751                        egui::Color32::YELLOW,
752                        egui::RichText::new(format!("Go to: {buf}_")).size(14.0),
753                    );
754                }
755
756                // Help button — right-aligned
757                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
758                    if ui
759                        .add(
760                            egui::Button::new(
761                                egui::RichText::new("?")
762                                    .size(15.0)
763                                    .color(egui::Color32::WHITE)
764                                    .strong(),
765                            )
766                            .fill(egui::Color32::from_gray(50))
767                            .corner_radius(4.0)
768                            .min_size(egui::vec2(26.0, 26.0)),
769                        )
770                        .on_hover_text("Keyboard shortcuts")
771                        .clicked()
772                    {
773                        help_clicked = true;
774                    }
775                });
776            });
777        });
778
779        help_clicked
780    }
781
782    fn show_remote_panel(&mut self, ctx: &egui::Context, remote: Option<&RemoteUiInfo>) {
783        let Some(remote) = remote else {
784            self.remote_panel_open = false;
785            return;
786        };
787        if !self.remote_panel_open {
788            return;
789        }
790
791        egui::Window::new("Remote Pairing")
792            .open(&mut self.remote_panel_open)
793            .collapsible(false)
794            .resizable(true)
795            .default_width(420.0)
796            .show(ctx, |ui| {
797                ui.label("Open the web remote on a phone or tablet.");
798                if remote.requires_token {
799                    ui.label(format!("Pairing code: {}", remote.token));
800                }
801                ui.separator();
802
803                let urls = pairing_urls(remote);
804                for pairing in &urls {
805                    let is_loopback = is_loopback_url(pairing);
806                    ui.label(if is_loopback { "This computer" } else { "Phone or tablet" });
807                    copyable_url(ui, pairing);
808                    if !is_loopback || urls.len() == 1 {
809                        draw_qr(ui, pairing, 176.0);
810                    }
811                    ui.add_space(8.0);
812                }
813                ui.separator();
814                ui.label("If a phone cannot connect, Wi-Fi isolation or a firewall may be blocking local device access.");
815            });
816    }
817
818    /// Check whether a `?` text event was produced this frame.
819    fn question_mark_pressed(ctx: &egui::Context) -> bool {
820        ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "?")))
821    }
822}
823
824fn pairing_url(url: &str, remote: &RemoteUiInfo) -> String {
825    if remote.requires_token {
826        let sep = if url.contains('?') { '&' } else { '?' };
827        format!("{url}{sep}token={}", remote.token)
828    } else {
829        url.to_string()
830    }
831}
832
833fn pairing_urls(remote: &RemoteUiInfo) -> Vec<String> {
834    let mut urls = remote.urls.iter().map(|url| pairing_url(url, remote)).collect::<Vec<_>>();
835    urls.sort();
836    urls.dedup();
837    urls
838}
839
840fn is_loopback_url(url: &str) -> bool {
841    url.contains("://127.0.0.1:") || url.contains("://localhost:")
842}
843
844fn copyable_url(ui: &mut egui::Ui, url: &str) {
845    ui.horizontal(|ui| {
846        let mut text = url.to_string();
847        ui.add(
848            egui::TextEdit::singleline(&mut text)
849                .font(egui::TextStyle::Monospace)
850                .desired_width(320.0),
851        );
852        if ui.button("Copy").clicked() {
853            ui.ctx().copy_text(url.to_string());
854        }
855    });
856}
857
858#[allow(clippy::cast_precision_loss)]
859fn draw_qr(ui: &mut egui::Ui, value: &str, size: f32) {
860    let Ok(code) = QrCode::new(value.as_bytes()) else {
861        ui.label("Could not render QR code");
862        return;
863    };
864    let modules = code.width();
865    let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
866    ui.painter().rect_filled(rect, 4.0, QR_LIGHT);
867    let quiet = 4.0;
868    let cell = (size - quiet * 2.0) / modules as f32;
869    for y in 0..modules {
870        for x in 0..modules {
871            if code[(x, y)] == qrcode::Color::Dark {
872                let min = rect.min + egui::vec2(quiet + x as f32 * cell, quiet + y as f32 * cell);
873                ui.painter().rect_filled(
874                    egui::Rect::from_min_size(min, egui::vec2(cell, cell)),
875                    0.0,
876                    QR_DARK,
877                );
878            }
879        }
880    }
881}