diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8dd08c9b5..d139efe64 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -89,6 +89,7 @@ impl VectorcraftApp { view_moved: None, hover_cursor: None, presented: None, + opening_fit: None, panes: Default::default(), windows: crate::doc_windows::Windows::new(known), }, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 1bb731ca8..a9211eccf 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -65,6 +65,10 @@ pub struct CanvasCache { /// In Presentation Mode, the canvas rect the active artboard was last fitted to: it is fitted /// again when the rect changes (the window going full screen). pub presented: Option, + /// The canvas a document's opening view was fitted to, with the view the fit gave. The window's + /// layout can still move the canvas's edges over the next frames, so until the view is moved or + /// the user acts, the opening view is fitted again when the canvas changes ([`canvas::show`]). + pub opening_fit: Option, /// Window › Arrange › Tile: the renders the panes of the other documents draw. pub panes: canvas::Panes, /// Window › Arrange › Float in Window: what the floating document windows keep between frames. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0793ac9e0..19244247d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -186,6 +186,36 @@ fn view_rects(app: &VectorcraftApp, outer: egui::Rect, ppp: f32) -> (egui::Rect, (full, rect.round_to_pixels(ppp)) } +/// Whether the user pressed a button or a key, typed, scrolled or zoomed in this frame: the +/// opening fit stops following the canvas then ([`crate::caches::CanvasCache::opening_fit`]). +fn acted(i: &egui::InputState) -> bool { + i.pointer.any_pressed() + || i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { pressed: true, .. } + | egui::Event::Text(_) + | egui::Event::MouseWheel { .. } + | egui::Event::Zoom(_) + | egui::Event::Touch { .. } + ) + }) +} + +/// The view `v` shows in the canvas `rect`, as the opening fit records it. +fn view_key(v: &View, rect: egui::Rect) -> ViewKey { + ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation } +} + +/// Whether the opening fit still follows the canvas `rect` (the view is unfitted, or unmoved since +/// the fit and the user hasn't acted), and whether the canvas moved under it this frame, so the view +/// is fitted again. +fn opening_fit_follows(app: &VectorcraftApp, ui: &Ui, rect: egui::Rect, fitted: bool) -> (bool, bool) { + let following = !fitted || (!ui.input(acted) && app.canvas.opening_fit.zip(app.view()).is_some_and(|(was, v)| view_key(v, was.rect) == was)); + let settling = fitted && following && app.canvas.opening_fit.is_some_and(|was| was.rect != rect); + (following, settling) +} + /// Paint the document canvas, complete or cached artwork, navigation and tool overlays. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -225,13 +255,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); let fitted = app.view().is_some_and(|v| v.fitted); + // A document's opening view is fitted to the canvas of the first frame that shows it, and the + // window's layout can still move the canvas's edges over the next frames. Until the view is + // moved or the user acts, the opening fit follows the canvas, so that the document opens at the + // zoom of the settled window. + let (following, settling) = opening_fit_follows(app, ui, rect, fitted); // Presentation Mode shows the active artboard on the whole screen and nothing but the art: // no selection, guides, grids or artboard edges (#4365). let presenting = app.ui.screen_mode == 3; - if !fitted || (presenting && app.canvas.presented != Some(rect)) { + if !fitted || settling || (presenting && app.canvas.presented != Some(rect)) { fit(app, "view.fitArtboard"); } app.canvas.presented = presenting.then_some(rect); + app.canvas.opening_fit = following.then(|| app.view().map(|v| view_key(v, rect))).flatten(); // Before the canvas's own interaction, so the view the canvas draws is the one they moved to. if bars { scroll_bars(app, ui, rect, outer, &t); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 2c4ef5baa..9ef8fe597 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -87,6 +87,60 @@ fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping"); } +/// A document's opening view is fitted to the canvas of the first frame that shows it, and the +/// window's layout can still move the canvas's edges over the next frames. Two starts of the app +/// then showed one file at zooms a hair apart, on canvases alike once the window had settled. So +/// until the view is moved or the user presses, types or scrolls, the opening fit follows the +/// canvas. +#[test] +fn the_opening_fit_follows_the_canvas_until_the_view_moves_or_the_user_acts() { + // A frame that shows the canvas `width` points wide, which limits the fit of the 800 × 600 + // artboard, with `events`: the view after it. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> View { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))), + max_texture_side: Some(8192), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); + show(app, &mut child); + }); + out.textures_delta.clear(); + *app.view().unwrap() + } + fn opened() -> (VectorcraftApp, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap(); + (app, egui::Context::default()) + } + let first_shown_at = |width: f32| { + let (mut app, ctx) = opened(); + frame(&mut app, &ctx, width, vec![]).zoom + }; + let (narrow, wide) = (first_shown_at(700.0), first_shown_at(702.0)); + assert!(narrow < wide, "the canvas's width limits the fit: {narrow} and {wide}"); + let escape = || egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + + // The first frame lays the canvas out 2 points narrower than the next: the document opens at + // the zoom of the settled canvas, as it does when the first frame has it already. + let (mut app, ctx) = opened(); + frame(&mut app, &ctx, 700.0, vec![]); + assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, wide); + // Once the user has pressed a key, the canvas changing keeps the view, as resizing the window + // does. + assert_eq!(frame(&mut app, &ctx, 702.0, vec![escape()]).zoom, wide); + assert_eq!(frame(&mut app, &ctx, 700.0, vec![]).zoom, wide); + + // A view moved before the canvas changes stays where it was moved to. + let (mut app, ctx) = opened(); + frame(&mut app, &ctx, 700.0, vec![]); + app.view_mut().unwrap().zoom = 0.5; + assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, 0.5); +} + /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent /// edges and all; one whose bytes don't match its size becomes an empty image of that size. #[test]