diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5a75de00be..97fa0d8b46 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -86,6 +86,8 @@ pub struct CanvasState { pub scrollbar_page: Option, /// Effective scale (screen points per document point). pub scale: f32, + /// Whether this frame's pages were drawn hinted ([`hint_pages`]). + pub(crate) pages_hinted: bool, pub canvas_rect: Option, pub focused: bool, /// The page's widget id and the pass that last drew it, so a click elsewhere can hand the @@ -242,6 +244,7 @@ impl Default for CanvasState { page_rects: Vec::new(), scrollbar_page: None, scale: PX_PER_PT, + pages_hinted: screen_render_options().hinting, canvas_rect: None, focused: false, widget_id: None, @@ -729,8 +732,10 @@ pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) { /// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media, /// and (while a drag lasts) the object left out of the page. /// Taken once per frame. -fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 { +fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>, hinted: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); + // A page drawn unhinted mid-zoom must not pass for the hinted page (see [`hint_pages`]). + hinted.hash(&mut h); let v = &app.session.view; (v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h); app.session.prefs.markup.hash(&mut h); @@ -1014,9 +1019,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // The status bar's zoom readout and slider were laid out before this, from the last frame's // scale: after a zoom change draw again at once, so they follow in the next frame rather than // whenever something else next repaints (the caret's blink). - if (geo.scale - app.canvas.scale).abs() > 1e-6 { + let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-6; + if rescaled { ui.ctx().request_repaint(); } + let hinted = hint_pages(zoom_delta, rescaled); + app.canvas.pages_hinted = hinted; app.canvas.scale = geo.scale; sync_fit_zoom(app, geo.scale); // A pointer zoom scrolls so the same page point is under the pointer at the new scale, also @@ -1201,7 +1209,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // The object a move has taken off its page: the page is drawn again without it (or it // would be on the page where it was and under the pointer at once). let hidden = app.canvas.dragged_off_page(); - let view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p)); + let view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p), hinted); for (i, pr) in geo.rects.iter().enumerate() { let sr = pr.translate(content_origin.to_vec2()); let Some(page) = layout.pages.get(i) else { continue }; @@ -1227,6 +1235,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // edit) then sits on the pixel grid in this frame too, rather than a frame later. if visible && !tiled && !fresh && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { let mut opts = page_render_options(app, dim_body); + opts.hinting &= hinted; // Only on the page it is on: the `Pos` can't match an object of another page. opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts); @@ -1283,6 +1292,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key); if !fresh && rendered < 4 { let mut opts = page_render_options(app, dim_body); + opts.hinting &= hinted; // Only on the page it is on: the `Pos` can't match an object of another page. opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); // The tiny subtraction avoids a 1px overshoot from round-tripping f32 @@ -3325,6 +3335,20 @@ fn caret_color_at(app: &mut WordApp, layout: &std::sync::Arc, page: u c } +/// Are pages drawn hinted this frame (as [`screen_render_options`] says)? Not while a pointer +/// zoom (Ctrl/⌘+wheel easing in, a pinch: `zoom_delta` other than 1) changes the scale +/// (`rescaled`): each such frame is a new size shown for a moment, and hinting every one would +/// cost each page about 1.5 ms more and fill the hint caches with sizes nobody rests on, pushing +/// out the one the zoom ends at. The first frame at rest draws the page hinted (the hinting is in +/// the page's key). A zoom that leaves the scale as it is (at 10 % or 500 %, or over the ribbon) +/// keeps the pages hinted, and other scale changes (the Zoom dialog or slider, a fit zoom +/// following the window) are drawn hinted at once. +fn hint_pages(zoom_delta: f32, rescaled: bool) -> bool { + let gesture = zoom_delta.is_nan() || (zoom_delta - 1.0).abs() > 1e-3; + let zooming = gesture && rescaled; + screen_render_options().hinting && !zooming +} + /// Render options for page rasters shown on screen, so a page looks as text does in the /// platform's other apps (exports never change): on macOS, text is darkened the way the system /// draws it; on Linux and the BSDs it is hinted lightly, as FreeType's `hintslight` draws it @@ -3623,7 +3647,7 @@ mod tests { let at = |a: &mut WordApp, off: usize| { let layout = a.session.layout(); let c = layout.caret_on(&Pos::body(0, off), 0).unwrap(); - let view = view_key(a, false, None); + let view = view_key(a, false, None, true); caret_color_at(a, &layout, c.page, c.x, c.top + c.height / 2.0, view, false) }; assert_eq!(at(&mut a, 2), Color32::BLACK, "white paper"); @@ -6090,8 +6114,26 @@ mod tests { let doc = wordcraft_doc::Document::from_text("Hello WordCraft"); let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default()); let object = Pos::body(0, 5); - let hidden = view_key(&app, false, Some(&object)); - assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again"); - assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame"); + let hidden = view_key(&app, false, Some(&object), true); + assert_ne!(hidden, view_key(&app, false, None, true), "the page without the dragged object is rendered again"); + assert_eq!(hidden, view_key(&app, false, Some(&object), true), "the same drag keeps the key: no re-render every frame"); + } + + /// A zoom easing in (or a pinch) shows a new size every frame for a moment: those pages are + /// drawn unhinted, so they fill no hint caches with sizes nobody rests on. The first frame + /// without zooming draws the page hinted again, even at the size the last zoom frame drew. + #[test] + fn pages_are_hinted_again_once_a_zoom_stops() { + let platform = screen_render_options().hinting; + assert_eq!(hint_pages(1.0, false), platform, "at rest, as the platform draws text"); + for zoom in [1.1, 0.9, 1.002, 0.998, f32::NAN, f32::INFINITY, 0.0] { + assert!(!hint_pages(zoom, true), "{zoom}: the scale changes under a pointer zoom"); + assert_eq!(hint_pages(zoom, false), platform, "{zoom}: at 10 % or 500 %, or over the ribbon: nothing changes"); + } + assert_eq!(hint_pages(1.0, true), platform, "the Zoom dialog or slider, a fit zoom: hinted at once"); + // The hinting is part of the page's key: the last zoom frame's raster is replaced. + let doc = wordcraft_doc::Document::from_text("Hello WordCraft"); + let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default()); + assert_ne!(view_key(&app, false, None, true), view_key(&app, false, None, false)); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7f687a4e8..309d3eb3ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3835,6 +3835,62 @@ mod tests { assert!(first > 0.0 && first < notch, "a notch eases in again: {first}"); } + /// A Ctrl+wheel notch eases in over several frames, each a new size: those pages are drawn + /// unhinted (they show for a moment, and hinting each size would fill the hint caches), and + /// the first frame after the zoom stops draws them hinted, as the platform draws text. + #[test] + fn an_easing_zoom_draws_unhinted_pages_until_it_stops() { + let ctx = egui::Context::default(); + let system = std::rc::Rc::new(std::cell::Cell::new(Some(true))); + let mut a = app_with_system_animations(&system); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a, Vec::new()); + } + let platform = crate::canvas::screen_render_options().hinting; + assert_eq!(a.canvas.pages_hinted, platform, "at rest"); + let over_page = a.canvas.canvas_rect.unwrap().center(); + frame(&mut a, vec![egui::Event::PointerMoved(over_page)]); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + }; + let before = a.canvas.scale; + frame(&mut a, vec![wheel]); + let (mut eased, mut unhinted) = (0, 0); + let mut last = a.canvas.scale; + for _ in 0..60 { + frame(&mut a, Vec::new()); + if a.canvas.scale != last { + eased += 1; + unhinted += usize::from(!a.canvas.pages_hinted); + last = a.canvas.scale; + } else { + break; + } + } + assert!(a.canvas.scale > before && eased >= 2, "the notch eased in over frames: {eased}"); + if platform { + assert_eq!(unhinted, eased, "every in-between size unhinted"); + } + // The frame after the zoom stops: hinted again, at the size it ended on. + frame(&mut a, Vec::new()); + assert_eq!(a.canvas.scale, last); + assert_eq!(a.canvas.pages_hinted, platform, "hinted once the zoom stops"); + } + /// With animation effects off a zoom step is finished in its own frame: Ctrl+wheel zooms the /// notch's whole step at once, the page is already placed and drawn as it will stay, and the /// status bar's zoom readout follows in the very next frame. Turns that arrive in small pieces