1pub 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
37pub 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 pub fn input_mut(&mut self) -> &mut InputHandler {
75 &mut self.input
76 }
77
78 #[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 let help_consumed = self.help.show(ctx, self.input.keybindings());
93
94 let notes_editing = state.notes_editing;
96 if !help_consumed && !notes_editing && Self::question_mark_pressed(ctx) {
97 self.help.toggle();
98 }
99
100 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 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 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 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 if state.whiteboard_active {
167 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 let editing_id =
181 if state.text_box_editing { state.selected_text_box } else { None };
182 let tb_cmds = crate::widgets::draw_text_boxes(
183 ui,
184 state.current_page_text_boxes(),
185 state.selected_text_box,
186 editing_id,
187 state.text_box_mode,
188 image_rect,
189 &mut self.tb_cache,
190 &mut self.tb_texture_cache,
191 );
192 for cmd in tb_cmds {
193 let _ = sender.send(cmd);
194 }
195
196 if state.laser_active
198 && let Some((px, py)) = state.pointer_position
199 {
200 let appearance = state.current_pointer_appearance();
201 crate::audience::overlays::draw_laser_overlay(
202 ui,
203 image_rect,
204 px,
205 py,
206 appearance.color,
207 appearance.size,
208 state.pointer_style,
209 );
210 }
211
212 if state.spotlight_active
214 && let Some((sx, sy)) = state.spotlight_position
215 {
216 crate::audience::overlays::draw_spotlight_overlay(
217 ui,
218 image_rect,
219 sx,
220 sy,
221 state.spotlight_radius,
222 state.spotlight_dim_opacity,
223 );
224 }
225
226 if state.zoom_active
230 && let Some(ref region) = state.zoom_region
231 {
232 crate::audience::overlays::draw_zoom_indicator(
233 ui,
234 image_rect,
235 region.center,
236 region.factor,
237 );
238 }
239
240 if let Some(np) = next_page {
242 let _ = np;
243 self.next_preview.show(ui, layout.next_preview);
244 } else {
245 self.next_preview.show_empty(ui, layout.next_preview);
246 }
247
248 self.notes.show(
250 ui,
251 layout.notes_panel,
252 &NotesPanelView {
253 notes: state.current_notes.as_deref(),
254 font_size: state.notes_font_size,
255 visible: state.notes_visible,
256 editing: state.notes_editing,
257 },
258 sender,
259 );
260
261 if self.show_status_bar(ui, layout.status_bar, state, sender) {
263 self.help.toggle();
264 }
265
266 if state.overview_visible {
268 self.overview.show(ctx, ui, state, cache, sender);
269 }
270
271 Self::show_quit_dialog(ui, state);
272 });
273 }
274
275 #[allow(clippy::too_many_lines)]
278 pub fn show_single_monitor_split(
279 &mut self,
280 ctx: &egui::Context,
281 state: &PresentationState,
282 cache: &mut PageCache,
283 sender: &CommandSender,
284 audience_render_size: dais_document::page::RenderSize,
285 ) {
286 let help_consumed = self.help.show(ctx, self.input.keybindings());
288
289 if !help_consumed && !state.notes_editing && Self::question_mark_pressed(ctx) {
290 self.help.toggle();
291 }
292
293 if !self.help.visible {
294 self.input.handle_input(
295 ctx,
296 UiModes {
297 overview_visible: state.overview_visible,
298 ink_active: state.ink_active,
299 laser_active: state.laser_active,
300 notes_editing: state.notes_editing,
301 text_box_mode: state.text_box_mode,
302 text_box_editing: state.text_box_editing,
303 selected_text_box: state.selected_text_box,
304 },
305 );
306 }
307
308 let presenter_size = FALLBACK_RENDER_SIZE;
309 let current_page = state.current_page;
310
311 if let Some(page) = cache.get(current_page, presenter_size) {
312 let page = page.clone();
313 self.current_slide.update(ctx, &page, current_page);
314 }
315
316 let next_page =
317 if current_page + 1 < state.total_pages { Some(current_page + 1) } else { None };
318 if let Some(next_index) = next_page
319 && let Some(page) = cache.get(next_index, presenter_size)
320 {
321 let page = page.clone();
322 self.next_preview.update(ctx, &page, next_index);
323 }
324
325 let audience_page = state.audience_page();
326 if let Some(page) = cache.get(audience_page, audience_render_size) {
327 let page = page.clone();
328 self.audience_display.update(ctx, &page, audience_page);
329 }
330
331 egui::CentralPanel::default()
332 .frame(egui::Frame::new().fill(egui::Color32::from_gray(30)))
333 .show(ctx, |ui| {
334 const VSPLIT: f32 = 8.0;
335 const STATUS_H: f32 = 40.0;
336 const HSPLIT: f32 = 8.0;
337 let available = ui.available_rect_before_wrap();
338
339 let left_w = (available.width() * self.single_left_fraction - VSPLIT * 0.5)
341 .clamp(200.0, available.width() - 200.0 - VSPLIT);
342 let left_rect = egui::Rect::from_min_max(
343 available.min,
344 egui::pos2(available.min.x + left_w, available.max.y),
345 );
346 let vsplit_rect = egui::Rect::from_min_max(
347 egui::pos2(left_rect.max.x, available.min.y),
348 egui::pos2(left_rect.max.x + VSPLIT, available.max.y),
349 );
350 let right_rect = egui::Rect::from_min_max(
351 egui::pos2(vsplit_rect.max.x, available.min.y),
352 available.max,
353 );
354
355 let vsplit_id = ui.make_persistent_id("sm_vsplit");
357 let vsplit_resp =
358 ui.interact(vsplit_rect, vsplit_id, egui::Sense::click_and_drag());
359 if vsplit_resp.dragged()
360 && let Some(ptr) = vsplit_resp.interact_pointer_pos()
361 {
362 self.single_left_fraction =
363 ((ptr.x - available.left()) / available.width()).clamp(0.3, 0.85);
364 ui.ctx().request_repaint();
365 }
366 if vsplit_resp.hovered() || vsplit_resp.dragged() {
367 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
368 }
369 ui.painter().rect_filled(
370 vsplit_rect,
371 2.0,
372 if vsplit_resp.hovered() || vsplit_resp.dragged() {
373 SPLITTER_HOVER
374 } else {
375 SPLITTER_COLOR
376 },
377 );
378
379 ui.painter().rect_filled(left_rect, 0.0, egui::Color32::BLACK);
381 let zoom_region = if state.zoom_active {
382 state.zoom_region.as_ref().map(|r| (r.center, r.factor))
383 } else {
384 None
385 };
386 let mut left_ui = ui.new_child(egui::UiBuilder::new().max_rect(left_rect));
387 let aud_rect = self.audience_display.show(&mut left_ui, zoom_region);
388 let aud_response = left_ui.interact(
389 aud_rect,
390 egui::Id::new("sm_split_audience"),
391 egui::Sense::click_and_drag(),
392 );
393 self.input.handle_slide_mouse(
394 &aud_response,
395 aud_rect,
396 crate::input::ActiveAids {
397 ink: state.ink_active,
398 laser: state.laser_active,
399 spotlight: state.spotlight_active,
400 zoom: state.zoom_active,
401 },
402 state.zoom_region.as_ref().map(|r| r.factor),
403 );
404 let editing_id =
405 if state.text_box_editing { state.selected_text_box } else { None };
406 let tb_cmds = crate::widgets::draw_text_boxes(
407 &mut left_ui,
408 state.current_page_text_boxes(),
409 state.selected_text_box,
410 editing_id,
411 state.text_box_mode,
412 aud_rect,
413 &mut self.tb_cache,
414 &mut self.tb_texture_cache,
415 );
416 for cmd in tb_cmds {
417 let _ = sender.send(cmd);
418 }
419 crate::audience::overlays::draw_overlays(
420 &mut left_ui,
421 left_rect,
422 aud_rect,
423 state,
424 &mut self.tb_cache,
425 &mut self.tb_texture_cache,
426 false,
427 );
428
429 let content_h = (right_rect.height() - STATUS_H).max(0.0);
431 let next_h = (content_h * self.single_top_fraction - HSPLIT * 0.5).max(60.0);
432 let notes_h = (content_h - next_h - HSPLIT).max(40.0);
433
434 let next_rect = egui::Rect::from_min_size(
435 right_rect.min,
436 egui::vec2(right_rect.width(), next_h),
437 );
438 let hsplit_rect = egui::Rect::from_min_size(
439 egui::pos2(right_rect.min.x, next_rect.max.y),
440 egui::vec2(right_rect.width(), HSPLIT),
441 );
442 let notes_rect = egui::Rect::from_min_size(
443 egui::pos2(right_rect.min.x, hsplit_rect.max.y),
444 egui::vec2(right_rect.width(), notes_h),
445 );
446 let status_rect = egui::Rect::from_min_size(
447 egui::pos2(right_rect.min.x, right_rect.max.y - STATUS_H),
448 egui::vec2(right_rect.width(), STATUS_H),
449 );
450
451 let hsplit_id = ui.make_persistent_id("sm_hsplit");
453 let hsplit_resp =
454 ui.interact(hsplit_rect, hsplit_id, egui::Sense::click_and_drag());
455 if hsplit_resp.dragged()
456 && let Some(ptr) = hsplit_resp.interact_pointer_pos()
457 {
458 self.single_top_fraction =
459 ((ptr.y - right_rect.top()) / content_h.max(1.0)).clamp(0.15, 0.75);
460 ui.ctx().request_repaint();
461 }
462 if hsplit_resp.hovered() || hsplit_resp.dragged() {
463 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
464 }
465 ui.painter().rect_filled(
466 hsplit_rect,
467 2.0,
468 if hsplit_resp.hovered() || hsplit_resp.dragged() {
469 SPLITTER_HOVER
470 } else {
471 SPLITTER_COLOR
472 },
473 );
474
475 if let Some(_np) = next_page {
476 self.next_preview.show(ui, next_rect);
477 } else {
478 self.next_preview.show_empty(ui, next_rect);
479 }
480
481 self.notes.show(
482 ui,
483 notes_rect,
484 &NotesPanelView {
485 notes: state.current_notes.as_deref(),
486 font_size: state.notes_font_size,
487 visible: state.notes_visible,
488 editing: state.notes_editing,
489 },
490 sender,
491 );
492
493 if self.show_status_bar(ui, status_rect, state, sender) {
494 self.help.toggle();
495 }
496
497 if state.overview_visible {
498 self.overview.show(ctx, ui, state, cache, sender);
499 }
500
501 Self::show_quit_dialog(ui, state);
502 });
503 }
504
505 fn show_quit_dialog(ui: &mut egui::Ui, state: &PresentationState) {
506 if !state.quit_requested {
507 return;
508 }
509
510 let screen = ui.max_rect();
511 ui.painter().rect_filled(screen, 0.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180));
512
513 let dialog_size = egui::vec2(320.0, 120.0);
514 let dialog_rect = egui::Rect::from_center_size(screen.center(), dialog_size);
515 ui.painter().rect_filled(dialog_rect, 8.0, egui::Color32::from_gray(50));
516 ui.painter().rect_stroke(
517 dialog_rect,
518 8.0,
519 egui::Stroke::new(1.0, egui::Color32::GRAY),
520 egui::StrokeKind::Outside,
521 );
522
523 ui.painter().text(
524 dialog_rect.center_top() + egui::vec2(0.0, 25.0),
525 egui::Align2::CENTER_CENTER,
526 "Quit presentation?",
527 egui::FontId::proportional(18.0),
528 egui::Color32::WHITE,
529 );
530 ui.painter().text(
531 dialog_rect.center_top() + egui::vec2(0.0, 50.0),
532 egui::Align2::CENTER_CENTER,
533 "Press q or Escape to confirm",
534 egui::FontId::proportional(13.0),
535 egui::Color32::LIGHT_GRAY,
536 );
537 ui.painter().text(
538 dialog_rect.center_top() + egui::vec2(0.0, 75.0),
539 egui::Align2::CENTER_CENTER,
540 "Press any other key to cancel",
541 egui::FontId::proportional(12.0),
542 egui::Color32::from_gray(160),
543 );
544 }
545
546 fn handle_splitters(
547 &mut self,
548 ui: &mut egui::Ui,
549 available: egui::Rect,
550 layout: &PresenterLayout,
551 ) {
552 let vertical_id = ui.make_persistent_id("presenter_vertical_splitter");
553 let vertical_response =
554 ui.interact(layout.vertical_splitter, vertical_id, egui::Sense::click_and_drag());
555 if vertical_response.dragged()
556 && let Some(pointer) = vertical_response.interact_pointer_pos()
557 {
558 self.left_fraction = ((pointer.x - available.left()) / available.width())
559 .clamp(MIN_LEFT_FRACTION, MAX_LEFT_FRACTION);
560 ui.ctx().request_repaint();
561 }
562 if vertical_response.hovered() || vertical_response.dragged() {
563 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
564 }
565 ui.painter().rect_filled(
566 layout.vertical_splitter,
567 2.0,
568 if vertical_response.hovered() || vertical_response.dragged() {
569 SPLITTER_HOVER
570 } else {
571 SPLITTER_COLOR
572 },
573 );
574
575 let horizontal_id = ui.make_persistent_id("presenter_horizontal_splitter");
576 let horizontal_response =
577 ui.interact(layout.horizontal_splitter, horizontal_id, egui::Sense::click_and_drag());
578 if horizontal_response.dragged()
579 && let Some(pointer) = horizontal_response.interact_pointer_pos()
580 {
581 self.top_fraction = ((pointer.y - available.top())
582 / (available.height() - 40.0).max(1.0))
583 .clamp(MIN_TOP_FRACTION, MAX_TOP_FRACTION);
584 ui.ctx().request_repaint();
585 }
586 if horizontal_response.hovered() || horizontal_response.dragged() {
587 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
588 }
589 ui.painter().rect_filled(
590 layout.horizontal_splitter,
591 2.0,
592 if horizontal_response.hovered() || horizontal_response.dragged() {
593 SPLITTER_HOVER
594 } else {
595 SPLITTER_COLOR
596 },
597 );
598 }
599
600 #[allow(clippy::too_many_lines)]
601 fn show_status_bar(
602 &self,
603 ui: &mut egui::Ui,
604 area: egui::Rect,
605 state: &PresentationState,
606 sender: &CommandSender,
607 ) -> bool {
608 let mut help_clicked = false;
609 let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(area));
610
611 child_ui.painter().rect_filled(area, 0.0, egui::Color32::from_gray(20));
613
614 child_ui.scope_builder(egui::UiBuilder::new().max_rect(area.shrink(4.0)), |ui| {
615 ui.horizontal(|ui| {
616 let slide_text = format!(
618 "Slide {}/{}",
619 state.current_logical_slide + 1,
620 state.total_logical_slides,
621 );
622
623 let group = state.slide_groups.get(state.current_logical_slide);
624 let overlay_text = if let Some(g) = group {
625 if g.pages.len() > 1 {
626 format!(
627 " (step {}/{})",
628 state.current_overlay_within_group + 1,
629 g.pages.len()
630 )
631 } else {
632 String::new()
633 }
634 } else {
635 String::new()
636 };
637
638 ui.label(
639 egui::RichText::new(format!("{slide_text}{overlay_text}"))
640 .size(14.0)
641 .color(egui::Color32::WHITE),
642 );
643
644 ui.separator();
645
646 if timer::show_timer(ui, &state.timer) {
648 let _ = sender.send(dais_core::commands::Command::ToggleTimer);
649 }
650
651 ui.separator();
652
653 timer::show_slide_timer(ui, state.slide_elapsed);
654
655 ui.separator();
656
657 let mut indicators = Vec::new();
659 if state.frozen {
660 indicators.push(("[F]rozen", egui::Color32::LIGHT_BLUE));
661 }
662 if state.blacked_out {
663 indicators.push(("[B]lack", egui::Color32::YELLOW));
664 }
665 if state.screen_share_mode {
666 indicators.push(("[S]creen-share", egui::Color32::LIGHT_GREEN));
667 }
668 if state.laser_active {
669 indicators.push(("[L]aser", egui::Color32::RED));
670 }
671 if state.ink_active {
672 indicators.push(("[D]raw", egui::Color32::from_rgb(255, 165, 0)));
673 }
674 if state.spotlight_active {
675 indicators.push(("Spotlight", egui::Color32::LIGHT_YELLOW));
676 }
677 if state.zoom_active {
678 indicators.push(("[Z]oom", egui::Color32::LIGHT_GREEN));
679 }
680 if state.text_box_mode {
681 indicators.push(("[X]Text", egui::Color32::from_rgb(180, 130, 255)));
682 }
683
684 for (text, color) in indicators {
685 ui.colored_label(color, egui::RichText::new(text).size(12.0));
686 }
687 if state.ink_active {
688 let p = state.active_pen.color;
689 let swatch = egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
690 let gray = egui::Color32::from_gray(180);
691 ui.colored_label(swatch, egui::RichText::new("■").size(12.0));
692 ui.colored_label(
693 gray,
694 egui::RichText::new(format!("{}px", state.active_pen.width)).size(11.0),
695 );
696 }
697
698 if self.input.mode() == crate::input::InputMode::JumpToSlide {
700 let buf = self.input.jump_buffer();
701 ui.colored_label(
702 egui::Color32::YELLOW,
703 egui::RichText::new(format!("Go to: {buf}_")).size(14.0),
704 );
705 }
706
707 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
709 if ui
710 .add(
711 egui::Button::new(
712 egui::RichText::new("?")
713 .size(15.0)
714 .color(egui::Color32::WHITE)
715 .strong(),
716 )
717 .fill(egui::Color32::from_gray(50))
718 .corner_radius(4.0)
719 .min_size(egui::vec2(26.0, 26.0)),
720 )
721 .on_hover_text("Keyboard shortcuts")
722 .clicked()
723 {
724 help_clicked = true;
725 }
726 });
727 });
728 });
729
730 help_clicked
731 }
732
733 fn question_mark_pressed(ctx: &egui::Context) -> bool {
735 ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "?")))
736 }
737}