1use std::cell::RefCell;
2use std::rc::Rc;
3
4use blockworx_geom::{Pos2, Rangef, Rect, Vec2, pos2};
5use blockworx_paint::{Canvas as _, Color, Cursor, Event, Interaction, Palette, Zoom, ZoomStep};
6use blockworx_store::record::Camera as Recorded;
7use egui::{PointerButton, Sense, Stroke};
8
9use super::egui_compat::{IntoEgui as _, IntoGeom};
10use super::interaction::compute_interaction;
11use super::painter::Painter;
12use crate::canvas::image::ImageRegistry;
13use crate::grid::GRID_SIZE;
14use crate::icons::Icons;
15const FRAME_MAX_ZOOM: f32 = 2.0;
21
22const FIT_BORDER_CELLS: f32 = 4.0;
25
26const CAMERA_SLACK: f32 = 0.125;
31
32const WHEEL_ZOOM_MIN: Zoom = Zoom::new(0.25);
35const WHEEL_ZOOM_MAX: Zoom = Zoom::new(4.0);
36
37const SCROLL_ZOOM_RATE: f32 = 0.002;
39
40const MIN_MINOR_GRID_SPACING: f32 = 5.0;
43
44const MAJOR_GRID_STEP: i32 = 4;
46
47#[derive(Clone, Copy, PartialEq, Eq)]
49enum GridLine {
50 Major,
51 Minor,
52}
53
54impl GridLine {
55 fn at(index: i32) -> Self {
56 if index % MAJOR_GRID_STEP == 0 {
57 Self::Major
58 } else {
59 Self::Minor
60 }
61 }
62
63 fn stroke(self, color: Color) -> Stroke {
64 match self {
65 Self::Major => Stroke::new(1.0, color.egui()),
66 Self::Minor => Stroke::new(0.5, color.egui()),
67 }
68 }
69}
70
71fn frame_zoom(viewport: Vec2, content: Vec2, max_zoom: Zoom) -> Zoom {
75 let zoom = (viewport.x / content.x).min(viewport.y / content.y);
76 Zoom::new(zoom.min(max_zoom.get()))
77}
78
79#[derive(Clone, Copy)]
83pub struct CanvasChrome {
84 pub background: Color,
85 pub grid: Color,
86}
87
88#[derive(Clone, Copy, Default)]
93struct EditorFeedback {
94 lost_focus: bool,
95 enter: bool,
96 tab: bool,
97 escape: bool,
98}
99
100impl EditorFeedback {
101 fn merge_into(self, interaction: &mut Interaction) {
102 interaction.lost_focus |= self.lost_focus;
103 interaction.enter_pressed |= self.enter;
104 interaction.tab_pressed |= self.tab;
105 interaction.escape_pressed |= self.escape;
106 }
107}
108
109#[derive(Clone, Copy, PartialEq, Debug)]
116pub struct Vantage {
117 pub zoom: Zoom,
118 pub translation: Vec2,
119}
120
121impl Vantage {
122 pub fn recorded(self, viewport: Vec2) -> Recorded {
129 let centre = (viewport / 2.0 - self.translation) / self.zoom.get();
130 Recorded {
131 x: centre.x,
132 y: centre.y,
133 zoom: self.zoom.get(),
134 }
135 }
136
137 pub fn of_recorded(camera: Recorded, viewport: Vec2) -> Self {
139 let zoom = Zoom::new(camera.zoom);
140 Self {
141 zoom,
142 translation: viewport / 2.0 - Vec2::new(camera.x, camera.y) * zoom.get(),
143 }
144 }
145}
146
147#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155pub enum Camera {
156 Moving,
157 Settled,
158}
159
160fn pan_key_held(ui: &egui::Ui) -> bool {
164 !ui.ctx().egui_wants_keyboard_input() && ui.input(|i| i.key_down(egui::Key::Space))
165}
166
167pub struct View {
168 pub zoom: Zoom,
169 pub translation: Vec2,
170 editor_feedback: EditorFeedback,
171 last_edit_id: Option<egui::Id>,
174 last_mouse_down: Option<Pos2>,
175 viewport: Rect,
178 safe: Rect,
184 hovered: bool,
188 target_view: Option<(Zoom, Vec2)>,
192 primary_drag_pans: bool,
196 touch_pans: bool,
201 camera: Camera,
204 worked: bool,
208 images: Rc<RefCell<ImageRegistry>>,
211 icons: Icons,
214}
215
216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
218pub enum Framing {
219 Animated,
220 Immediate,
221}
222
223const VIEW_ANIMATION_RATE: f32 = 16.0;
225
226fn overshoot(lo: f32, hi: f32, visible_lo: f32, visible_hi: f32) -> f32 {
230 if lo < visible_lo {
231 lo - visible_lo
232 } else if hi > visible_hi {
233 hi - visible_hi
234 } else {
235 0.0
236 }
237}
238
239impl View {
240 pub fn new() -> Self {
241 Self {
242 zoom: Zoom::unity(),
243 translation: Vec2::ZERO,
244 editor_feedback: EditorFeedback::default(),
245 last_edit_id: None,
246 last_mouse_down: None,
247 viewport: Rect::ZERO,
248 safe: Rect::NOTHING,
249 hovered: false,
250 target_view: None,
251 primary_drag_pans: false,
252 touch_pans: false,
253 camera: Camera::Settled,
254 worked: false,
255 images: Rc::new(RefCell::new(ImageRegistry::default())),
256 icons: Icons::default(),
257 }
258 }
259
260 pub fn register_icons(&mut self, ctx: &egui::Context) {
262 self.icons = Icons::register(ctx, &mut self.images.borrow_mut());
263 }
264
265 pub fn viewport(&self) -> Rect {
268 self.viewport
269 }
270
271 pub fn set_safe_region(&mut self, region: Rect) {
276 self.safe = region;
277 }
278
279 fn framing_rect(&self) -> Rect {
282 if self.safe.is_positive() {
283 self.safe
284 } else {
285 self.viewport
286 }
287 }
288
289 pub fn canvas_hovered(&self) -> bool {
293 self.hovered
294 }
295
296 pub fn viewport_size(&self) -> Vec2 {
299 self.viewport.size()
300 }
301
302 pub fn zoom(&self) -> Zoom {
305 self.zoom
306 }
307
308 pub fn camera(&self) -> Camera {
311 self.camera
312 }
313
314 pub fn vantage(&self) -> Vantage {
316 Vantage {
317 zoom: self.zoom,
318 translation: self.translation,
319 }
320 }
321
322 pub fn stand_at(&mut self, vantage: Vantage) {
327 self.zoom = vantage.zoom;
328 self.translation = vantage.translation;
329 self.target_view = None;
330 }
331
332 pub fn worked_camera(&self) -> bool {
335 self.worked
336 }
337
338 pub fn focused_edit_id(&self) -> Option<egui::Id> {
341 self.last_edit_id
342 }
343
344 pub fn fit_to_rect_instant(&mut self, content: Rect) {
351 self.frame_rect(
352 content.expand2(content.size() * CAMERA_SLACK),
353 Zoom::max_value(),
354 Framing::Immediate,
355 );
356 }
357
358 pub fn fit_content(&mut self, content: Rect) {
363 self.frame_rect(
364 content.expand(FIT_BORDER_CELLS * GRID_SIZE),
365 Zoom::new(FRAME_MAX_ZOOM),
366 Framing::Immediate,
367 );
368 }
369
370 pub fn focus_on(&mut self, content: Rect, framing: Framing) {
376 self.frame_rect(
377 content.expand2(content.size() * CAMERA_SLACK),
378 Zoom::new(FRAME_MAX_ZOOM),
379 framing,
380 );
381 }
382
383 pub fn bring_into_view(&mut self, content: Rect, framing: Framing) {
394 if !content.is_positive() || !self.viewport.is_positive() {
395 return;
396 }
397 let visible = self.visible_world_rect();
398 if visible.contains_rect(content) {
399 return;
400 }
401 let fits = |r: Rect| r.width() <= visible.width() && r.height() <= visible.height();
402 let gutter = content.expand(FIT_BORDER_CELLS * GRID_SIZE);
403 let target = if fits(gutter) { gutter } else { content };
404 if !fits(target) {
405 self.focus_on(content, framing);
406 return;
407 }
408 let shift = Vec2::new(
409 overshoot(target.min.x, target.max.x, visible.min.x, visible.max.x),
410 overshoot(target.min.y, target.max.y, visible.min.y, visible.max.y),
411 );
412 let panned = self.translation - shift * self.zoom.get();
413 match framing {
414 Framing::Animated => self.target_view = Some((self.zoom, panned)),
415 Framing::Immediate => {
416 self.translation = panned;
417 self.target_view = None;
418 }
419 }
420 }
421
422 fn frame_rect(&mut self, content: Rect, max_zoom: Zoom, framing: Framing) {
423 if !content.is_positive() || !self.viewport.is_positive() {
424 return;
425 }
426 let within = self.framing_rect();
429 let zoom = frame_zoom(within.size(), content.size(), max_zoom);
430 let translation =
433 (within.center() - self.viewport.min) - content.center().to_vec2() * zoom.get();
434 if framing == Framing::Animated {
435 self.target_view = Some((zoom, translation));
437 } else {
438 self.zoom = zoom;
439 self.translation = translation;
440 self.target_view = None;
441 }
442 }
443
444 fn animate_view(&mut self, ctx: &egui::Context) {
449 let Some((zoom, translation)) = self.target_view else {
450 return;
451 };
452 let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
453 let t = 1.0 - (-VIEW_ANIMATION_RATE * dt).exp();
454 self.zoom = Zoom::new(self.zoom.get() + (zoom.get() - self.zoom.get()) * t);
455 self.translation += (translation - self.translation) * t;
456 if (self.zoom.get() - zoom.get()).abs() < 1e-3
457 && (self.translation - translation).length() < 0.5
458 {
459 self.zoom = zoom;
460 self.translation = translation;
461 self.target_view = None;
462 } else {
463 ctx.request_repaint();
464 }
465 }
466 pub fn world_to_screen(&self, origin: Pos2, world: Pos2) -> Pos2 {
467 origin + self.translation + world.to_vec2() * self.zoom.get()
468 }
469
470 pub fn screen_to_world_pos(&self, screen: Pos2) -> Pos2 {
473 self.screen_to_world(self.viewport.min, screen)
474 }
475
476 fn screen_to_world(&self, origin: Pos2, screen: Pos2) -> Pos2 {
477 ((screen - origin - self.translation) / self.zoom.get()).to_pos2()
478 }
479
480 pub fn visible_world_rect(&self) -> Rect {
482 Rect::from_min_max(
483 self.screen_to_world(self.viewport.min, self.viewport.min),
484 self.screen_to_world(self.viewport.min, self.viewport.max),
485 )
486 }
487
488 pub fn visible_world_center(&self) -> Pos2 {
494 if !self.viewport.is_positive() {
495 return (-self.translation / self.zoom.get()).to_pos2();
496 }
497 self.screen_to_world(self.viewport.min, self.viewport.center())
498 }
499
500 pub fn begin<'a>(
507 &'a mut self,
508 ui: &'a mut egui::Ui,
509 palette: Palette,
510 chrome: CanvasChrome,
511 ) -> Canvas<'a> {
512 let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
513 let rect = rect.geom();
514 self.viewport = rect;
515 self.hovered = response.hovered();
516
517 self.animate_view(ui.ctx());
519 self.worked = false;
520 let pan = self.handle_pan_zoom(ui, &response, rect);
521 self.camera = pan;
522
523 if let Some(pos) = response.interact_pointer_pos() {
524 if self.last_mouse_down.is_none() {
525 self.last_mouse_down = Some(pos.geom());
526 }
527 } else {
528 self.last_mouse_down = None;
529 }
530
531 Canvas {
532 view: self,
533 ui,
534 palette,
535 chrome,
536 rect,
537 response,
538 pan,
539 }
540 }
541
542 fn handle_pan_zoom(&mut self, ui: &egui::Ui, response: &egui::Response, rect: Rect) -> Camera {
547 if let Some(pan) = self.handle_touch_gesture(ui, response, rect) {
548 return pan;
549 }
550 if response.hovered() {
554 let cursor = ui
555 .ctx()
556 .pointer_hover_pos()
557 .map_or(rect.center(), IntoGeom::geom);
558 let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
564 let factor = (scroll * SCROLL_ZOOM_RATE).exp() * pinch;
565 if factor != 1.0 {
566 self.zoom_about(factor, cursor, rect);
567 }
568 }
569
570 if response.drag_started() {
574 self.primary_drag_pans = pan_key_held(ui);
575 }
576 let primary_pan = self.primary_drag_pans
579 && (response.dragged_by(PointerButton::Primary) || response.drag_stopped());
580 if response.drag_stopped() {
581 self.primary_drag_pans = false;
582 }
583 if primary_pan
584 || response.dragged_by(PointerButton::Secondary)
585 || response.dragged_by(PointerButton::Middle)
586 {
587 self.target_view = None;
588 self.translation += response.drag_delta().geom();
589 self.worked = true;
590 Camera::Moving
591 } else {
592 Camera::Settled
593 }
594 }
595
596 fn handle_touch_gesture(
604 &mut self,
605 ui: &egui::Ui,
606 response: &egui::Response,
607 rect: Rect,
608 ) -> Option<Camera> {
609 let (gesture, touching) = ui.input(|i| (i.multi_touch(), i.any_touches()));
610 if !touching {
611 self.touch_pans = false;
612 return None;
613 }
614 let ours = self.touch_pans || response.hovered() || response.is_pointer_button_down_on();
617 let Some(gesture) = gesture.filter(|_| ours) else {
618 return self.touch_pans.then_some(Camera::Moving);
621 };
622 self.touch_pans = true;
623 self.target_view = None;
624 self.worked = true;
625 self.translation += gesture.translation_delta.geom();
626 if gesture.zoom_delta != 1.0 {
627 self.zoom_about(gesture.zoom_delta, gesture.center_pos.geom(), rect);
628 }
629 Some(Camera::Moving)
630 }
631
632 fn zoom_about(&mut self, factor: f32, anchor: Pos2, rect: Rect) {
637 self.target_view = None;
638 self.worked = true;
639 let new_zoom = Zoom::new(self.zoom.get() * factor).clamp(WHEEL_ZOOM_MIN, WHEEL_ZOOM_MAX);
640 let from_corner = anchor.to_vec2() - rect.min.to_vec2();
641 let world_offset = from_corner - self.translation;
642 self.translation = from_corner - world_offset * (new_zoom.get() / self.zoom.get());
643 self.zoom = new_zoom;
644 }
645
646 pub fn zoom_step(&mut self, step: ZoomStep, anchor: Option<Pos2>) {
649 if !self.viewport.is_positive() {
650 return;
651 }
652 let anchor = anchor
653 .filter(|p| self.viewport.contains(*p))
654 .unwrap_or_else(|| self.viewport.center());
655 self.zoom_about(step.factor(), anchor, self.viewport);
656 }
657
658 fn draw_grid(&self, painter: &egui::Painter, rect: Rect, chrome: CanvasChrome) {
662 painter.rect_filled(rect.egui(), 0.0, chrome.background.egui());
663
664 let origin = rect.min;
665 let world = Rect::from_min_max(
666 self.screen_to_world(origin, rect.min),
667 self.screen_to_world(origin, rect.max),
668 );
669 for (x, line) in self.grid_lines(world.x_range()) {
670 let sx = self.world_to_screen(origin, pos2(x, 0.0)).x;
671 painter.vline(sx, rect.y_range().egui(), line.stroke(chrome.grid));
672 }
673 for (y, line) in self.grid_lines(world.y_range()) {
674 let sy = self.world_to_screen(origin, pos2(0.0, y)).y;
675 painter.hline(rect.x_range().egui(), sy, line.stroke(chrome.grid));
676 }
677 }
678
679 fn grid_lines(&self, span: Rangef) -> impl Iterator<Item = (f32, GridLine)> {
682 let draw_minor = GRID_SIZE * self.zoom.get() >= MIN_MINOR_GRID_SPACING;
683 ((span.min / GRID_SIZE).floor() as i32..=(span.max / GRID_SIZE).ceil() as i32)
684 .map(|i| (i as f32 * GRID_SIZE, GridLine::at(i)))
685 .filter(move |&(_, line)| line == GridLine::Major || draw_minor)
686 }
687
688 fn render_pending_edit(&mut self, ui: &mut egui::Ui, painter: &mut Painter) -> EditorFeedback {
692 let Some(edit) = painter.take_edit_text() else {
693 self.last_edit_id = None;
694 return EditorFeedback::default();
695 };
696 let edit_id = edit.id.egui();
697 let screen_rect = painter.remap_rect(edit.position);
698 let screen_font = painter.remap_font(&edit.font).egui();
699 let mut buffer = edit.buffer.borrow_mut();
700 let layout_font = screen_font.clone();
706 let hint_font = screen_font.clone();
707 let text_color = edit
708 .colors
709 .map(|c| c.text.egui())
710 .or_else(|| ui.visuals().override_text_color)
711 .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
712 let wrap_px = edit.wrap_width.map(|w| w.get() * self.zoom.get());
713 let mut wrap_at = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _avail: f32| {
714 let job = egui::text::LayoutJob::simple(
715 buf.as_str().to_owned(),
716 layout_font.clone(),
717 text_color,
718 wrap_px.unwrap_or(f32::INFINITY),
719 );
720 ui.fonts_mut(|f| f.layout_job(job))
721 };
722 let mut widget = if edit.multiline {
723 egui::TextEdit::multiline(&mut *buffer)
724 } else {
725 egui::TextEdit::singleline(&mut *buffer)
726 }
727 .id(edit_id)
728 .desired_width(f32::INFINITY)
729 .font(screen_font);
730 if edit.wrap_width.is_some() {
731 widget = widget.layouter(&mut wrap_at);
732 }
733 if let Some(limit) = edit.char_limit {
734 widget = widget.char_limit(limit);
735 }
736 if let Some(hint) = edit.hint {
737 widget = widget.hint_text(egui::RichText::new(hint).font(hint_font));
741 }
742 if let Some(c) = edit.colors {
743 widget = widget
745 .text_color(c.text.egui())
746 .background_color(c.background.egui());
747 }
748 let resp = ui.place(screen_rect.egui(), widget);
749 if edit.tab_cycle {
756 ui.memory_mut(|m| {
757 m.set_focus_lock_filter(
758 edit_id,
759 egui::EventFilter {
760 tab: true,
761 escape: true,
762 ..Default::default()
763 },
764 );
765 });
766 }
767 if self.last_edit_id != Some(edit_id) {
771 resp.request_focus();
772 if edit.tab_cycle || edit.select_all_on_focus {
773 let mut state =
776 egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default();
777 let chars = buffer.chars().count();
778 state
779 .cursor
780 .set_char_range(Some(egui::text::CCursorRange::two(
781 egui::text::CCursor::new(0),
782 egui::text::CCursor::new(chars),
783 )));
784 state.store(ui.ctx(), edit_id);
785 }
786 self.last_edit_id = Some(edit_id);
787 }
788 let (tab, escape) = if edit.tab_cycle && resp.has_focus() {
791 ui.input_mut(|i| {
792 (
793 i.consume_key(egui::Modifiers::NONE, egui::Key::Tab),
794 i.consume_key(egui::Modifiers::NONE, egui::Key::Escape),
795 )
796 })
797 } else {
798 (false, false)
799 };
800 EditorFeedback {
801 lost_focus: resp.lost_focus(),
802 enter: resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)),
803 tab,
804 escape,
805 }
806 }
807}
808
809pub struct Canvas<'a> {
817 view: &'a mut View,
818 ui: &'a mut egui::Ui,
819 palette: Palette,
820 chrome: CanvasChrome,
821 rect: Rect,
822 response: egui::Response,
823 pan: Camera,
824}
825
826impl Canvas<'_> {
827 pub fn fit_to(&mut self, measure: impl FnOnce(&mut Painter) -> Option<Rect>) {
831 let mut painter = Painter::new(
832 self.ui.painter().with_clip_rect(Rect::NOTHING.egui()),
833 self.rect.min,
834 self.view.zoom,
835 self.view.translation,
836 self.palette.clone(),
837 self.view.images.clone(),
838 self.view.icons.clone(),
839 );
840 if let Some(content) = measure(&mut painter) {
841 self.view.fit_content(content);
842 }
843 }
844
845 pub fn paint<F>(self, f: F)
848 where
849 F: FnOnce(Interaction, &mut Painter),
850 {
851 let Canvas {
852 view,
853 ui,
854 palette,
855 chrome,
856 rect,
857 response,
858 pan,
859 } = self;
860 let origin = rect.min;
861 let egui_painter = ui.painter().with_clip_rect(rect.egui());
862 view.draw_grid(&egui_painter, rect, chrome);
863
864 let mut interaction = compute_interaction(
866 &response,
867 |p| view.screen_to_world(origin, p),
868 view.zoom,
869 ui,
870 );
871 if let Some(Event::DragStarted { pos }) = interaction.event.as_mut()
872 && let Some(last) = view.last_mouse_down
873 {
874 *pos = view.screen_to_world(origin, last);
875 }
876
877 if pan == Camera::Moving {
878 if matches!(
879 interaction.event,
880 Some(
881 Event::DragStarted { .. } | Event::Dragging { .. } | Event::DragStopped { .. }
882 ),
883 ) {
884 interaction.event = None;
885 }
886 interaction.press = None;
889 }
890
891 std::mem::take(&mut view.editor_feedback).merge_into(&mut interaction);
892
893 let mut painter = Painter::new(
895 egui_painter,
896 origin,
897 view.zoom,
898 view.translation,
899 palette,
900 view.images.clone(),
901 view.icons.clone(),
902 );
903 f(interaction, &mut painter);
904
905 if pan == Camera::Moving {
908 painter.set_cursor(Cursor::Grabbing);
909 } else if view.hovered && pan_key_held(ui) {
910 painter.set_cursor(Cursor::Grab);
911 }
912
913 view.editor_feedback = view.render_pending_edit(ui, &mut painter);
914 }
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920 use blockworx_paint::{EditId, EditText, Font};
921
922 #[test]
926 fn a_zoom_step_holds_the_world_under_its_anchor() {
927 let mut view = View::new();
928 view.viewport = Rect::from_min_size(pos2(30.0, 20.0), Vec2::new(800.0, 600.0));
929 view.translation = Vec2::new(17.0, -9.0);
930 let anchor = pos2(240.0, 380.0);
931 let origin = view.viewport.min;
932 let before = view.screen_to_world(origin, anchor);
933
934 view.zoom_step(ZoomStep::In, Some(anchor));
935 assert!(
936 view.zoom > Zoom::unity(),
937 "zooming in should raise the zoom"
938 );
939 let after = view.screen_to_world(origin, anchor);
940 assert!(
941 before.distance(after) < 0.01,
942 "the world under the cursor moved: {before:?} -> {after:?}"
943 );
944
945 view.zoom_step(ZoomStep::Out, Some(anchor));
947 assert!((view.zoom.get() - 1.0).abs() < 1e-5, "{:?}", view.zoom);
948 }
949
950 #[test]
953 fn a_zoom_step_without_a_pointer_uses_the_viewport_center() {
954 let mut view = View::new();
955 view.viewport = Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(800.0, 600.0));
956 let origin = view.viewport.min;
957 let center = view.viewport.center();
958 let before = view.screen_to_world(origin, center);
959 view.zoom_step(ZoomStep::In, Some(pos2(-50.0, -50.0)));
961 assert!(before.distance(view.screen_to_world(origin, center)) < 0.01);
962 }
963
964 #[test]
968 fn bringing_content_into_view_moves_as_little_as_it_can() {
969 let settled = |view: &mut View| {
970 if let Some((zoom, translation)) = view.target_view.take() {
971 view.zoom = zoom;
972 view.translation = translation;
973 }
974 view.visible_world_rect()
975 };
976 let mut view = View::new();
977 view.viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
978 let visible = view.visible_world_rect();
979 assert!(
980 visible.width() > 0.0 && visible.height() > 0.0,
981 "precondition: the view shows something to be outside of",
982 );
983
984 let inside = Rect::from_min_size(pos2(100.0, 100.0), Vec2::splat(50.0));
985 assert!(visible.contains_rect(inside), "precondition: already shown");
986 view.bring_into_view(inside, Framing::Animated);
987 assert!(
988 view.target_view.is_none(),
989 "content already in view moved the camera",
990 );
991
992 let beside = Rect::from_min_size(pos2(900.0, 100.0), Vec2::splat(50.0));
993 assert!(
994 !visible.contains_rect(beside) && beside.width() < visible.width(),
995 "precondition: outside the view, and small enough to pan to",
996 );
997 let zoom_before = view.zoom.get();
998 view.bring_into_view(beside, Framing::Animated);
999 assert_eq!(
1000 view.target_view.expect("the camera moves").0.get(),
1001 zoom_before,
1002 "a pan must not touch the zoom",
1003 );
1004 assert!(settled(&mut view).contains_rect(beside));
1005
1006 let mut view = View::new();
1007 view.viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
1008 let huge = Rect::from_min_size(pos2(400.0, 0.0), Vec2::new(2000.0, 300.0));
1009 assert!(
1010 huge.width() > view.visible_world_rect().width(),
1011 "precondition: no pan can hold this at the current zoom",
1012 );
1013 view.bring_into_view(huge, Framing::Animated);
1014 assert!(
1015 view.target_view.expect("the camera moves").0.get() < view.zoom.get(),
1016 "content too wide for the view must be zoomed out to",
1017 );
1018 assert!(settled(&mut view).contains_rect(huge));
1019 }
1020
1021 #[test]
1024 fn a_content_fit_leaves_a_border_and_caps_the_zoom() {
1025 let mut view = View::new();
1026 view.viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
1027 let content = Rect::from_min_size(pos2(100.0, 100.0), Vec2::new(1600.0, 400.0));
1030 view.fit_content(content);
1031 assert!(
1032 view.zoom.get() < FRAME_MAX_ZOOM,
1033 "the cap should not bind here"
1034 );
1035 let origin = view.viewport.min;
1036 let left = view.world_to_screen(origin, content.min).x;
1037 let right = view.world_to_screen(origin, content.max).x;
1038 let gutter = FIT_BORDER_CELLS * GRID_SIZE * view.zoom.get();
1039 assert!(
1040 left - view.viewport.left() >= gutter - 0.5,
1041 "content starts {left} with only {} of gutter",
1042 left - view.viewport.left()
1043 );
1044 assert!(view.viewport.right() - right >= gutter - 0.5);
1045
1046 view.fit_content(Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(10.0, 10.0)));
1048 assert_eq!(view.zoom, Zoom::new(FRAME_MAX_ZOOM));
1049 }
1050
1051 #[test]
1052 fn focus_zoom_caps_small_content_but_fit_zooms_in_further() {
1053 let viewport = Vec2::new(800.0, 600.0);
1054 let tiny = Vec2::new(10.0, 10.0);
1055 let focus_cap = Zoom::new(FRAME_MAX_ZOOM);
1056 assert_eq!(frame_zoom(viewport, tiny, focus_cap), focus_cap);
1058 assert!(frame_zoom(viewport, tiny, Zoom::max_value()) > focus_cap);
1060 }
1061
1062 fn frame(ctx: &egui::Context, view: &mut View, events: Vec<egui::Event>) -> Option<Event> {
1065 let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
1066 let input = egui::RawInput {
1067 screen_rect: Some(screen.egui()),
1068 events,
1069 ..Default::default()
1070 };
1071 let mut seen = None;
1072 let chrome = CanvasChrome {
1073 background: Color::BLACK,
1074 grid: Color::GRAY,
1075 };
1076 ctx.run_ui(input, |ui| {
1077 view.begin(ui, Palette::tokyo_night_moon(), chrome)
1078 .paint(|interaction, _painter| {
1079 seen = interaction.event;
1080 });
1081 })
1082 .drop_without_applying_deltas();
1083 seen
1084 }
1085
1086 fn key_down(key: egui::Key) -> egui::Event {
1087 egui::Event::Key {
1088 key,
1089 physical_key: None,
1090 pressed: true,
1091 repeat: false,
1092 modifiers: egui::Modifiers::default(),
1093 }
1094 }
1095
1096 fn key_up(key: egui::Key) -> egui::Event {
1097 egui::Event::Key {
1098 key,
1099 physical_key: None,
1100 pressed: false,
1101 repeat: false,
1102 modifiers: egui::Modifiers::default(),
1103 }
1104 }
1105
1106 fn press(pos: Pos2) -> egui::Event {
1109 egui::Event::PointerButton {
1110 pos: pos.egui(),
1111 button: PointerButton::Primary,
1112 pressed: true,
1113 modifiers: egui::Modifiers::default(),
1114 }
1115 }
1116
1117 #[test]
1120 fn space_plus_left_drag_pans_instead_of_reaching_the_tool() {
1121 let ctx = egui::Context::default();
1122 let mut view = View::new();
1123 let start = pos2(400.0, 300.0);
1124 frame(&ctx, &mut view, vec![key_down(egui::Key::Space)]);
1125 frame(
1126 &ctx,
1127 &mut view,
1128 vec![egui::Event::PointerMoved(start.egui())],
1129 );
1130 frame(&ctx, &mut view, vec![press(start)]);
1131 let before = view.translation;
1132 let seen = frame(
1133 &ctx,
1134 &mut view,
1135 vec![egui::Event::PointerMoved(
1136 (start + Vec2::new(60.0, -25.0)).egui(),
1137 )],
1138 );
1139 assert_eq!(view.translation - before, Vec2::new(60.0, -25.0));
1140 assert!(
1141 !matches!(
1142 seen,
1143 Some(Event::DragStarted { .. } | Event::Dragging { .. })
1144 ),
1145 "the pan leaked to the tool as {seen:?}"
1146 );
1147 }
1148
1149 #[test]
1152 fn a_plain_left_drag_is_left_to_the_tool() {
1153 let ctx = egui::Context::default();
1154 let mut view = View::new();
1155 let start = pos2(400.0, 300.0);
1156 frame(
1157 &ctx,
1158 &mut view,
1159 vec![egui::Event::PointerMoved(start.egui())],
1160 );
1161 frame(&ctx, &mut view, vec![press(start)]);
1162 let before = view.translation;
1163 let seen = frame(
1164 &ctx,
1165 &mut view,
1166 vec![egui::Event::PointerMoved(
1167 (start + Vec2::new(60.0, -25.0)).egui(),
1168 )],
1169 );
1170 assert_eq!(view.translation, before, "a plain drag must not pan");
1171 assert!(
1172 matches!(
1173 seen,
1174 Some(Event::DragStarted { .. } | Event::Dragging { .. })
1175 ),
1176 "the tool never saw the drag ({seen:?})"
1177 );
1178 }
1179
1180 #[test]
1183 fn releasing_the_key_mid_drag_keeps_panning() {
1184 let ctx = egui::Context::default();
1185 let mut view = View::new();
1186 let start = pos2(400.0, 300.0);
1187 frame(&ctx, &mut view, vec![key_down(egui::Key::Space)]);
1188 frame(
1189 &ctx,
1190 &mut view,
1191 vec![egui::Event::PointerMoved(start.egui())],
1192 );
1193 frame(&ctx, &mut view, vec![press(start)]);
1194 let mut at = start + Vec2::new(30.0, 0.0);
1195 frame(&ctx, &mut view, vec![egui::Event::PointerMoved(at.egui())]);
1196 let before = view.translation;
1197 at += Vec2::new(20.0, 10.0);
1198 let seen = frame(
1199 &ctx,
1200 &mut view,
1201 vec![
1202 key_up(egui::Key::Space),
1203 egui::Event::PointerMoved(at.egui()),
1204 ],
1205 );
1206 assert_eq!(view.translation - before, Vec2::new(20.0, 10.0));
1207 assert!(!matches!(seen, Some(Event::Dragging { .. })), "{seen:?}");
1208 }
1209
1210 fn touch(id: u64, phase: egui::TouchPhase, pos: Pos2) -> egui::Event {
1213 egui::Event::Touch {
1214 device_id: egui::TouchDeviceId(0),
1215 id: egui::TouchId(id),
1216 phase,
1217 pos: pos.egui(),
1218 force: None,
1219 }
1220 }
1221
1222 fn two_fingers(phase: egui::TouchPhase, at: [Pos2; 2]) -> Vec<egui::Event> {
1226 vec![
1227 egui::Event::PointerMoved(at[0].egui()),
1228 touch(0, phase, at[0]),
1229 touch(1, phase, at[1]),
1230 ]
1231 }
1232
1233 fn start_two_finger_gesture(ctx: &egui::Context, view: &mut View, at: [Pos2; 2]) {
1238 frame(ctx, view, Vec::new());
1239 let mut down = two_fingers(egui::TouchPhase::Start, at);
1240 down.push(press(at[0]));
1241 frame(ctx, view, down);
1242 frame(ctx, view, two_fingers(egui::TouchPhase::Move, at));
1243 }
1244
1245 #[test]
1250 fn two_fingers_pan_the_canvas() {
1251 let ctx = egui::Context::default();
1252 let mut view = View::new();
1253 let start = [pos2(360.0, 300.0), pos2(440.0, 300.0)];
1254 let step = Vec2::new(20.0, -12.0);
1255
1256 start_two_finger_gesture(&ctx, &mut view, start);
1257 frame(
1258 &ctx,
1259 &mut view,
1260 two_fingers(egui::TouchPhase::Move, start.map(|p| p + step)),
1261 );
1262 let before = view.translation;
1263 let zoom_before = view.zoom;
1264 let seen = frame(
1265 &ctx,
1266 &mut view,
1267 two_fingers(egui::TouchPhase::Move, start.map(|p| p + step * 2.0)),
1268 );
1269
1270 assert_eq!(
1271 view.translation - before,
1272 step,
1273 "the canvas did not follow the fingers"
1274 );
1275 assert_eq!(
1276 view.zoom, zoom_before,
1277 "the fingers held their spacing, so nothing should have zoomed"
1278 );
1279 assert!(
1280 !matches!(
1281 seen,
1282 Some(Event::DragStarted { .. } | Event::Dragging { .. })
1283 ),
1284 "the primary finger reached the tool as a drag ({seen:?})"
1285 );
1286 }
1287
1288 #[test]
1291 fn pinching_zooms_about_the_gesture_center() {
1292 let ctx = egui::Context::default();
1293 let mut view = View::new();
1294 let center = pos2(400.0, 300.0);
1295 let reach = Vec2::new(40.0, 0.0);
1296 let start = [center - reach, center + reach];
1297
1298 start_two_finger_gesture(&ctx, &mut view, start);
1299 let origin = view.viewport.min;
1300 let world_before = view.screen_to_world(origin, center);
1301
1302 frame(
1303 &ctx,
1304 &mut view,
1305 two_fingers(
1306 egui::TouchPhase::Move,
1307 [center - reach * 2.0, center + reach * 2.0],
1308 ),
1309 );
1310
1311 assert!(
1312 view.zoom.get() > 1.5,
1313 "spreading the fingers should have doubled the zoom, got {:?}",
1314 view.zoom
1315 );
1316 let world_after = view.screen_to_world(origin, center);
1317 assert!(
1318 world_before.distance(world_after) < 0.01,
1319 "the world under the pinch moved: {world_before:?} -> {world_after:?}"
1320 );
1321 }
1322
1323 #[test]
1326 fn the_last_finger_of_a_pan_never_becomes_a_tool_drag() {
1327 let ctx = egui::Context::default();
1328 let mut view = View::new();
1329 let start = [pos2(360.0, 300.0), pos2(440.0, 300.0)];
1330 let step = Vec2::new(25.0, 0.0);
1331
1332 start_two_finger_gesture(&ctx, &mut view, start);
1333 frame(
1334 &ctx,
1335 &mut view,
1336 two_fingers(egui::TouchPhase::Move, start.map(|p| p + step)),
1337 );
1338 frame(
1340 &ctx,
1341 &mut view,
1342 vec![touch(1, egui::TouchPhase::End, start[1] + step)],
1343 );
1344 let seen = frame(
1345 &ctx,
1346 &mut view,
1347 vec![
1348 egui::Event::PointerMoved((start[0] + step * 2.0).egui()),
1349 touch(0, egui::TouchPhase::Move, start[0] + step * 2.0),
1350 ],
1351 );
1352 assert!(
1353 !matches!(
1354 seen,
1355 Some(Event::DragStarted { .. } | Event::Dragging { .. })
1356 ),
1357 "the trailing finger reached the tool as a drag ({seen:?})"
1358 );
1359
1360 frame(
1362 &ctx,
1363 &mut view,
1364 vec![touch(0, egui::TouchPhase::End, start[0] + step * 2.0)],
1365 );
1366 assert!(!view.touch_pans, "the gesture latch outlived the gesture");
1367 }
1368
1369 #[test]
1374 fn an_open_editor_settles() {
1375 let buffer = Rc::new(RefCell::new(String::from("Amplifier")));
1376 let mut view = View::new();
1377 let chrome = CanvasChrome {
1378 background: Color::BLACK,
1379 grid: Color::GRAY,
1380 };
1381 let settle = crate::tools::settle::probe(30, |ui| {
1382 ui.style_mut().visuals.text_cursor.blink = false;
1385 view.begin(ui, Palette::tokyo_night_moon(), chrome)
1386 .paint(|_, painter| {
1387 painter.set_edit_text(EditText {
1388 position: Rect::from_min_size(pos2(100.0, 100.0), Vec2::new(120.0, 20.0)),
1389 buffer: Rc::clone(&buffer),
1390 font: Font::proportional(14.0),
1391 id: EditId::of("settle_title_edit"),
1392 multiline: false,
1393 char_limit: Some(crate::grid::MAX_LABEL_CHARS),
1394 tab_cycle: true,
1395 select_all_on_focus: false,
1396 hint: None,
1397 colors: None,
1398 wrap_width: None,
1399 });
1400 });
1401 });
1402 crate::tools::settle::assert_settles(&settle, 8);
1403 }
1404
1405 #[test]
1406 fn frame_zoom_clamps_huge_content_to_the_floor() {
1407 let viewport = Vec2::new(800.0, 600.0);
1408 let huge = Vec2::new(100_000.0, 100_000.0);
1409 assert_eq!(
1410 frame_zoom(viewport, huge, Zoom::max_value()),
1411 Zoom::min_value()
1412 );
1413 }
1414}