fix(ui): shape tools show the shape while dragging (#87)

The drag drew a 1 px white box (invisible on white canvases). It now
previews the exact path shape.create commits, filled and stroked, with
the accent path outline on top. shape_geometry is shared by the preview
and finish_shape; engine::vector_cmds::shape_path builds the path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sommerper 2026-10-05 07:02:28 +02:00 committed by GitHub
parent 3400412616
commit 60c5df70b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 84 additions and 43 deletions

View File

@ -349,6 +349,12 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>)
})
}
/// The path `shape.create` draws for live-shape params `p` (the Shape tools preview it while dragging).
pub fn shape_path(p: &Value) -> Result<Path> {
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect");
Ok(vector::shapes::live_path(&live_from_params("shape.create", kind, p, None)?))
}
fn live_kind(l: &LiveShape) -> &'static str {
match l {
LiveShape::Rect { radii, .. } if radii.iter().any(|r| *r > 0.0) => "roundedRect",

View File

@ -1455,22 +1455,10 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
painter.add(egui::Shape::line(pts, Stroke::new(w, col)));
}
}
Tool::Line => {
painter.line_segment(
[xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)],
Stroke::new(1.0, crate::theme::Tokens::get(painter.ctx()).accent),
);
}
Tool::RectMarquee
| Tool::EllipseMarquee
| Tool::ObjectSelection
| Tool::Rectangle
| Tool::EllipseShape
| Tool::Triangle
| Tool::Polygon
| Tool::CustomShape => {
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.modifiers),
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => {
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
if matches!(d.tool, Tool::EllipseMarquee | Tool::EllipseShape) {
if d.tool == Tool::EllipseMarquee {
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE)));
} else {
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);

View File

@ -10,7 +10,7 @@ use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
use crate::state::Tool;
use crate::state::{Tool, ToolOptions};
use crate::theme::Tokens;
/// Pen tool path under construction: knots as [anchor, in, out] (document px).
@ -25,9 +25,14 @@ pub fn is_shape_tool(t: Tool) -> bool {
matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape)
}
fn hex(c: [f32; 4]) -> String {
fn rgb32(c: [f32; 4]) -> Color32 {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
}
fn hex(c: [f32; 4]) -> String {
let c = rgb32(c);
format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())
}
fn stroke_param(app: &PhotocraftApp) -> Value {
@ -35,12 +40,10 @@ fn stroke_param(app: &PhotocraftApp) -> Value {
if o.stroke_width > 0.0 { json!({"width": o.stroke_width, "color": hex(app.session.tools.background)}) } else { Value::Null }
}
/// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre.
pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
let o = app.ui.tool_options.clone();
let fill = if o.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null };
let stroke = stroke_param(app);
let p = if tool == Tool::Line {
/// Geometry of a Shape-tool drag as `shape.create` params, without fill and stroke (None when too
/// small): ⇧ constrains proportions, ⌥ draws from the centre. Custom shapes give their box as a `rect`.
fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<Value> {
Some(if tool == Tool::Line {
let (mut dx, mut dy) = (end[0] - start[0], end[1] - start[1]);
if mods.shift {
// Snap to 45°.
@ -49,9 +52,9 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [
(dx, dy) = (len * a.cos(), len * a.sin());
}
if dx.hypot(dy) < 1.0 {
return;
return None;
}
json!({"kind": "line", "from": start, "to": [start[0] + dx, start[1] + dy], "weight": o.line_weight.max(1.0), "fill": fill, "stroke": stroke})
json!({"kind": "line", "from": start, "to": [start[0] + dx, start[1] + dy], "weight": o.line_weight.max(1.0)})
} else {
let (mut w, mut h) = (end[0] - start[0], end[1] - start[1]);
if mods.shift {
@ -62,29 +65,55 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [
let (w, h) = if mods.alt { (w * 2.0, h * 2.0) } else { (w, h) };
let rect = [x0.min(x0 + w).round(), y0.min(y0 + h).round(), w.abs().round(), h.abs().round()];
if rect[2] < 1.0 || rect[3] < 1.0 {
return;
}
if tool == Tool::CustomShape {
// ⇧ keeps the shape's proportions (the rect is already squared).
crate::preset_panels::finish_custom_shape(app, rect, mods.shift, fill, stroke);
return;
return None;
}
match tool {
Tool::Rectangle if o.corner_radius > 0.0 => {
json!({"kind": "roundedRect", "rect": rect, "radii": vec![o.corner_radius; 4], "fill": fill, "stroke": stroke})
}
Tool::Rectangle => json!({"kind": "rect", "rect": rect, "fill": fill, "stroke": stroke}),
Tool::EllipseShape => json!({"kind": "ellipse", "rect": rect, "fill": fill, "stroke": stroke}),
Tool::Triangle => json!({"kind": "polygon", "rect": rect, "sides": 3, "fill": fill, "stroke": stroke}),
_ => json!({"kind": "polygon", "rect": rect, "sides": o.polygon_sides.max(3), "fill": fill, "stroke": stroke}),
Tool::Rectangle if o.corner_radius > 0.0 => json!({"kind": "roundedRect", "rect": rect, "radii": vec![o.corner_radius; 4]}),
Tool::Rectangle | Tool::CustomShape => json!({"kind": "rect", "rect": rect}),
Tool::EllipseShape => json!({"kind": "ellipse", "rect": rect}),
Tool::Triangle => json!({"kind": "polygon", "rect": rect, "sides": 3}),
_ => json!({"kind": "polygon", "rect": rect, "sides": o.polygon_sides.max(3)}),
}
};
})
}
/// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre.
pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return };
let fill = if app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null };
let stroke = stroke_param(app);
if tool == Tool::CustomShape {
// ⇧ keeps the shape's proportions (the rect is already squared).
if let Ok(rect) = serde_json::from_value(p["rect"].take()) {
crate::preset_panels::finish_custom_shape(app, rect, mods.shift, fill, stroke);
}
return;
}
p["fill"] = fill;
p["stroke"] = stroke;
if let Err(e) = app.run("shape.create", p) {
app.ui.status = e;
app.ui.status_error = true;
}
}
/// Shape-tool drag preview: the shape `finish_shape` will create, filled and stroked, under its
/// path outline. Custom shapes show their box outline only.
pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
let o = &app.ui.tool_options;
let Some(path) = shape_geometry(o, tool, start, end, mods).and_then(|p| photocraft_engine::vector_cmds::shape_path(&p).ok()) else { return };
let custom = tool == Tool::CustomShape;
// ponytail: preview colours skip the canvas's colour management; the commit renders them exactly.
let fill = if o.shape_fill && !custom { rgb32(app.session.tools.foreground) } else { Color32::TRANSPARENT };
let stroke = if o.stroke_width > 0.0 && !custom { Stroke::new(o.stroke_width * xf.zoom, rgb32(app.session.tools.background)) } else { Stroke::NONE };
let accent = Tokens::get(painter.ctx()).accent;
for (pts, _) in path_lines(&path, &|q| xf.to_screen(q[0] as f32, q[1] as f32)) {
// Every tool's own shape is convex (custom shapes aren't, and draw no fill).
painter.add(egui::Shape::convex_polygon(pts.clone(), fill, stroke));
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, accent)));
}
}
// ---------------------------------------------------------------------------------------------
// Pen
@ -303,10 +332,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
lbl(ui, "Fill:");
crate::widgets::checkbox(ui, &mut o.shape_fill, "");
let fg = app.session.tools.foreground;
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let (r, _) = ui.allocate_exact_size(vec2(22.0, 16.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, Color32::from_rgb(q(fg[0]), q(fg[1]), q(fg[2])));
ui.painter().rect_filled(r, 2.0, rgb32(app.session.tools.foreground));
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
lbl(ui, "Stroke:");
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
@ -620,6 +647,26 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.layers.len(), 4);
}
#[test]
fn shape_preview_is_the_committed_path() {
let mut app = app();
app.ui.tool_options.corner_radius = 8.0;
let preview = |app: &PhotocraftApp, tool, end| {
shape_geometry(&app.ui.tool_options, tool, [10.0, 10.0], end, egui::Modifiers::ALT)
.and_then(|p| photocraft_engine::vector_cmds::shape_path(&p).ok())
};
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line] {
let shown = preview(&app, tool, [60.0, 30.0]).unwrap();
finish_shape(&mut app, tool, [10.0, 10.0], [60.0, 30.0], egui::Modifiers::ALT);
let st = app.session.active().unwrap();
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("{tool:?}: no shape layer") };
assert_eq!(sh.path, shown, "{tool:?}");
}
assert!(preview(&app, Tool::Rectangle, [10.2, 40.0]).is_none(), "too thin to draw");
assert!(photocraft_engine::vector_cmds::shape_path(&json!({"kind": "nope", "rect": [0, 0, 5, 5]})).is_err());
assert!(photocraft_engine::vector_cmds::shape_path(&json!({"kind": "rect"})).is_err());
}
#[test]
fn pen_builds_and_closes_a_work_path() {
let mut app = app();