mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Draw: Ink to Shape converts pen strokes into shapes
Draw > Ink to Shape recognises each pen stroke on the slide's ink layer as a line, rectangle, oval or isosceles triangle (apex up or down) and replaces it with that preset shape, outlined in the stroke's colour and width with no fill. Strokes it can't recognise, and highlighter strokes, stay ink. The ink shape is removed once it has no strokes left. The whole conversion is one undo step. The recogniser (engine/src/inkshape.rs) is pure geometry: ends meeting, area covered within the bounding box, and spread from the fitted ellipse. Ribbon big buttons now carry an accessibility label. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,6 +29,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_slide,
|
||||
freeform
|
||||
),
|
||||
cmd!(
|
||||
"draw.inkToShape",
|
||||
"Ink to Shape",
|
||||
["Draw", "Convert"],
|
||||
None,
|
||||
"{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles",
|
||||
has_slide,
|
||||
ink_to_shape
|
||||
),
|
||||
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),
|
||||
@@ -860,3 +869,79 @@ fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape
|
||||
/// outlined in the stroke's colour and width; the rest stays ink. One undo step.
|
||||
fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
use crate::inkshape::{Recognized, recognize};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
let shapes = s.doc()?.shapes();
|
||||
let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {
|
||||
return Ok(json!({"shapes": [], "kept": 0}));
|
||||
};
|
||||
let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));
|
||||
let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };
|
||||
let found: Vec<Option<Recognized>> = strokes
|
||||
.iter()
|
||||
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
|
||||
.collect();
|
||||
if found.iter().all(Option::is_none) {
|
||||
return Ok(json!({"shapes": [], "kept": strokes.len()}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mut new = vec![];
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };
|
||||
let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };
|
||||
let all = std::mem::take(strokes);
|
||||
let mut kept = vec![];
|
||||
let mut made = vec![];
|
||||
for (st, r) in all.into_iter().zip(found.iter()) {
|
||||
let Some(r) = r else {
|
||||
kept.push(st);
|
||||
continue;
|
||||
};
|
||||
let (preset, xfrm) = match *r {
|
||||
Recognized::Line { x0, y0, x1, y1 } => {
|
||||
("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))
|
||||
}
|
||||
Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),
|
||||
};
|
||||
made.push((preset, xfrm, st.color, st.width));
|
||||
}
|
||||
let remaining = kept.len();
|
||||
*strokes = kept;
|
||||
if remaining == 0 {
|
||||
list.remove(pos);
|
||||
}
|
||||
for (preset, xfrm, color, width) in made {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
let name = format!(
|
||||
"{} {}",
|
||||
match preset {
|
||||
"line" => "Straight Connector",
|
||||
"rect" => "Rectangle",
|
||||
"ellipse" => "Oval",
|
||||
_ => "Isosceles Triangle",
|
||||
},
|
||||
id.0
|
||||
);
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
list.push(Shape {
|
||||
id,
|
||||
name,
|
||||
xfrm: Some(xfrm),
|
||||
geom: Geom::preset(preset),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),
|
||||
kind: ShapeKind::Shape,
|
||||
..Default::default()
|
||||
});
|
||||
new.push(id);
|
||||
}
|
||||
sel.shapes = new.clone();
|
||||
Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle.
|
||||
//!
|
||||
//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends
|
||||
//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1,
|
||||
//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the
|
||||
//! centre.
|
||||
|
||||
/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Recognized {
|
||||
/// From `(x0, y0)` to `(x1, y1)`.
|
||||
Line {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
x1: f64,
|
||||
y1: f64,
|
||||
},
|
||||
Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
Ellipse {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
/// Apex at the top (`flip_v` false) or at the bottom.
|
||||
Triangle {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
flip_v: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Strokes smaller than this (points) stay ink.
|
||||
const MIN_SIZE: f64 = 6.0;
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
pub fn recognize(points: &[(f64, f64)]) -> Option<Recognized> {
|
||||
let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect();
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for (x, y) in &pts {
|
||||
x0 = x0.min(*x);
|
||||
y0 = y0.min(*y);
|
||||
x1 = x1.max(*x);
|
||||
y1 = y1.max(*y);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let size = w.max(h);
|
||||
if !size.is_finite() || size < MIN_SIZE {
|
||||
return None;
|
||||
}
|
||||
let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum();
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
let gap = (last.0 - first.0).hypot(last.1 - first.1);
|
||||
if length <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Open stroke: a line if nearly straight, otherwise not a shape.
|
||||
if gap > 0.25 * size {
|
||||
return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 });
|
||||
}
|
||||
// Closed but flat: a line drawn back and forth isn't a shape.
|
||||
if w.min(h) < 0.15 * size {
|
||||
return None;
|
||||
}
|
||||
// Covered area (shoelace, the gap closed) over the bounding box.
|
||||
let mut a2 = 0.0;
|
||||
for k in 0..pts.len() {
|
||||
let (p, q) = (pts[k], pts[(k + 1) % pts.len()]);
|
||||
a2 += p.0 * q.1 - q.0 * p.1;
|
||||
}
|
||||
let cover = (a2.abs() / 2.0) / (w * h);
|
||||
// Ellipse fit: distance from the centre in box-normalised units should stay near 1.
|
||||
let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0);
|
||||
let radial: Vec<f64> = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect();
|
||||
let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::<f64>() / radial.len() as f64;
|
||||
if cover > 0.86 {
|
||||
Some(Recognized::Rect { x: x0, y: y0, w, h })
|
||||
} else if cover > 0.66 && spread < 0.12 {
|
||||
Some(Recognized::Ellipse { x: x0, y: y0, w, h })
|
||||
} else if (0.35..0.66).contains(&cover) {
|
||||
// Apex: the side of the box with fewer points near it is where the triangle narrows.
|
||||
let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count();
|
||||
let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count();
|
||||
Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> {
|
||||
let mut v = vec![];
|
||||
for k in 0..corners.len() {
|
||||
let (a, b) = (corners[k], corners[(k + 1) % corners.len()]);
|
||||
for i in 0..per_side {
|
||||
let t = i as f64 / per_side as f64;
|
||||
// A little hand wobble.
|
||||
let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8;
|
||||
v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob));
|
||||
}
|
||||
}
|
||||
v.push((corners[0].0 + 2.0, corners[0].1 + 1.5));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_ellipses_triangles_and_lines() {
|
||||
let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap();
|
||||
assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}");
|
||||
let ell: Vec<(f64, f64)> = (0..=80)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 80.0 * std::f64::consts::TAU;
|
||||
(200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell));
|
||||
let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap();
|
||||
assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}");
|
||||
let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap();
|
||||
assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}");
|
||||
let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect();
|
||||
assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribbles_dots_and_garbage_stay_ink() {
|
||||
assert_eq!(recognize(&[]), None);
|
||||
assert_eq!(recognize(&[(1.0, 1.0)]), None);
|
||||
assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small");
|
||||
let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect();
|
||||
assert_eq!(recognize(&zig), None, "an open zigzag is no line");
|
||||
assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None);
|
||||
let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect();
|
||||
assert_eq!(recognize(&there_and_back), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_to_shape_command_converts_and_undoes() {
|
||||
use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
let mut s = crate::Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30);
|
||||
let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect();
|
||||
let mk = |v: &[(f64, f64)]| InkStroke {
|
||||
points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(),
|
||||
color: deckcraft_color::Rgba::rgb(200, 0, 0),
|
||||
width: 3.0,
|
||||
highlighter: false,
|
||||
};
|
||||
let strokes = vec![mk(&rect), mk(&zig)];
|
||||
s.edit(|doc, sel| {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
crate::shapes_mut(doc, sel).unwrap().push(Shape {
|
||||
id,
|
||||
name: "Ink".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
kind: ShapeKind::Ink { strokes },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(serde_json::Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
let n0 = s.doc().unwrap().shapes().len();
|
||||
let r = s.execute("draw.inkToShape", &json!({})).unwrap();
|
||||
assert_eq!(r["kept"], 1);
|
||||
let sh = s.doc().unwrap().shapes();
|
||||
assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added");
|
||||
let made = sh.last().unwrap();
|
||||
assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"));
|
||||
assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), n0);
|
||||
// Nothing recognisable: a no-op, not an error. Empty slide too.
|
||||
s.execute("draw.inkToShape", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod inkshape;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
pub mod recovery;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the
|
||||
//! mouse, press the button, get a rectangle shape.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn pen_a_rectangle_then_ink_to_shape() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.tab = "Draw".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Black Pen").click();
|
||||
h.run_steps(2);
|
||||
let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
|
||||
let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k);
|
||||
let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)];
|
||||
h.hover_at(at(200.0, 150.0));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
for w in corners.windows(2) {
|
||||
for i in 1..=12 {
|
||||
let t = i as f32 / 12.0;
|
||||
let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t);
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let has_ink = |h: &Harness<'_, SlideApp>| {
|
||||
h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))
|
||||
};
|
||||
assert!(has_ink(&h), "the pen drew a stroke");
|
||||
h.get_by_label("Ink to Shape").click();
|
||||
h.run_steps(3);
|
||||
assert!(!has_ink(&h), "the only stroke became a shape");
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh = st.shapes().last().unwrap();
|
||||
assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom);
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}");
|
||||
assert_eq!(st.selection.shapes, vec![sh.id]);
|
||||
if let Ok(img) = h.render() {
|
||||
let _ = img.save("/tmp/ink_to_shape.png");
|
||||
}
|
||||
}
|
||||
@@ -1200,8 +1200,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false);
|
||||
let _ = r.on_hover_text("Ink to Shape (coming soon)");
|
||||
let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })));
|
||||
let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink);
|
||||
if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() {
|
||||
app.session.set_tool(ToolKind::Select);
|
||||
run(app, "draw.inkToShape", json!({}));
|
||||
}
|
||||
let r = big_button(ui, Icon::InkToText, "Ink to\nText", false);
|
||||
let _ = r.on_hover_text("Ink to Text (coming soon)");
|
||||
let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false);
|
||||
@@ -2420,3 +2424,7 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) {
|
||||
};
|
||||
p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inktoshape_tests.rs"]
|
||||
mod inktoshape_tests;
|
||||
|
||||
@@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split:
|
||||
let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max);
|
||||
let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
// Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape").
|
||||
let spoken = label.replace('\n', " ");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken));
|
||||
let painter = ui.painter_at(rect.expand(1.0));
|
||||
let (main_rect, arrow_rect) = if split {
|
||||
(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max)))
|
||||
|
||||
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 61 | 11 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 15 | 26 | 38% |
|
||||
| P2 | 54 | 13 | 16 | 25 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
@@ -31,7 +31,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Animations | 79% |
|
||||
| Slide show | 66% |
|
||||
| Review | 60% |
|
||||
| Draw | 33% |
|
||||
| Draw | 50% |
|
||||
| Undo & clipboard | 88% |
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
@@ -224,7 +224,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Review | Compare presentations | P2 | M |
|
||||
| Review | Track changes / version history | P2 | M |
|
||||
| Draw | Ink pens, highlighter, eraser | P1 | P |
|
||||
| Draw | Ink to shape / text | P2 | M |
|
||||
| Draw | Ink to shape / text | P2 | P |
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P |
|
||||
|
||||
Reference in New Issue
Block a user