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:
parent
6e6373ce89
commit
6d7b30508b
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue