diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index a61cfbb..877e6a2 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -20,6 +20,15 @@ pub fn specs() -> Vec { has_slide, connect ), + cmd!( + "shape.freeform", + "Freeform", + ["Insert", "Shapes"], + None, + "{points: [[x, y], …] slide pt (≥ 2), closed?: bool (filled shape), smooth?: bool (curve through the points)} → {id}", + has_slide, + freeform + ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), @@ -731,3 +740,80 @@ fn connect(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib})) } + +/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics. +pub fn freeform_path(pts: &[slidecraft_geom::Point], closed: bool, smooth: bool) -> String { + let f = |v: f64| { + let s = format!("{:.2}", v); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y)); + let n = pts.len(); + if smooth && n > 2 { + let at = |i: isize| -> slidecraft_geom::Point { + if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] } + }; + let segs = if closed { n } else { n - 1 }; + for i in 0..segs as isize { + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = p1 + (p2 - p0) / 6.0; + let c2 = p2 - (p3 - p1) / 6.0; + d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y)); + } + } else { + for q in &pts[1..] { + d += &format!(" L {} {}", f(q.x), f(q.y)); + } + } + if closed { + d += " Z"; + } + d +} + +fn freeform(s: &mut Session, p: &Value) -> Result { + let pts: Vec = p + .get("points") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| Some(slidecraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .filter(|q| q.x.is_finite() && q.y.is_finite()) + .take(20_000) + .collect() + }) + .unwrap_or_default(); + if pts.len() < 2 { + return Err(bad("shape.freeform", "need at least two points")); + } + let closed = bool_or(p, "closed", false) && pts.len() > 2; + let smooth = bool_or(p, "smooth", false); + let mut bb = slidecraft_geom::Rect::from_points(pts[0], pts[0]); + for q in &pts { + bb = bb.union_pt(*q); + } + let (w, h) = (bb.width().max(1.0), bb.height().max(1.0)); + let local: Vec = pts.iter().map(|q| slidecraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); + let d = freeform_path(&local, closed, smooth); + // Closed shapes take the default shape style, open paths the line style. + let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?; + let id = slidecraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?; + let sh = slidecraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?; + sh.geom = Geom::Custom { + paths: vec![slidecraft_model::CustomPath { + w, + h, + d: d.clone(), + fill: if closed { slidecraft_geom::preset::FillMode::Norm } else { slidecraft_geom::preset::FillMode::None }, + stroke: true, + }], + }; + sh.xfrm = Some(slidecraft_geom::Xfrm::new(bb.x0, bb.y0, w, h)); + sh.kind = slidecraft_model::ShapeKind::Shape; + sh.name = format!("Freeform {}", id.0); + Ok(()) + })?; + Ok(json!({"id": id.0})) +} diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 62a6b9f..104eea9 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -36,9 +36,11 @@ impl Session { /// Run command `id` (`f`) inside the guard. pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision)); + let depth = self.depth; match catch_unwind(AssertUnwindSafe(|| f(self))) { Ok(r) => r, Err(payload) => { + self.depth = depth; let msg = panic_message(payload.as_ref()); // Keep the document as it was before the command. if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut()) diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index a401e00..41f323c 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -29,6 +29,9 @@ impl Session { let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); let k = key.to_ascii_lowercase(); let k = k.trim_start_matches("arrow"); + if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { + return self.finish_freeform(false); + } if editing && !mods.cmd { let r = match k { "left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0ba45b6..df73445 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -267,6 +267,9 @@ pub struct Session { pub painter: Option<(cmd::format::Painted, bool)>, /// Set as Default Shape: the look new shapes get. pub default_look: Option, + /// Nesting of commands run by commands: only the outermost one is an undo step and a journal + /// entry. + depth: u32, } impl Default for Session { @@ -289,6 +292,7 @@ impl Session { untitled: 0, default_look: None, painter: None, + depth: 0, } } /// A session with one new presentation open. @@ -351,7 +355,11 @@ impl Session { } } let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone())); - let r = (spec.run)(self, params)?; + let outer = self.depth == 0; + self.depth += 1; + let r = (spec.run)(self, params); + self.depth -= 1; + let r = r?; if let Some(st) = self.active_mut() { clamp_selection(st); } @@ -360,10 +368,11 @@ impl Session { && !Arc::ptr_eq(&old, &st.doc) && st.interaction.is_none() && spec.undoable + && outer { push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel }); } - if spec.journal { + if spec.journal && outer { self.journal.push((id.to_string(), params.clone())); if self.journal.len() > 10_000 { self.journal.drain(..1000); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d287be3..fb52d08 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -310,3 +310,59 @@ fn drawing_a_line_between_shapes_glues_it() { let (p0, p1) = line_ends(&s, id); assert!((p0.x - 200.0).abs() < 1e-6 && (p1.x - 400.0).abs() < 1e-6, "{p0:?} {p1:?}"); } + +#[test] +fn freeform_tools_and_command() { + let mut s = session(); + let before = s.doc().unwrap().shapes().len(); + // Composite command: one undo step. + let r = s.execute("shape.freeform", &json!({"points": [[100, 100], [200, 120], [150, 220]], "closed": true})).unwrap(); + assert!(r["id"].as_u64().is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), before + 1); + + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 }; + // Polygon: click, click, click, click on the start → closed, filled. + s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); + for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] { + s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap(); + s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap(); + } + assert!(s.tool.drawing_freeform()); + s.pointer(ev(tools::PointerKind::Down, 301.0, 301.0)).unwrap(); + assert!(!s.tool.drawing_freeform()); + let st = s.doc().unwrap(); + let sh = st.shapes().last().unwrap(); + match &sh.geom { + slidecraft_model::Geom::Custom { paths } => assert!(paths[0].d.ends_with('Z') && paths[0].d.matches(" L ").count() == 2, "{}", paths[0].d), + g => panic!("{g:?}"), + } + let x = sh.xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h), (300.0, 300.0, 100.0, 100.0)); + + // Scribble: one drag, open path. + s.set_tool(tools::ToolKind::Shape { preset: "scribble".into() }); + s.pointer(ev(tools::PointerKind::Down, 10.0, 10.0)).unwrap(); + for i in 1..20 { + s.pointer(ev(tools::PointerKind::Drag, 10.0 + i as f64 * 5.0, 10.0 + (i as f64).sin() * 10.0)).unwrap(); + } + let r = s.pointer(ev(tools::PointerKind::Up, 110.0, 10.0)).unwrap(); + assert!(r["id"].as_u64().is_some()); + assert_eq!(s.tool.kind, tools::ToolKind::Select); + + // Curve: clicks then Enter → smooth open path. + s.set_tool(tools::ToolKind::Shape { preset: "curve".into() }); + for (x, y) in [(500.0, 100.0), (550.0, 50.0), (600.0, 100.0), (650.0, 50.0)] { + s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap(); + s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap(); + } + let r = s.key("Enter", Default::default()).unwrap(); + let id = slidecraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + let st = s.doc().unwrap(); + match &st.shape(id).unwrap().geom { + slidecraft_model::Geom::Custom { paths } => assert_eq!(paths[0].d.matches(" C ").count(), 3), + g => panic!("{g:?}"), + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index c56510b..0b35fb7 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -89,15 +89,63 @@ pub enum Hit { #[derive(Clone, Debug)] enum Drag { - Move { ids: Vec, start: Point, origs: Vec<(ShapeId, Xfrm)>, moved: bool, duplicate: bool, enter_text: Option<(ShapeId, Point)> }, - Resize { id: ShapeId, handle: u8, orig: Xfrm, start: Point }, - LineEnd { id: ShapeId, end: u8, orig: Xfrm }, - Rotate { ids: Vec, center: Point, start: f64, origs: Vec<(ShapeId, Xfrm)> }, - Adjust { id: ShapeId, index: usize, orig: Xfrm }, - Marquee { start: Point, add: bool }, - Create { start: Point }, - TextSelect { shape: ShapeId }, - Ink { points: Vec<(f64, f64, f32)> }, + Move { + ids: Vec, + start: Point, + origs: Vec<(ShapeId, Xfrm)>, + moved: bool, + duplicate: bool, + enter_text: Option<(ShapeId, Point)>, + }, + Resize { + id: ShapeId, + handle: u8, + orig: Xfrm, + start: Point, + }, + LineEnd { + id: ShapeId, + end: u8, + orig: Xfrm, + }, + Rotate { + ids: Vec, + center: Point, + start: f64, + origs: Vec<(ShapeId, Xfrm)>, + }, + Adjust { + id: ShapeId, + index: usize, + orig: Xfrm, + }, + Marquee { + start: Point, + add: bool, + }, + Create { + start: Point, + }, + TextSelect { + shape: ShapeId, + }, + Ink { + points: Vec<(f64, f64, f32)>, + }, + /// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while + /// the button is down (dragging adds scribbled points). + Freeform { + mode: String, + points: Vec, + pressed: bool, + }, +} + +/// Shape-gallery names that draw a freeform path instead of a preset. +pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")]; + +pub fn is_freeform_tool(name: &str) -> bool { + FREEFORM_TOOLS.iter().any(|f| f.0 == name) } /// A snapping guide line to draw (slide coordinates): vertical (x) or horizontal (y). @@ -125,12 +173,22 @@ pub struct ToolState { /// site the end would glue to. pub sites: Vec, pub glue: Option, + /// Freeform being drawn: points so far (plus the rubber-band point), smoothed, and whether + /// the pointer is over the start (closing the shape). + pub path_preview: Vec, + pub path_smooth: bool, + pub path_closing: bool, } impl ToolState { pub fn dragging(&self) -> bool { self.drag.is_some() } + + /// A freeform path is being drawn (Enter/Escape/double-click finish it). + pub fn drawing_freeform(&self) -> bool { + matches!(self.drag, Some(Drag::Freeform { .. })) + } } /// Handle positions in slide coordinates for a box (8 resize + rotate). @@ -209,6 +267,15 @@ impl Session { } match ev.kind { PointerKind::Move => { + if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { + let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0); + let mut v = points.clone(); + v.push(p); + self.tool.path_preview = v; + self.tool.path_smooth = mode == "curve"; + self.tool.path_closing = closing; + return Ok(Value::Null); + } self.tool.hover = self.hit_test(p, tol); let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if slidecraft_geom::preset::is_line_like(preset)); if line_tool { @@ -251,7 +318,25 @@ impl Session { } fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { + // Next click of a freeform: close on the start point, else add a vertex. + if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) { + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(true); + } + points.push(p); + self.tool.path_preview = points.clone(); + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); + return Ok(Value::Null); + } match self.tool.kind.clone() { + ToolKind::Shape { preset } if is_freeform_tool(&preset) => { + self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true }); + self.tool.path_preview = vec![p]; + self.tool.path_smooth = preset == "curve"; + self.tool.path_closing = false; + return Ok(Value::Null); + } ToolKind::Shape { .. } | ToolKind::TextBox => { self.tool.drag = Some(Drag::Create { start: p }); self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); @@ -539,6 +624,18 @@ impl Session { })?; } } + Drag::Freeform { mode, mut points, pressed } => { + // Dragging scribbles (except for curves, which only take clicked points). + if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { + points.push(p); + } + let mut v = points.clone(); + if mode == "curve" { + v.push(p); + } + self.tool.path_preview = v; + self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); + } Drag::Ink { mut points } => { points.push((p.x, p.y, 0.5)); self.tool.ink_preview = points.clone(); @@ -633,6 +730,17 @@ impl Session { } out = res; } + Some(Drag::Freeform { mode, points, .. }) => { + if mode == "scribble" { + // A scribble ending back at its start closes into a filled shape. + let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0); + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(closed); + } + // Polygon / curve: keep going until double-click, Enter or a click on the start. + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return Ok(Value::Null); + } Some(Drag::Ink { points }) => { self.tool.ink_preview.clear(); if points.len() > 1 { @@ -655,6 +763,14 @@ impl Session { } fn double_click(&mut self, p: Point, tol: f64) -> Result { + if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { + // The double-click's own click added a duplicate vertex. + while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) { + points.pop(); + } + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(false); + } if !matches!(self.tool.kind, ToolKind::Select) { // Double-click while drawing: lock drawing mode off. self.tool.sticky = false; @@ -698,6 +814,21 @@ impl Session { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } + /// End the freeform being drawn: create the shape (if it has at least two points). + pub fn finish_freeform(&mut self, closed: bool) -> Result { + let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) }; + self.tool.path_preview.clear(); + self.tool.path_closing = false; + if !self.tool.sticky { + self.tool.kind = ToolKind::Select; + } + if points.len() < 2 { + return Ok(Value::Null); + } + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"})) + } + /// The connection site within glue range of `p` (excluding shape `exclude`). fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> { let st = self.active()?; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 15435ea..f15c319 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -51,6 +51,7 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option { "highlighter" => ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_engine::model::Rgba::rgb(255, 230, 0), width: 10.0 }, "eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_engine::model::Rgba::BLACK, width: 2.0 }, other if slidecraft_engine::geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.to_string() }, + other if slidecraft_engine::tools::is_freeform_tool(other) => ToolKind::Shape { preset: other.to_string() }, _ => return None, }) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c364b97..94e0d31 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -381,6 +381,28 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To if let Some(pv) = app.session.tool.preview { draw_preview(app, painter, xf, pv, t); } + // Freeform being drawn. + let path = &app.session.tool.path_preview; + if path.len() > 1 { + let pts: Vec = if app.session.tool.path_smooth && path.len() > 2 { + let d = slidecraft_engine::cmd::shape::freeform_path(path, false, true); + let mut v = vec![]; + let bp = slidecraft_render::parse_path(&d); + slidecraft_geom::preset_flatten(&bp, &mut |el| match el { + slidecraft_geom::PathEl::MoveTo(q) | slidecraft_geom::PathEl::LineTo(q) => v.push(q), + _ => {} + }); + v + } else { + path.clone() + }; + painter.add(egui::Shape::line(pts.iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.5, t.accent))); + if app.session.tool.path_closing + && let Some(s0) = path.first() + { + painter.circle(xf.to_screen(*s0), 5.0, t.accent, Stroke::new(1.5, t.handle_fill)); + } + } // Connection sites of the shape under a connector end, and the site it would glue to. for q in &app.session.tool.sites { let c = xf.to_screen(*q); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 25e6a47..45b5adf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -325,6 +325,7 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_color::Rgba::rgb(255, 230, 0), width: 10.0 }, "eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 }, other if slidecraft_geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.into() }, + other if slidecraft_engine::tools::is_freeform_tool(other) => ToolKind::Shape { preset: other.into() }, _ => return None, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5446ffd..f993018 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -570,7 +570,7 @@ impl SlideApp { } egui::Event::Key { key, pressed: true, modifiers, .. } => { let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - if menus::ui_shortcut(self, key, mods) { + if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { continue; } let name = key_name(key); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 645e9d5..e385da7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -633,6 +633,18 @@ pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { out = Some(p.name); } } + if cat == slidecraft_geom::preset::Category::Lines { + for (name, label) in slidecraft_engine::tools::FREEFORM_TOOLS { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text); + if resp.on_hover_text(label).clicked() { + out = Some(name); + } + } + } }); ui.add_space(4.0); } @@ -2342,3 +2354,16 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { } }); } + +/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). +fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { + let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); + let pts: Vec = match name { + "curve" => (0..=24).map(|i| i as f32 / 24.0).map(|u| at(u, 0.5 - 0.38 * (u * std::f32::consts::TAU).sin())).collect(), + "freeform" => { + [(0.0, 0.85), (0.15, 0.2), (0.55, 0.45), (0.8, 0.05), (1.0, 0.75), (0.45, 1.0), (0.0, 0.85)].iter().map(|(x, y)| at(*x, *y)).collect() + } + _ => (0..=40).map(|i| i as f32 / 40.0).map(|u| at(u, 0.5 + 0.35 * (u * 19.0).sin() * (1.0 - u * 0.4))).collect(), + }; + p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); +} diff --git a/docs/parity.md b/docs/parity.md index 8db0e49..8ca1413 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -16,7 +16,7 @@ Live Presentations), add-ins and VBA macros. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 58 | 13 | 1 | 90% | -| P1 | 65 | 33 | 23 | 9 | 68% | +| P1 | 65 | 34 | 23 | 8 | 70% | | P2 | 50 | 11 | 11 | 28 | 33% | | Area | Parity | @@ -26,7 +26,7 @@ Live Presentations), add-ins and VBA macros. | Slides | 95% | | Views | 90% | | Masters & themes | 80% | -| Shapes | 72% | +| Shapes | 79% | | Format | 75% | | Arrange | 100% | | Text | 83% | @@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Shapes | Adjustment handles | P0 | D | | Shapes | Connectors (straight, elbow, curved) | P0 | D | | Shapes | Connector glue to connection sites | P1 | D | -| Shapes | Freeform, curve and scribble tools | P1 | M | +| Shapes | Freeform, curve and scribble tools | P1 | D | | Shapes | Edit points | P1 | M | | Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | M | | Shapes | Change shape | P1 | D |