M1: 2x pixel preview (#68)

Pixel preview takes a resolution: `view.pixelPreview {scale: 1|2}` turns it on at 1x (one rendered
pixel per page pixel) or 2x (two, as on a high-density display); the canvas renders at that
resolution from a whole-pixel origin and magnifies it with hard edges once zoomed past it. The zoom
menu offers "Pixel preview 1x" and "2x" and now shows the on/off state of every view toggle.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 11:43:54 +07:00 committed by GitHub
parent 6e6373ce89
commit 6d7b30508b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 89 additions and 20 deletions

View File

@ -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 |

View File

@ -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<Value> {
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<Value> {
let v = view(s)?;
let c = (v.cx, v.cy);
@ -140,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)),

View File

@ -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;

View File

@ -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));
}

View File

@ -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<f64> {
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)
};

View File

@ -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();
}