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 if !state.whiteboard_active {
180 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 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 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 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 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 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 if self.show_status_bar(ui, layout.status_bar, state, sender) {
265 self.help.toggle();
266 }
267
268 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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}