Freeform, Curve and Scribble drawing tools; shape.freeform command; composite commands are one undo step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 01:00:06 -04:00
parent 8598c41085
commit f0d230bdcd
12 changed files with 351 additions and 15 deletions

View File

@ -20,6 +20,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})?;
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<Value> {
let pts: Vec<slidecraft_geom::Point> = 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<slidecraft_geom::Point> = 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}))
}

View File

@ -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<Value>) -> Result<Value> {
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())

View File

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

View File

@ -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<cmd::format::Painted>,
/// 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);

View File

@ -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:?}"),
}
}

View File

@ -89,15 +89,63 @@ pub enum Hit {
#[derive(Clone, Debug)]
enum Drag {
Move { ids: Vec<ShapeId>, 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<ShapeId>, 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<ShapeId>,
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<ShapeId>,
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<Point>,
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<Point>,
pub glue: Option<Point>,
/// 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<Point>,
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<Value> {
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<Value> {
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<Value> {
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()?;

View File

@ -51,6 +51,7 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<ToolKind> {
"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,
})
}

View File

@ -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<Point> = 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);

View File

@ -325,6 +325,7 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<slidecraft_engine::
"highlighter" => 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,
})
}

View File

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

View File

@ -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<egui::Pos2> = 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)));
}

View File

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