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::{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
41pub 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 pub fn input_mut(&mut self) -> &mut InputHandler {
81 &mut self.input
82 }
83
84 #[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 let help_consumed = self.help.show(ctx, self.input.keybindings());
100
101 let notes_editing = state.notes_editing;
103 if !help_consumed && !notes_editing && Self::question_mark_pressed(ctx) {
104 self.help.toggle();
105 }
106
107 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 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 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 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 if state.whiteboard_active {
176 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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}