diff --git a/ROADMAP.md b/ROADMAP.md index e39d7f9..38c7e43 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -208,7 +208,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Area | Weight | Score | Remaining work | Hours | |---|---|---|---|---| -| Canvas & navigation | 6 | 72% | frame guides, 2x pixel preview | 3–5 | +| Canvas & navigation | 6 | 73% | frame guides | 2–4 | | Selection & transform | 8 | 68% | measure hover polish, scale tool on-canvas handles | 5–8 | | Layers panel | 5 | 79% | keyboard row navigation | 1–2 | | Shapes & vector | 8 | 74% | live boolean editing, arc handles on canvas, shape builder on strokes and open paths, one-sided widths | 4–8 | diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6eb4946..93640a8 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -19,6 +19,23 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul Ok(json!({"on": *slot})) } +/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x. +fn pixel_preview(s: &mut Session, p: &Value) -> Result { + let scale = match p.get("scale") { + None | Some(Value::Null) => None, + Some(v) => match v.as_u64() { + Some(k @ (1 | 2)) => Some(k as u8), + _ => return Err(bad("view.pixelPreview", "`scale` must be 1 or 2")), + }, + }; + let v = view(s)?; + if let Some(k) = scale { + v.pixel_scale = k; + } + v.pixel_preview = boolean(p, "on").unwrap_or(scale.is_some() || !v.pixel_preview); + Ok(json!({"on": v.pixel_preview, "scale": v.pixel_scale})) +} + fn zoom_to(s: &mut Session, z: f64) -> Result { let v = view(s)?; let c = (v.cx, v.cy); @@ -140,7 +157,7 @@ pub fn specs() -> Vec { spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), - spec!("view.pixelPreview", "Pixel preview", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_preview)), + spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 48d040d..2acbd40 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -104,6 +104,8 @@ pub struct ViewState { pub rulers: bool, pub outlines: bool, pub pixel_preview: bool, + /// Pixel preview resolution: rendered pixels per page pixel (1 or 2). + pub pixel_scale: u8, pub show_slices: bool, pub comments: bool, pub frame_outlines: bool, @@ -126,6 +128,7 @@ impl Default for ViewState { rulers: false, outlines: false, pixel_preview: false, + pixel_scale: 1, show_slices: false, comments: true, frame_outlines: false, @@ -894,6 +897,8 @@ mod nested_override_tests; #[cfg(test)] mod paint_tests; #[cfg(test)] +mod pixel_preview_tests; +#[cfg(test)] mod proto_multi_tests; #[cfg(test)] mod proto_present_tests; diff --git a/crates/engine/src/pixel_preview_tests.rs b/crates/engine/src/pixel_preview_tests.rs new file mode 100644 index 0000000..39c4d42 --- /dev/null +++ b/crates/engine/src/pixel_preview_tests.rs @@ -0,0 +1,38 @@ +//! Pixel preview: toggling it, and its 1x / 2x resolution. + +use serde_json::json; + +use crate::Session; + +fn preview(s: &Session) -> (bool, u8) { + let v = &s.ds().unwrap().view; + (v.pixel_preview, v.pixel_scale) +} + +#[test] +fn toggles_and_sets_its_resolution() { + let mut s = Session::new(); + assert_eq!(preview(&s), (false, 1)); + s.execute("view.pixelPreview", &json!({})).unwrap(); + assert_eq!(preview(&s), (true, 1)); + s.execute("view.pixelPreview", &json!({})).unwrap(); + assert_eq!(preview(&s), (false, 1)); + // A resolution turns it on. + let r = s.execute("view.pixelPreview", &json!({"scale": 2})).unwrap(); + assert_eq!(r, json!({"on": true, "scale": 2})); + assert_eq!(preview(&s), (true, 2)); + // Turning it off keeps the resolution for next time. + s.execute("view.pixelPreview", &json!({"on": false})).unwrap(); + assert_eq!(preview(&s), (false, 2)); + s.execute("view.pixelPreview", &json!({"on": true, "scale": 1})).unwrap(); + assert_eq!(preview(&s), (true, 1)); +} + +#[test] +fn rejects_other_resolutions() { + let mut s = Session::new(); + for bad in [json!(3), json!(0), json!(1.5), json!("2"), json!(-1)] { + assert!(s.execute("view.pixelPreview", &json!({"scale": bad})).is_err(), "{bad}"); + } + assert_eq!(preview(&s), (false, 1)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index aaf8938..45b666a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -140,7 +140,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { && done.generation == app.canvas.generation { // A pixel preview render is magnified with hard pixel edges. - let filter = if done.view.scale == 1.0 && app.session.ds().is_ok_and(pixel_preview) { + let filter = if app.session.ds().ok().and_then(pixel_preview) == Some(done.view.scale) { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR @@ -211,21 +211,22 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } -/// Pixel preview is on and zoomed in past one screen pixel per page pixel. -fn pixel_preview(ds: &figcraft_engine::DocState) -> bool { - ds.view.pixel_preview && ds.view.zoom > 1.0 +/// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past it. +fn pixel_preview(ds: &figcraft_engine::DocState) -> Option { + let k = f64::from(ds.view.pixel_scale.clamp(1, 2)); + (ds.view.pixel_preview && ds.view.zoom > k).then_some(k) } fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); - let (scale, w, h) = if pixel_preview(ds) { - // Pixel preview: render one pixel per page unit from a whole-pixel origin, shown magnified + let (scale, w, h) = if let Some(k) = pixel_preview(ds) { + // Pixel preview: render `k` pixels per page unit from a whole-pixel origin, shown magnified // without smoothing. - (ox, oy) = (ox.floor(), oy.floor()); - let z = ds.view.zoom.max(1e-9); - (1.0, (f64::from(rect.width()) / z).ceil() as u32 + 2, (f64::from(rect.height()) / z).ceil() as u32 + 2) + (ox, oy) = ((ox * k).floor() / k, (oy * k).floor() / k); + let z = ds.view.zoom.max(1e-9) / k; + (k, (f64::from(rect.width()) / z).ceil() as u32 + 2, (f64::from(rect.height()) / z).ceil() as u32 + 2) } else { (ds.view.zoom * f64::from(ppp), (rect.width() * ppp).ceil().max(1.0) as u32, (rect.height() * ppp).ceil().max(1.0) as u32) }; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 1e2d862..483b56b 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -139,15 +139,23 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } } ui.separator(); - for (label, cmd) in [ - ("Pixel preview", "view.pixelPreview"), - ("Pixel grid", "view.pixelGrid"), - ("Snap to pixel grid", "view.snapPixel"), - ("Layout grids", "view.layoutGrids"), - ("Rulers", "view.rulers"), - ("Outlines", "view.outlines"), - ] { - if menu_item(ui, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) { + let Ok(v) = app.session.ds().map(|d| d.view.clone()) else { return }; + for (label, scale) in [("Pixel preview 1x", 1u8), ("Pixel preview 2x", 2)] { + let on = v.pixel_preview && v.pixel_scale == scale; + if menu_check(ui, on, label, "") { + app.run("view.pixelPreview", json!({"on": !on, "scale": scale})); + ui.close(); + } + } + let toggles: [(&str, &str, bool); 5] = [ + ("Pixel grid", "view.pixelGrid", v.pixel_grid), + ("Snap to pixel grid", "view.snapPixel", v.snap_pixel), + ("Layout grids", "view.layoutGrids", v.layout_grids), + ("Rulers", "view.rulers", v.rulers), + ("Outlines", "view.outlines", v.outlines), + ]; + for (label, cmd, on) in toggles { + if menu_check(ui, on, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) { app.run(cmd, json!({})); ui.close(); }