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