mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 02:30:43 -06:00
Canvas: zoom is physical at any display scale (#1978)
View zoom now means device pixels per document pixel, so 100% shows one image pixel per physical display pixel on a scaled (125%, 200%) display, as in Photoshop. Fit, Actual Pixels, pan/zoom anchoring, scrollbars, the navigator, the pixel grid and every hit tolerance go through canvas_ppp / point_zoom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
32c918aaee
commit
27263bb16d
@@ -57,3 +57,6 @@ sync_github_name = true
|
||||
[people.Hathor875]
|
||||
real_name = "Krzysztof Cieślik"
|
||||
sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -623,7 +623,7 @@ mod tests {
|
||||
let idx = app.session.active_index().unwrap();
|
||||
assert!(app.ui.views[idx].fit_pending);
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0));
|
||||
crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0), 1.0);
|
||||
assert_eq!(app.ui.views[idx].zoom, 0.75);
|
||||
assert_eq!(app.ui.views[idx].center, [40.0, 30.0]);
|
||||
assert!(!app.ui.views[idx].fit_pending);
|
||||
|
||||
@@ -203,7 +203,7 @@ fn export_log(app: &mut PhotocraftApp, rows: Option<Vec<u64>>) -> Result<Value,
|
||||
// ------------------------------------------------------------------ tools
|
||||
|
||||
fn tolerance(app: &PhotocraftApp) -> f64 {
|
||||
7.0 / f64::from(app.current_zoom().max(0.01))
|
||||
7.0 / f64::from(app.point_zoom().max(0.01))
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
@@ -293,7 +293,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
}
|
||||
}
|
||||
(Tool::Note, ToolEvent::Down { x, y, .. }) => {
|
||||
let z = f64::from(app.current_zoom().max(0.01));
|
||||
let z = f64::from(app.point_zoom().max(0.01));
|
||||
let hit =
|
||||
doc.notes.iter().rposition(|n| x >= n.position[0] && x <= n.position[0] + 16.0 / z && y >= n.position[1] && y <= n.position[1] + 20.0 / z);
|
||||
match hit {
|
||||
|
||||
@@ -63,9 +63,11 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let size = [st.doc.size.width, st.doc.size.height];
|
||||
let area = app.last_canvas_rect.size();
|
||||
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
|
||||
let ppp = app.canvas_ppp();
|
||||
let v = &mut app.ui.views[i];
|
||||
// Leave room for the name above the board.
|
||||
v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32);
|
||||
// Leave room for the name above the board. The area is in egui points; the stored zoom is
|
||||
// device pixels per document pixel.
|
||||
v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32) * ppp;
|
||||
v.zoom = crate::zoom_levels::clamp(v.zoom, size);
|
||||
// Widen before adding: a board near ±2^30, or one whose far edge saturated at i32::MAX,
|
||||
// overflows an i32 sum (#981).
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn release_stale(app: &mut PhotocraftApp, secondary_down: bool) {
|
||||
fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) {
|
||||
// Right is bigger on screen, also in a flipped view (View › Flip Horizontal).
|
||||
let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 };
|
||||
let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.current_zoom());
|
||||
let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.point_zoom());
|
||||
let b = &app.session.tools.brush;
|
||||
if b.size == size && b.hardness == hardness {
|
||||
return;
|
||||
|
||||
+147
-43
@@ -325,7 +325,7 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> {
|
||||
/// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The
|
||||
/// stroke smoothing is the session brush's (the options bar's Smoothing %).
|
||||
fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec<f64>]) -> serde_json::Value {
|
||||
let mut p = json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) });
|
||||
let mut p = json!({ "points": points, "erase": erase, "zoom": app.point_zoom(), "target": paint_target(app) });
|
||||
if tool == Tool::Pencil {
|
||||
p["autoErase"] = json!(app.ui.tool_options.pencil_auto_erase);
|
||||
}
|
||||
@@ -573,7 +573,7 @@ impl ViewXform {
|
||||
let v = app.ui.views.get(app.session.active_index()?)?;
|
||||
Some(Self {
|
||||
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
|
||||
zoom: v.zoom,
|
||||
zoom: v.zoom / app.canvas_ppp(),
|
||||
center: v.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
@@ -627,9 +627,12 @@ impl ViewXform {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
|
||||
/// Fit the document into `area` (egui points) for a display with `ppp` physical pixels per
|
||||
/// point; `View::zoom` is stored in physical pixels per document pixel.
|
||||
pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) {
|
||||
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
|
||||
let zoom = crate::zoom_levels::clamp(((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0), [doc.size.width, doc.size.height]);
|
||||
let points = ((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0);
|
||||
let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]);
|
||||
view.zoom = zoom;
|
||||
view.center = [w / 2.0, h / 2.0];
|
||||
view.fit_pending = false;
|
||||
@@ -638,9 +641,10 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
|
||||
|
||||
/// Centre the document and zoom until it fills the canvas. One axis can extend beyond the
|
||||
/// viewport, matching the Hand tool's Fill Screen action.
|
||||
pub fn fill_view(view: &mut View, doc: &Document, area: Vec2) {
|
||||
pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) {
|
||||
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
|
||||
let zoom = crate::zoom_levels::clamp((area.x / w).max(area.y / h), [doc.size.width, doc.size.height]);
|
||||
let points = (area.x / w).max(area.y / h);
|
||||
let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]);
|
||||
view.zoom = zoom;
|
||||
view.center = [w / 2.0, h / 2.0];
|
||||
view.fit_pending = false;
|
||||
@@ -2104,6 +2108,14 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f
|
||||
/// Draw one canvas view and handle its input. `primary` = main window (tools active).
|
||||
pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View {
|
||||
let ctx = ui.ctx().clone();
|
||||
// `View::zoom` is device pixels per document pixel; this canvas's geometry is in its own
|
||||
// viewport's egui points. A document window can sit on another display with a different
|
||||
// scale, so only the primary canvas records `app.ppp` for the frame's shared helpers
|
||||
// (`point_zoom`, the navigator, control-channel coordinate mapping).
|
||||
let ppp = ctx.pixels_per_point();
|
||||
if primary {
|
||||
app.ppp = ppp;
|
||||
}
|
||||
// The display this window is on: its monitor profile (#569). A document window on a display
|
||||
// the last reading didn't know asks for a new one.
|
||||
let output = crate::monitor_status::view_display(app, &ctx);
|
||||
@@ -2122,17 +2134,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
view.doc_size = size;
|
||||
}
|
||||
if view.fit_pending && rect.width() > 50.0 {
|
||||
fit_view(&mut view, &doc, rect.size());
|
||||
fit_view(&mut view, &doc, rect.size(), ppp);
|
||||
}
|
||||
if view.fill_pending && rect.width() > 50.0 {
|
||||
fill_view(&mut view, &doc, rect.size());
|
||||
fill_view(&mut view, &doc, rect.size(), ppp);
|
||||
}
|
||||
// Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs).
|
||||
if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size()) {
|
||||
if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size(), ppp) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let flip = app.ui.view.flip_horizontal;
|
||||
let xf = ViewXform { rect, zoom: view.zoom, center: view.center, flip, rotation: view.rotation };
|
||||
// Canvas geometry (ViewXform, ViewParams, painter rects) is in egui points.
|
||||
let point_zoom = view.zoom / ppp;
|
||||
let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation };
|
||||
let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid);
|
||||
let response = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
let painter = ui.painter_at(rect);
|
||||
@@ -2159,16 +2173,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation);
|
||||
if app.gpu.is_some()
|
||||
&& gpu_ok
|
||||
&& let Some((k, key, preview_size)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point())
|
||||
.or_else(|| ensure_filter_preview(app, idx, &ctx))
|
||||
.or_else(|| ensure_proxy_preview(app, idx))
|
||||
&& let Some((k, key, preview_size)) =
|
||||
ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx))
|
||||
{
|
||||
on_gpu = true;
|
||||
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
|
||||
let params = crate::gpu_canvas::ViewParams {
|
||||
doc: key,
|
||||
doc_size: preview_size,
|
||||
zoom: view.zoom * k as f32,
|
||||
zoom: view.zoom * k as f32 / ppp,
|
||||
// A size-changing preview grows around the old image center; keep the view's pan.
|
||||
center: [
|
||||
view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
|
||||
@@ -2193,7 +2206,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let params = crate::gpu_canvas::ViewParams {
|
||||
doc: doc.id.0,
|
||||
doc_size: [doc.size.width, doc.size.height],
|
||||
zoom: view.zoom,
|
||||
zoom: point_zoom,
|
||||
center: view.center,
|
||||
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
|
||||
shadow: {
|
||||
@@ -2304,7 +2317,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Selection outline: true boundary, animated marching ants (cached per revision).
|
||||
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
|
||||
// Trace at display resolution over the visible part only; key by the mask's tile identity
|
||||
// (not the document revision) so unrelated edits don't re-trace it.
|
||||
// (not the document revision) so unrelated edits don't re-trace it. The step is in
|
||||
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
|
||||
// selection still traces at 100% on a scaled display.
|
||||
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
|
||||
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
|
||||
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
|
||||
@@ -2373,11 +2388,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// zooms nor slides the image towards the pointer.
|
||||
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
|
||||
if nz != view.zoom {
|
||||
zoom_about(&mut view, &xf, p, nz, false);
|
||||
zoom_about(&mut view, &xf, p, nz, false, ppp);
|
||||
}
|
||||
}
|
||||
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
|
||||
let d = xf.unmap_vec(scroll) / view.zoom;
|
||||
let d = xf.unmap_vec(scroll) / (view.zoom / ppp);
|
||||
view.center[0] -= d.x;
|
||||
view.center[1] -= d.y;
|
||||
}
|
||||
@@ -2436,7 +2451,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling);
|
||||
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking;
|
||||
if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
|
||||
let d = xf.unmap_vec(d) / view.zoom;
|
||||
let d = xf.unmap_vec(d) / (view.zoom / ppp);
|
||||
view.center[0] -= d.x;
|
||||
view.center[1] -= d.y;
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
@@ -2468,7 +2483,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
}
|
||||
if tool == Tool::Hand && response.dragged() {
|
||||
let d = xf.unmap_vec(response.drag_delta()) / view.zoom;
|
||||
let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
|
||||
view.center[0] -= d.x;
|
||||
view.center[1] -= d.y;
|
||||
} else if primary {
|
||||
@@ -2667,7 +2682,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
Tool::Zoom => {
|
||||
let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);
|
||||
let center = app.session.prefs().tools.zoom_clicked_point_to_center;
|
||||
zoom_about(&mut view, &xf, p, nz, center);
|
||||
zoom_about(&mut view, &xf, p, nz, center, ppp);
|
||||
}
|
||||
// A click with the (temporary) Hand does nothing, never the tool underneath.
|
||||
Tool::Hand | Tool::RotateView => {}
|
||||
@@ -2793,7 +2808,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let cur = app.session.prefs().cursors.clone();
|
||||
let painting = app.drag.is_some();
|
||||
let brush = &app.session.tools.brush;
|
||||
let full = (brush.size / 2.0 * view.zoom).max(1.0);
|
||||
let full = (brush.size / 2.0 * xf.zoom).max(1.0);
|
||||
let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full };
|
||||
match cur.painting {
|
||||
PaintingCursor::Standard => egui::CursorIcon::Default,
|
||||
@@ -2850,11 +2865,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
}
|
||||
Tool::Type | Tool::VerticalType => egui::CursorIcon::Text,
|
||||
Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, view.zoom),
|
||||
Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom),
|
||||
Tool::RedEye => {
|
||||
let pupil = app.ui.tool_options.red_eye_pupil_size.clamp(1.0, 100.0);
|
||||
let r_doc = photocraft_algo::redeye::search_radius(pupil);
|
||||
let r = (r_doc * view.zoom).max(2.0);
|
||||
let r = (r_doc * xf.zoom).max(2.0);
|
||||
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
|
||||
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
|
||||
for (w, c) in [(2.5, Color32::from_black_alpha(140)), (1.0, Color32::from_white_alpha(220))] {
|
||||
@@ -2904,14 +2919,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under
|
||||
/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the
|
||||
/// centre of the view instead (#204).
|
||||
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool) {
|
||||
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) {
|
||||
let before = xf.to_doc(p);
|
||||
view.zoom = new_zoom;
|
||||
if center_on_point {
|
||||
view.center = [before[0] as f32, before[1] as f32];
|
||||
return;
|
||||
}
|
||||
let u = xf.unmap_vec((p - xf.rect.center()) / new_zoom);
|
||||
// `new_zoom` is device px per document pixel; the pointer vector is in egui points.
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let u = xf.unmap_vec((p - xf.rect.center()) / (new_zoom / ppp));
|
||||
view.center = [before[0] as f32 - u.x, before[1] as f32 - u.y];
|
||||
}
|
||||
|
||||
@@ -3448,7 +3465,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui:
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_move(app, x, y);
|
||||
}
|
||||
let zoom = app.current_zoom();
|
||||
let zoom = app.point_zoom();
|
||||
if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) {
|
||||
d.track(mods);
|
||||
d.shift_to([x, y]);
|
||||
@@ -3735,7 +3752,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_up(app);
|
||||
}
|
||||
let zoom = app.current_zoom();
|
||||
let zoom = app.point_zoom();
|
||||
let Some(mut d) = app.drag.take() else { return };
|
||||
d.track(mods);
|
||||
if d.reposition {
|
||||
@@ -3987,7 +4004,7 @@ fn polygon_click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers)
|
||||
.ui
|
||||
.polygon
|
||||
.first()
|
||||
.is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.current_zoom().max(0.01) as f64);
|
||||
.is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.point_zoom().max(0.01) as f64);
|
||||
if close {
|
||||
commit_polygon(app);
|
||||
return;
|
||||
@@ -4905,6 +4922,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_pixel_selection_traces_at_hundred_percent_on_a_scaled_display() {
|
||||
// #1943 review: the outline step is display resolution in device pixels, not points, so a
|
||||
// 1 px selection still traces at 100 % on a 2x display (a points-based step sampled every
|
||||
// second pixel and lost it: zero segments).
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("select.rect", json!({"x": 50, "y": 50, "width": 1, "height": 100})).unwrap();
|
||||
app.run("view.actualPixels", json!({})).unwrap();
|
||||
app.sync_views();
|
||||
let view = app.ui.views[0].clone();
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(2.0);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), view.clone(), true);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(app.ppp, 2.0, "the primary canvas records the display scale");
|
||||
let segs = app.outline_cache.as_ref().map_or(0, |(_, _, s)| s.len());
|
||||
assert!(segs > 0, "a 1 px selection traces at 100 % on a 2x display");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secondary_canvas_does_not_overwrite_the_main_canvas_scale() {
|
||||
// #1943 review: a document window can sit on a display with another scale; only the
|
||||
// primary canvas records `app.ppp`, so the main mapping doesn't change under it.
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("view.actualPixels", json!({})).unwrap();
|
||||
app.sync_views();
|
||||
let view = app.ui.views[0].clone();
|
||||
let frame = |app: &mut PhotocraftApp, ppp: f32, primary: bool| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
canvas_view(app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), view.clone(), primary);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, 2.0, true);
|
||||
assert_eq!(app.ppp, 2.0);
|
||||
frame(&mut app, 1.0, false);
|
||||
assert_eq!(app.ppp, 2.0, "a secondary window's scale doesn't replace the main canvas's");
|
||||
// The main canvas mapping still uses its own scale: 100 % is 1 doc px per physical px.
|
||||
let xf = ViewXform::active(&app).unwrap();
|
||||
let step = xf.to_screen(1.0, 0.0) - xf.to_screen(0.0, 0.0);
|
||||
assert!((step.x - 0.5).abs() < 1e-3, "{step:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_drags_paint_no_stand_in_shape_over_the_canvas() {
|
||||
// #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide
|
||||
@@ -4998,30 +5066,66 @@ mod tests {
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
let mut view = View { fill_pending: true, ..View::default() };
|
||||
|
||||
fill_view(&mut view, &doc, vec2(600.0, 600.0));
|
||||
fill_view(&mut view, &doc, vec2(600.0, 600.0), 1.0);
|
||||
|
||||
assert_eq!(view.zoom, 3.0, "the short edge fills the available height");
|
||||
assert_eq!(view.center, [200.0, 100.0]);
|
||||
assert!(!view.fill_pending);
|
||||
|
||||
// A scaled display stores the physical factor: the same point zoom is 2× the device px.
|
||||
let mut view = View { fill_pending: true, ..View::default() };
|
||||
fill_view(&mut view, &doc, vec2(600.0, 600.0), 2.0);
|
||||
assert_eq!(view.zoom, 6.0);
|
||||
|
||||
// Fit never magnifies past the 1 point-per-pixel cap; on a scaled display the cap is 2.
|
||||
let mut view = View { fit_pending: true, ..View::default() };
|
||||
fit_view(&mut view, &doc, vec2(600.0, 600.0), 1.0);
|
||||
assert_eq!(view.zoom, 1.0);
|
||||
let mut view = View { fit_pending: true, ..View::default() };
|
||||
fit_view(&mut view, &doc, vec2(600.0, 600.0), 2.0);
|
||||
assert_eq!(view.zoom, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hundred_percent_is_one_physical_pixel_per_document_pixel() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
app.sync_views();
|
||||
for ppp in [1.0f32, 1.25, 2.0] {
|
||||
app.ppp = ppp;
|
||||
let xf = ViewXform::active(&app).unwrap();
|
||||
// 100% zoom: one document pixel is one physical pixel, i.e. 1/ppp egui points.
|
||||
let step = xf.to_screen(1.0, 0.0) - xf.to_screen(0.0, 0.0);
|
||||
// `to_screen` differences around the view centre cancel in f32; 1e-3 is still exact
|
||||
// enough to see a wrong ppp factor (which would be a 20–100% error).
|
||||
assert!((step.x - 1.0 / ppp).abs() < 1e-3, "ppp {ppp}: {step:?} want {:?}", 1.0 / ppp);
|
||||
assert!((app.point_zoom() - 1.0 / ppp).abs() < 1e-5, "ppp {ppp}: {}", app.point_zoom());
|
||||
// Screen ↔ document stays an exact round trip.
|
||||
let back = xf.to_doc(xf.to_screen(123.0, 45.0));
|
||||
assert!((back[0] - 123.0).abs() < 1e-3 && (back[1] - 45.0).abs() < 1e-3, "ppp {ppp}: {back:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_about_centres_the_clicked_point_with_the_preference() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0 };
|
||||
let p = pos2(600.0, 200.0);
|
||||
let doc = xf.to_doc(p);
|
||||
// Off: the document point under the pointer stays under it.
|
||||
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
|
||||
zoom_about(&mut view, &xf, p, 2.0, false);
|
||||
let after = ViewXform { rect, zoom: 2.0, center: view.center, flip: false, rotation: 0.0 };
|
||||
let s = after.to_screen(doc[0] as f32, doc[1] as f32);
|
||||
assert!((s - p).length() < 0.5, "{s:?} vs {p:?}");
|
||||
// On: the clicked point is the view centre.
|
||||
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
|
||||
zoom_about(&mut view, &xf, p, 2.0, true);
|
||||
assert!((view.center[0] - doc[0] as f32).abs() < 0.5, "{:?}", view.center);
|
||||
assert!((view.center[1] - doc[1] as f32).abs() < 0.5, "{:?}", view.center);
|
||||
for ppp in [1.0f32, 2.0] {
|
||||
// `View::zoom` is physical; the transform works in points.
|
||||
let xf = ViewXform { rect, zoom: 1.0 / ppp, center: [400.0, 300.0], flip: false, rotation: 0.0 };
|
||||
let doc = xf.to_doc(p);
|
||||
// Off: the document point under the pointer stays under it.
|
||||
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
|
||||
zoom_about(&mut view, &xf, p, 2.0, false, ppp);
|
||||
let after = ViewXform { rect, zoom: 2.0 / ppp, center: view.center, flip: false, rotation: 0.0 };
|
||||
let s = after.to_screen(doc[0] as f32, doc[1] as f32);
|
||||
assert!((s - p).length() < 0.5, "ppp {ppp}: {s:?} vs {p:?}");
|
||||
// On: the clicked point is the view centre.
|
||||
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
|
||||
zoom_about(&mut view, &xf, p, 2.0, true, ppp);
|
||||
assert!((view.center[0] - doc[0] as f32).abs() < 0.5, "ppp {ppp}: {:?}", view.center);
|
||||
assert!((view.center[1] - doc[1] as f32).abs() < 0.5, "ppp {ppp}: {:?}", view.center);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -385,7 +385,7 @@ pub fn press(app: &mut PhotocraftApp) -> bool {
|
||||
}
|
||||
|
||||
fn tolerance(app: &PhotocraftApp) -> f64 {
|
||||
HANDLE_PX / (app.current_zoom() as f64).max(0.01)
|
||||
HANDLE_PX / (app.point_zoom() as f64).max(0.01)
|
||||
}
|
||||
|
||||
/// The pending frame's angle (degrees), 0 when it isn't a finite number.
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
/// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press
|
||||
/// that hit nothing.
|
||||
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool {
|
||||
let tol = 6.0 / f64::from(app.current_zoom().max(0.01));
|
||||
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let ds = &app.ui.direct_selection;
|
||||
let found = candidates(app).into_iter().find_map(|(r, path)| {
|
||||
let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() };
|
||||
@@ -251,7 +251,7 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -
|
||||
/// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except
|
||||
/// Pen + ⌥ on an anchor, which makes it a corner.
|
||||
fn release(app: &mut PhotocraftApp, target: PathRef, edit: &Edit, start: [f64; 2], end: [f64; 2]) {
|
||||
let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.current_zoom()) >= 1.0;
|
||||
let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.point_zoom()) >= 1.0;
|
||||
let (id, mut params) = match edit {
|
||||
Edit::Convert([s, k]) if !moved => ("path.convertPoint", json!({"subpath": s, "knot": k})),
|
||||
_ if !moved => return,
|
||||
|
||||
@@ -217,6 +217,6 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// Screen distance (document px) that counts as "on" a handle at the current zoom.
|
||||
pub(crate) fn tolerance(app: &PhotocraftApp) -> f64 {
|
||||
let z = app.session.active_index().and_then(|i| app.ui.views.get(i)).map_or(1.0, |v| v.zoom.max(0.01));
|
||||
let z = app.point_zoom().max(0.01);
|
||||
8.0 / f64::from(z)
|
||||
}
|
||||
|
||||
@@ -1637,9 +1637,10 @@ impl CanvasCallback {
|
||||
let p = &self.params;
|
||||
let (origin, scale) = self.placement(ppp);
|
||||
let (mode, lod) = filter_mode(scale);
|
||||
// The pixel grid shows above 500% and lightens a dark pixel by about a quarter (the
|
||||
// constants are shared with the CPU path, pixel_grid.rs).
|
||||
let grid = if p.pixel_grid && crate::pixel_grid::shows_at(p.zoom) { crate::pixel_grid::STRENGTH } else { 0.0 };
|
||||
// The pixel grid shows above 500% (the constants are shared with the CPU path,
|
||||
// pixel_grid.rs). `p.zoom` is screen points per document pixel; the shared threshold is
|
||||
// in device pixels, so scale by the display's pixels per point.
|
||||
let grid = if p.pixel_grid && crate::pixel_grid::shows_at(p.zoom * ppp) { crate::pixel_grid::STRENGTH } else { 0.0 };
|
||||
let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 };
|
||||
let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color);
|
||||
// 32-bit preview: linear-light gain 2^exposure (0 = off) and 1 / gamma.
|
||||
|
||||
@@ -210,7 +210,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
app.gradient.drag = None;
|
||||
return false;
|
||||
}
|
||||
let zoom = app.current_zoom().max(0.01);
|
||||
let zoom = app.point_zoom().max(0.01);
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => {
|
||||
let p = [x as f32, y as f32];
|
||||
@@ -231,7 +231,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
}
|
||||
|
||||
fn drag_to(app: &mut PhotocraftApp, p: [f32; 2], mods: egui::Modifiers) {
|
||||
let zoom = app.current_zoom().max(0.01);
|
||||
let zoom = app.point_zoom().max(0.01);
|
||||
let active = active_gradient(app);
|
||||
let Some(drag) = app.gradient.drag.as_mut() else { return };
|
||||
// ⇧: 45° gradient angles, the same snap as the classic drag (stroke_constraint.rs).
|
||||
@@ -284,7 +284,7 @@ fn drag_to(app: &mut PhotocraftApp, p: [f32; 2], mods: egui::Modifiers) {
|
||||
|
||||
fn finish(app: &mut PhotocraftApp) {
|
||||
let Some(drag) = app.gradient.drag.take() else { return };
|
||||
let zoom = app.current_zoom().max(0.01);
|
||||
let zoom = app.point_zoom().max(0.01);
|
||||
match drag {
|
||||
Drag::Draw { from, to, redraw } => {
|
||||
if dist(from, to) * zoom < 2.0 {
|
||||
|
||||
@@ -122,7 +122,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
ToolEvent::Down { .. } => {
|
||||
if active(app) {
|
||||
// A click on the first point, or on the last one again, closes the outline.
|
||||
let reach = 8.0 / app.current_zoom().max(0.01) as f64;
|
||||
let reach = 8.0 / app.point_zoom().max(0.01) as f64;
|
||||
let near = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) < reach;
|
||||
let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start) || d.points.last().is_some_and(|l| near([l[0], l[1]]))));
|
||||
if close {
|
||||
|
||||
@@ -396,6 +396,10 @@ pub struct PhotocraftApp {
|
||||
fonts_ready: bool,
|
||||
/// Screen rect of the main canvas last frame (for overlays and the navigator).
|
||||
pub last_canvas_rect: egui::Rect,
|
||||
/// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`). The
|
||||
/// canvas maps document pixels to physical pixels, so point-space geometry divides the view
|
||||
/// zoom by this (see [`Self::point_zoom`]).
|
||||
pub ppp: f32,
|
||||
/// The document area showing the active document's canvas last frame (not the tabs, the
|
||||
/// start screen or an opening file's card): files dropped here are placed as layers.
|
||||
pub(crate) drop_canvas_rect: Option<egui::Rect>,
|
||||
@@ -549,6 +553,7 @@ impl PhotocraftApp {
|
||||
last_window_title: String::new(),
|
||||
fonts_ready: false,
|
||||
last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)),
|
||||
ppp: 1.0,
|
||||
drop_canvas_rect: None,
|
||||
tab_strip: None,
|
||||
drop_places: Default::default(),
|
||||
@@ -1478,10 +1483,23 @@ impl PhotocraftApp {
|
||||
}
|
||||
|
||||
impl PhotocraftApp {
|
||||
/// Zoom of the active document's main view (screen points per document pixel).
|
||||
/// Zoom of the active document's main view: screen (device) pixels per document pixel, the
|
||||
/// user-facing factor (`100%` is `1.0`).
|
||||
pub fn current_zoom(&self) -> f32 {
|
||||
self.session.active_index().and_then(|i| self.ui.views.get(i)).map_or(1.0, |v| v.zoom)
|
||||
}
|
||||
|
||||
/// Screen (device) pixels per egui point of the canvas (`ctx.pixels_per_point`), as of the
|
||||
/// last canvas frame. `1.0` before the first frame.
|
||||
pub fn canvas_ppp(&self) -> f32 {
|
||||
if self.ppp.is_finite() && self.ppp > 0.0 { self.ppp } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The active view's zoom in egui points per document pixel: the unit every screen-space
|
||||
/// distance, tolerance and texture-level choice on the canvas is measured in.
|
||||
pub fn point_zoom(&self) -> f32 {
|
||||
self.current_zoom() / self.canvas_ppp()
|
||||
}
|
||||
}
|
||||
|
||||
impl PhotocraftApp {
|
||||
|
||||
@@ -73,12 +73,12 @@ fn settings(app: &PhotocraftApp, pressure: f32) -> Settings {
|
||||
fn spacing(app: &PhotocraftApp) -> f64 {
|
||||
let f = app.ui.tool_options.magnetic_frequency;
|
||||
let f = if f.is_finite() { f64::from(f.clamp(0.0, 100.0)) } else { 57.0 };
|
||||
(8.0 + (100.0 - f) * 0.9) / f64::from(app.current_zoom().max(0.01))
|
||||
(8.0 + (100.0 - f) * 0.9) / f64::from(app.point_zoom().max(0.01))
|
||||
}
|
||||
|
||||
/// Within this distance of the first point (document px), a click closes the border.
|
||||
fn close_radius(app: &PhotocraftApp) -> f64 {
|
||||
8.0 / f64::from(app.current_zoom().max(0.01))
|
||||
8.0 / f64::from(app.point_zoom().max(0.01))
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
|
||||
@@ -389,7 +389,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
let visible = !external
|
||||
&& app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| {
|
||||
let v = &app.ui.views[i];
|
||||
let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / v.zoom, app.last_canvas_rect.height() / 2.0 / v.zoom);
|
||||
let pz = app.point_zoom();
|
||||
let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / pz, app.last_canvas_rect.height() / 2.0 / pz);
|
||||
let r =
|
||||
photocraft_geom::Rect::new((v.center[0] - hw) as i32, (v.center[1] - hh) as i32, (v.center[0] + hw) as i32, (v.center[1] + hh) as i32);
|
||||
!clip.bounds.intersect(&r).is_empty()
|
||||
|
||||
@@ -142,7 +142,7 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64;
|
||||
d = constrain(d, app.move_mods.last, if pixels { TRANSFORM_DIRECTIONS } else { MOVE_DIRECTIONS });
|
||||
}
|
||||
app.move_mods.last = Some(d);
|
||||
let zoom = f64::from(app.current_zoom().max(0.01));
|
||||
let zoom = f64::from(app.point_zoom().max(0.01));
|
||||
if app.move_mods.alt && !pixels && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
|
||||
app.move_mods.dup_from = duplicate(app);
|
||||
// Only once per drag, even if duplicating failed.
|
||||
|
||||
@@ -1468,8 +1468,9 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Visible-area rectangle.
|
||||
let v = app.ui.views[idx].clone();
|
||||
let canvas = app.last_canvas_rect;
|
||||
let vw = canvas.width() / v.zoom * s;
|
||||
let vh = canvas.height() / v.zoom * s;
|
||||
let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6);
|
||||
let vw = canvas.width() / point_zoom * s;
|
||||
let vh = canvas.height() / point_zoom * s;
|
||||
let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s);
|
||||
let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0));
|
||||
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle);
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
if app.patch_preview.as_ref()?.offsets.last().map(|o| o.0) != Some(offset) {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
// Zoomed out, a finer colour fit than the screen shows is wasted.
|
||||
let min_step = (1.0 / app.current_zoom().max(1e-3)).floor().clamp(1.0, 64.0) as u32;
|
||||
let min_step = (1.0 / app.point_zoom().max(1e-3)).floor().clamp(1.0, 64.0) as u32;
|
||||
let params = json!({"offset": offset, "mode": mode, "target": crate::canvas::paint_target(app)});
|
||||
let result = photocraft_engine::retouch_cmds::patch_preview(&app.session, ¶ms, MAX_CELLS, min_step);
|
||||
let p = app.patch_preview.as_mut()?;
|
||||
|
||||
@@ -33,7 +33,8 @@ const REGION_SNAP: i32 = 64;
|
||||
/// produce one segment per cell; past this the grid is simply left off the remainder.
|
||||
const MAX_SEGMENTS: usize = 60_000;
|
||||
|
||||
/// Whether the grid is drawn at `zoom`.
|
||||
/// Whether the grid is drawn at `zoom`, in device (physical) pixels per document pixel: the
|
||||
/// canvas view zoom on the CPU path, the shader's point scale times `pixels_per_point` on the GPU.
|
||||
pub fn shows_at(zoom: f32) -> bool {
|
||||
zoom > MIN_ZOOM
|
||||
}
|
||||
|
||||
@@ -515,7 +515,13 @@ fn empty(ui: &mut egui::Ui, s: &str) {
|
||||
fn doc_point(app: &PhotocraftApp, pos: Pos2) -> Option<[f64; 2]> {
|
||||
let i = app.session.active_index()?;
|
||||
let v = app.ui.views.get(i)?;
|
||||
let xf = crate::canvas::ViewXform { rect: app.last_canvas_rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation };
|
||||
let xf = crate::canvas::ViewXform {
|
||||
rect: app.last_canvas_rect,
|
||||
zoom: v.zoom / app.canvas_ppp(),
|
||||
center: v.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
};
|
||||
Some(xf.to_doc(pos))
|
||||
}
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> {
|
||||
return None;
|
||||
}
|
||||
let doc = &app.session.active()?.doc;
|
||||
let tol = 4.0 / app.current_zoom().max(0.01) as f64;
|
||||
let tol = 4.0 / app.point_zoom().max(0.01) as f64;
|
||||
for (i, g) in doc.guides.vertical.iter().enumerate() {
|
||||
if (*g as f64 - x).abs() <= tol {
|
||||
return Some((true, i));
|
||||
|
||||
@@ -87,14 +87,15 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
|
||||
center.clamp(a.min(b), a.max(b))
|
||||
}
|
||||
|
||||
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points. True
|
||||
/// when the view moved (the caller then repaints).
|
||||
pub fn clamp_view(view: &mut View, size: egui::Vec2) -> bool {
|
||||
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a
|
||||
/// display with `ppp` physical pixels per point. True when the view moved (then repaint).
|
||||
pub fn clamp_view(view: &mut View, size: egui::Vec2, ppp: f32) -> bool {
|
||||
let [w, h] = view.doc_size;
|
||||
if w == 0 || h == 0 {
|
||||
return false;
|
||||
}
|
||||
let c = [clamp_axis(view.center[0], w as f32, size.x, view.zoom), clamp_axis(view.center[1], h as f32, size.y, view.zoom)];
|
||||
let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let c = [clamp_axis(view.center[0], w as f32, size.x, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)];
|
||||
// Ignore sub-pixel float noise so the clamp never keeps requesting frames.
|
||||
let moved = (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3;
|
||||
if moved {
|
||||
@@ -127,7 +128,9 @@ pub fn bar_rects(rect: Rect, horizontal: bool, vertical: bool) -> (Option<Rect>,
|
||||
/// Returns true when the pointer is over a bar (the canvas then leaves the pointer alone).
|
||||
pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, overscroll: bool, key: Id) -> bool {
|
||||
let [w, h] = view.doc_size;
|
||||
let zoom = view.zoom;
|
||||
// `Span` works in egui points; `View::zoom` is device pixels per document pixel.
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
// Horizontal axis in "screen order": with the view flipped, run the image backwards.
|
||||
let hx = |c: f32| if flip { w as f32 - c } else { c };
|
||||
let with_extent = |s: Span| (s, s.extent(overscroll));
|
||||
@@ -285,9 +288,9 @@ mod tests {
|
||||
assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's");
|
||||
assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred");
|
||||
let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800], rotation: 0.0 };
|
||||
assert!(clamp_view(&mut v, vec2(500.0, 400.0)));
|
||||
assert!(clamp_view(&mut v, vec2(500.0, 400.0), 1.0));
|
||||
assert_eq!(v.center, [250.0, 200.0]);
|
||||
assert!(!clamp_view(&mut v, vec2(500.0, 400.0)), "stable: no repaint loop");
|
||||
assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -304,7 +307,7 @@ mod tests {
|
||||
assert!(at.is_finite() && len.is_finite());
|
||||
assert_eq!(s.doc_per_point((0.0, 1000.0), 0.0), 0.0);
|
||||
let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, fill_pending: false, doc_size: [0, 0], rotation: 0.0 };
|
||||
assert!(!clamp_view(&mut v, vec2(500.0, 400.0)));
|
||||
assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0));
|
||||
}
|
||||
|
||||
mod canvas {
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn options(app: &PhotocraftApp) -> SnapOptions {
|
||||
|
||||
/// Snap tolerance in document pixels at the current zoom.
|
||||
pub fn tolerance(app: &PhotocraftApp) -> f64 {
|
||||
SNAP_PX / app.current_zoom().max(0.01) as f64
|
||||
SNAP_PX / app.point_zoom().max(0.01) as f64
|
||||
}
|
||||
|
||||
fn smart_on(app: &PhotocraftApp) -> bool {
|
||||
|
||||
@@ -381,7 +381,9 @@ pub enum DialogKind {
|
||||
/// Per-document view (camera) state.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
/// Screen pixels per document pixel.
|
||||
/// Screen (device) pixels per document pixel: the user-facing zoom (`100%` is `1.0`).
|
||||
/// Canvas geometry works in egui points and divides by `ctx.pixels_per_point`
|
||||
/// (`PhotocraftApp::point_zoom`), so a scaled display doesn't magnify the image.
|
||||
pub zoom: f32,
|
||||
/// Document-space point shown at the canvas centre.
|
||||
pub center: [f32; 2],
|
||||
|
||||
@@ -77,7 +77,7 @@ pub const HANDLE_PX: f64 = 12.0;
|
||||
|
||||
/// [`HANDLE_PX`] in document pixels at the current zoom.
|
||||
pub fn handle_tolerance(app: &PhotocraftApp) -> f64 {
|
||||
HANDLE_PX / app.current_zoom().max(0.01) as f64
|
||||
HANDLE_PX / app.point_zoom().max(0.01) as f64
|
||||
}
|
||||
|
||||
/// The box as one undo step restores it.
|
||||
|
||||
@@ -54,7 +54,7 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) {
|
||||
/// Topmost visible type layer whose laid-out text contains the document point.
|
||||
fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
|
||||
let doc = app.session.active()?.doc.clone();
|
||||
let slop = 6.0 / app.current_zoom().max(0.01);
|
||||
let slop = 6.0 / app.point_zoom().max(0.01);
|
||||
let mut ids: Vec<LayerId> =
|
||||
doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
|
||||
ids.reverse(); // walk() is bottom-up; hit the topmost first
|
||||
@@ -160,7 +160,7 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64
|
||||
let (r, b) = (bx + w, by + h);
|
||||
let (mx, my) = (bx + w / 2.0, by + h / 2.0);
|
||||
let spots = [(bx, by), (r, by), (r, b), (bx, b), (mx, by), (r, my), (mx, b), (bx, my)];
|
||||
let tol = f64::from(6.0 / app.current_zoom().max(0.01));
|
||||
let tol = f64::from(6.0 / app.point_zoom().max(0.01));
|
||||
spots
|
||||
.iter()
|
||||
.position(|&(sx, sy)| {
|
||||
@@ -254,7 +254,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs());
|
||||
let min = 4.0 / app.current_zoom().max(0.01) as f64;
|
||||
let min = 4.0 / app.point_zoom().max(0.01) as f64;
|
||||
let o = app.ui.tool_options.clone();
|
||||
// Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default).
|
||||
let text = if app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" };
|
||||
|
||||
@@ -50,7 +50,7 @@ fn xf(app: &PhotocraftApp) -> ViewXform {
|
||||
let v = &app.ui.views[0];
|
||||
ViewXform {
|
||||
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
|
||||
zoom: v.zoom,
|
||||
zoom: v.zoom / app.canvas_ppp(),
|
||||
center: v.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
@@ -616,7 +616,8 @@ fn frame_point(app: &PhotocraftApp, p: [f64; 2]) -> Pos2 {
|
||||
|
||||
fn assert_affine(a: Affine, b: Affine) {
|
||||
for (x, y) in a.m.into_iter().zip(b.m) {
|
||||
assert!((x - y).abs() < 2e-4, "{a:?} != {b:?}");
|
||||
// Translations around 500 doc px at a scaled ppp cancel to ~2.7e-4 in f32 rounding.
|
||||
assert!((x - y).abs() < 1e-3, "{a:?} != {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool {
|
||||
|
||||
fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option<Hit> {
|
||||
let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app));
|
||||
(h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.current_zoom().max(0.01)))).then_some(h)
|
||||
(h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.point_zoom().max(0.01)))).then_some(h)
|
||||
}
|
||||
|
||||
/// Type uses legacy scaling: Shift constrains; Ctrl/Cmd is the mode key, never corner Distort.
|
||||
|
||||
@@ -132,7 +132,7 @@ fn pen_to_json(pen: &PenPath, closed: bool) -> Value {
|
||||
/// Pen press: close on the first anchor, reshape the last anchor's outgoing handle,
|
||||
/// or add a new anchor (dragging a new anchor pulls symmetrical handles).
|
||||
pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
let tol = 6.0 / app.current_zoom().max(0.01) as f64;
|
||||
let tol = 6.0 / app.point_zoom().max(0.01) as f64;
|
||||
let pen = app.ui.pen.get_or_insert_with(PenPath::default);
|
||||
if let Some(first) = pen.knots.first().map(|k| k[0])
|
||||
&& pen.knots.len() >= 2
|
||||
|
||||
@@ -607,8 +607,11 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let size = [st.doc.size.width, st.doc.size.height];
|
||||
let area = app.last_canvas_rect.size();
|
||||
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
|
||||
// The area is in egui points; the stored zoom is device pixels per document pixel.
|
||||
let ppp = app.canvas_ppp();
|
||||
let points = ((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32);
|
||||
let v = &mut app.ui.views[i];
|
||||
v.zoom = crate::zoom_levels::clamp(((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32), size);
|
||||
v.zoom = crate::zoom_levels::clamp(points * ppp, size);
|
||||
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
|
||||
v.fit_pending = false;
|
||||
Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]}))
|
||||
|
||||
@@ -39,10 +39,12 @@ pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 {
|
||||
crate::zoom_levels::clamp(zoom0 * e.exp2(), size)
|
||||
}
|
||||
|
||||
/// Set `view` to `zoom` with document point `doc` shown at screen point `screen`.
|
||||
pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32) {
|
||||
/// Set `view` to `zoom` (device pixels per document pixel) with document point `doc` shown at
|
||||
/// screen point `screen` (egui points) on a display with `ppp` physical pixels per point.
|
||||
pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32, ppp: f32) {
|
||||
view.zoom = zoom;
|
||||
let d = (screen - xf.rect.center()) / zoom;
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let d = (screen - xf.rect.center()) / (zoom / ppp);
|
||||
let dx = if xf.flip { -d.x } else { d.x };
|
||||
view.center = [doc[0] as f32 - dx, doc[1] as f32 - d.y];
|
||||
}
|
||||
@@ -62,14 +64,14 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
|
||||
if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) {
|
||||
match z.rect_to.as_mut() {
|
||||
Some(to) => *to = p,
|
||||
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size)),
|
||||
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size), app.ppp),
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(id(), z));
|
||||
}
|
||||
if b.stopped {
|
||||
ctx.data_mut(|d| d.remove::<ZoomDrag>(id()));
|
||||
if let Some(to) = z.rect_to {
|
||||
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt));
|
||||
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt), app.ppp);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -77,16 +79,17 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
|
||||
|
||||
/// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom
|
||||
/// at the press point; ⌥ steps out).
|
||||
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool) {
|
||||
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool, ppp: f32) {
|
||||
if out || r.width() < 4.0 || r.height() < 4.0 {
|
||||
let doc = xf.to_doc(anchor);
|
||||
anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size));
|
||||
anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size), ppp);
|
||||
return;
|
||||
}
|
||||
// `k` is the ratio of two screen-point lengths, so it scales the zoom in any unit.
|
||||
let k = (xf.rect.width() / r.width()).min(xf.rect.height() / r.height());
|
||||
let zoom = crate::zoom_levels::clamp(view.zoom * k, view.doc_size);
|
||||
let c = xf.to_doc(r.center());
|
||||
anchor_view(view, xf, xf.rect.center(), c, zoom);
|
||||
anchor_view(view, xf, xf.rect.center(), c, zoom, ppp);
|
||||
}
|
||||
|
||||
/// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants.
|
||||
|
||||
@@ -84,15 +84,9 @@ fn setup(h: &mut Harness, background: &str, tool: &str) {
|
||||
}
|
||||
|
||||
fn xf(h: &Harness) -> ViewXform {
|
||||
let app = h.state();
|
||||
let v = &app.ui.views[0];
|
||||
ViewXform {
|
||||
rect: photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect),
|
||||
zoom: v.zoom,
|
||||
center: v.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
}
|
||||
// The same mapping the canvas draws with: `View::zoom` is device pixels per document pixel,
|
||||
// and the transform works in the viewport's egui points.
|
||||
ViewXform::active(h.state()).expect("an active document view")
|
||||
}
|
||||
|
||||
/// Screen point (egui points) of document point `(x, y)`.
|
||||
@@ -182,11 +176,11 @@ fn marquee_previews_show_while_dragging_on_white_and_black_at_1x_and_2x() {
|
||||
}
|
||||
} else {
|
||||
// The ellipse touches its box at the edge midpoints: check short arcs there.
|
||||
let c = screen(&h, 200.0, 150.0);
|
||||
let (rx, ry) = (100.0, 70.0);
|
||||
for (dx, dy) in [(0.0, -ry), (rx, 0.0), (0.0, ry), (-rx, 0.0)] {
|
||||
let m = c + egui::vec2(dx, dy);
|
||||
let along = if dx == 0.0 { egui::vec2(14.0, 0.0) } else { egui::vec2(0.0, 14.0) };
|
||||
// The arcs keep a physical length at any scale, so their sagitta stays inside
|
||||
// `coverage`'s ±2 physical px search.
|
||||
for (mx, my) in [(200.0, 80.0), (300.0, 150.0), (200.0, 220.0), (100.0, 150.0)] {
|
||||
let m = screen(&h, mx, my);
|
||||
let along = if mx == 200.0 { egui::vec2(14.0 / ppp, 0.0) } else { egui::vec2(0.0, 14.0 / ppp) };
|
||||
let now = coverage(&img, ppp, m - along, m + along, ink(bg));
|
||||
assert!(now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {now:.2} contrasting");
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
|
||||
- `engine.commands`: list commands with enablement
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
|
||||
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
|
||||
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
|
||||
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
|
||||
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
|
||||
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
|
||||
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418) | low–medium | Needs recurring visual QA with realistic documents. |
|
||||
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. |
|
||||
| Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, single row/column marquee, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
|
||||
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. |
|
||||
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
|
||||
|
||||
Reference in New Issue
Block a user