mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
Pages drawn mid-zoom are unhinted, and hinted once the zoom stops
A Ctrl/Cmd+wheel notch eases in over several frames and a pinch changes the scale every frame: each frame was a new size, so its pages were hinted afresh (a new hinting instance per face and every glyph hinted again, about 1.5 ms more per page) and the hint caches filled with sizes nobody rests on, which could clear the one the zoom ends at. While a pointer zoom changes the scale, pages are now drawn unhinted; the hinting is part of the page's texture key, so the first frame at rest draws the page hinted, also at the size the last zoom frame drew. 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 (Zoom dialog, slider, a fit zoom following the window) are drawn hinted at once. Rounding sizes into buckets was considered instead: steps between eased frames are far larger than any bucket fine enough to keep the glyphs on the pixel grid at the size the zoom ends at. Tests: an easing Ctrl+wheel zoom draws every in-between size unhinted and the page hinted the frame it stops; the decision for a gesture, the zoom limits and other scale changes; the page key holds the hinting.
This commit is contained in:
committed by
vangel76
parent
3ee5840d6d
commit
2e7852fa4d
@@ -86,6 +86,8 @@ pub struct CanvasState {
|
||||
pub scrollbar_page: Option<usize>,
|
||||
/// 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<Rect>,
|
||||
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<DocLayout>, 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<egui::Event>| {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user