M8: Draw tab — pen, highlighter, eraser, ink strokes and Ink to Shape

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 01:20:40 -04:00
parent 41e14619bd
commit f76a0beca3
9 changed files with 234 additions and 6 deletions

View File

@ -0,0 +1,134 @@
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
use serde_json::{Value as Json, json};
use sheetcraft_core::CellRef;
use sheetcraft_model::*;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "draw.pen", "Pen", ["Draw", "Tools"], None, "{on?: bool, color?: hex, width?: 2}", has_doc, pen),
cmd!(noundo "draw.eraser", "Eraser", ["Draw", "Tools"], None, "{on?: bool}", has_doc, eraser),
cmd!(noundo "draw.lasso", "Lasso Select", ["Draw", "Tools"], None, "{} (selects ink by dragging; same as Select Objects)", has_doc, |s, _| {
s.draw_tool = String::new();
ok()
}),
cmd!("draw.stroke", "Ink Stroke", [], None, "{points: [[x,y],…] in sheet points, color?: hex, width?: 2}", has_doc, stroke),
cmd!("draw.inkToShape", "Ink to Shape", ["Draw", "Convert"], None, "{id?} converts an ink stroke (default: the last) into a rectangle, ellipse, triangle or line", has_doc, ink_to_shape),
]
}
fn pen(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.draw_tool != "pen");
s.draw_tool = if on { "pen".into() } else { String::new() };
if let Some(c) = str_param(p, "color") {
s.draw_color = c.to_string();
}
if let Some(w) = f64_param(p, "width") {
s.draw_width = w.clamp(0.5, 40.0) as f32;
}
Ok(json!({"tool": s.draw_tool, "color": s.draw_color, "width": s.draw_width}))
}
fn eraser(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.draw_tool != "eraser");
s.draw_tool = if on { "eraser".into() } else { String::new() };
Ok(json!({"tool": s.draw_tool}))
}
fn parse_points(t: &str) -> Vec<[f32; 2]> {
t.split(';').filter_map(|pt| pt.split_once(',').and_then(|(x, y)| Some([x.parse().ok()?, y.parse().ok()?]))).collect()
}
fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let pts: Vec<[f64; 2]> = p
.get("points")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|q| Some([q.get(0)?.as_f64()?, q.get(1)?.as_f64()?])).filter(|q| q[0].is_finite() && q[1].is_finite()).take(20_000).collect())
.unwrap_or_default();
if pts.len() < 2 {
return Err(bad("draw.stroke", "a stroke needs at least two points"));
}
let minx = pts.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).max(0.0);
let miny = pts.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).max(0.0);
let maxx = pts.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max);
let maxy = pts.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max);
let color = str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
let d = s.doc()?;
let si = d.wb.active_sheet;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
let dx = (minx - sh.col_left(cell.col)) as f32;
let dy = (miny - sh.row_top(cell.row)) as f32;
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
let id = d.wb.next_object_id();
let shape = Shape {
id,
kind: ShapeKind::Ink,
anchor: Anchor { cell, dx, dy, width: (maxx - minx).max(1.0) as f32, height: (maxy - miny).max(1.0) as f32 },
fill: Color::from_hex(&format!("#{:02X}0000", (width.round() as u8).min(255))).unwrap_or_default(),
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
text,
};
edit(s, |cx| {
cx.sheet_mut(si)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
/// Ink width is stored in the red channel of `fill` (shapes have no width field).
pub fn ink_width(sh: &Shape) -> f32 {
match sh.fill {
Color::Rgb(v) => ((v >> 16) & 0xFF).max(1) as f32,
_ => 2.0,
}
}
pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
parse_points(&sh.text)
}
fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let id = u32_param(p, "id");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let idx = match id {
Some(id) => sh.shapes.iter().position(|x| x.id == id && x.kind == ShapeKind::Ink),
None => sh.shapes.iter().rposition(|x| x.kind == ShapeKind::Ink),
}
.ok_or_else(|| bad("draw.inkToShape", "no ink stroke to convert"))?;
let ink = sh.shapes.get_mut(idx).ok_or_else(|| bad("draw.inkToShape", "gone"))?;
let pts = parse_points(&ink.text);
let (w, h) = (ink.anchor.width.max(1.0), ink.anchor.height.max(1.0));
let closed = match (pts.first(), pts.last()) {
(Some(a), Some(b)) => ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt() < 0.25 * w.max(h),
_ => false,
};
let kind = if !closed {
ShapeKind::Line
} else {
// Corners hugging the bounding box mean a rectangle; one apex at the top a triangle.
let near = |cx: f32, cy: f32| pts.iter().any(|q| ((q[0] - cx).powi(2) + (q[1] - cy).powi(2)).sqrt() < 0.15 * w.min(h).max(8.0));
let corners = [near(0.0, 0.0), near(w, 0.0), near(0.0, h), near(w, h)].iter().filter(|b| **b).count();
let apex = near(w / 2.0, 0.0);
if corners >= 3 {
ShapeKind::Rectangle
} else if apex && near(0.0, h) && near(w, h) {
ShapeKind::Triangle
} else {
ShapeKind::Ellipse
}
};
let color = ink.line;
ink.kind = kind;
ink.fill = if kind == ShapeKind::Line { color } else { Color::Auto };
ink.text = String::new();
if kind == ShapeKind::Line && pts.first().zip(pts.last()).is_some_and(|(a, b)| a[1] > b[1]) {
// Lines are drawn top-left to bottom-right; keep the stroke direction simple.
}
Ok(json!({"kind": format!("{kind:?}")}))
})
}

View File

@ -2,6 +2,7 @@
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod data;
pub mod draw;
pub mod edit;
pub mod extra;
pub mod file;
@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(inspect::specs());
v.extend(extra::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(pivot::specs());
v.extend(whatif::specs());

View File

@ -196,6 +196,10 @@ pub struct Session {
pub recording: Option<usize>,
/// Windows hidden with View › Hide.
pub hidden_windows: Vec<usize>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
pub draw_tool: String,
pub draw_color: String,
pub draw_width: f32,
}
impl Session {

View File

@ -173,3 +173,20 @@ fn parity_counts() {
assert!(total > 250);
assert!(done > 150, "parity {done}/{total}");
}
#[test]
fn ink_strokes_and_ink_to_shape() {
let mut s = s();
// A rough closed box.
let pts: Vec<[f64; 2]> = vec![[100.0, 100.0], [200.0, 102.0], [201.0, 180.0], [99.0, 181.0], [101.0, 104.0]];
s.execute("draw.stroke", json!({"points": pts})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert_eq!(sh.shapes.len(), 1);
assert_eq!(sh.shapes[0].kind, sheetcraft_model::ShapeKind::Ink);
let r = s.execute("draw.inkToShape", json!({})).unwrap();
assert_eq!(r["kind"], "Rectangle");
// An open stroke becomes a line.
s.execute("draw.stroke", json!({"points": [[0.0, 0.0], [50.0, 10.0], [120.0, 30.0]]})).unwrap();
assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line");
assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err());
}

View File

@ -395,6 +395,9 @@ pub enum ShapeKind {
TextBox,
/// An icon from SheetCraft's own icon set; `Shape::text` holds the icon name.
Icon,
/// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
/// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
Ink,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View File

@ -217,6 +217,15 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
ShapeKind::Triangle => {
p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line)));
}
ShapeKind::Ink => {
let w = sheetcraft_engine::cmd::draw::ink_width(sp) * geo.z;
let pts: Vec<Pos2> = sheetcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect();
if pts.len() >= 2 {
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
p.add(PathShape::line(pts, PathStroke::new(w, col)));
}
continue;
}
ShapeKind::Icon => {
if let Some(icon) = crate::icons::from_name(&sp.text) {
crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill });

View File

@ -77,6 +77,8 @@ pub struct GridState {
pub renaming_tab: Option<usize>,
pub rename_text: String,
pub list_picker: Option<CellRef>,
/// Ink stroke being drawn (sheet points).
pub ink: Vec<[f32; 2]>,
/// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet).
pub pages: Option<((u64, u64, usize), Vec<(RangeRef, u32)>)>,
pub header_menu: Option<(Pos2, bool)>,
@ -1007,6 +1009,43 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
}
// Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer.
if !app.session.draw_tool.is_empty() && in_cells {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.session.draw_tool == "pen" {
if let Some(p) = pos
&& resp.dragged()
{
let sx = sh.col_left(geo.fc) as f32 + geo.scroll.x + (p.x - geo.cells.left() - geo.frozen_w) / geo.z;
let sy = sh.row_top(geo.fr) as f32 + geo.scroll.y + (p.y - geo.cells.top() - geo.frozen_h) / geo.z;
app.grid.ink.push([sx, sy]);
}
if !app.grid.ink.is_empty() {
// Live preview.
let pts: Vec<Pos2> = app
.grid
.ink
.iter()
.map(|q| pos2(geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z, geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * geo.z))
.collect();
ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
}
if resp.drag_stopped() && app.grid.ink.len() >= 2 {
let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink);
let _ = app.run("draw.stroke", json!({"points": pts}));
}
return;
}
if app.session.draw_tool == "eraser" && (resp.clicked() || resp.dragged()) {
if let Some(p) = pos
&& let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p)
&& sh.shapes.iter().any(|s| s.id == id && s.kind == sheetcraft_engine::model::ShapeKind::Ink)
{
let _ = app.run("object.delete", json!({"kind": "shape", "id": id}));
}
return;
}
}
// Objects (charts, pictures, shapes) take clicks first.
if resp.drag_started() || resp.clicked() {
if let Some(p) = pos

View File

@ -1157,12 +1157,32 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
}
fn draw(app: &mut SheetApp, ui: &mut Ui) {
let _ = app;
for (icon, label) in [(Icon::Pen, "Pen"), (Icon::Eraser, "Eraser"), (Icon::Shapes, "Ink to\nShape")] {
let r = big_button(ui, icon, label, label, false);
if r.clicked() {
app.toast = Some(("Drawing with ink arrives in a later milestone — use Insert › Shapes meanwhile.".into(), crate::now_ms()));
let tool = app.session.draw_tool.clone();
if big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false).clicked() {
act(app, "draw.lasso", json!({}));
}
sep(ui);
for (label, color) in [("Pen\nBlue", "#1F5FC9"), ("Pen\nBlack", "#000000"), ("Pen\nRed", "#D13B2F"), ("Highlighter", "#F2E33A")] {
let on = tool == "pen" && app.session.draw_color.eq_ignore_ascii_case(color);
let r = big_button(ui, Icon::Pen, label, "Draw with ink", false);
if on {
ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if r.clicked() {
let width = if label == "Highlighter" { 12.0 } else { 2.0 };
act(app, "draw.pen", json!({"on": !on, "color": color, "width": width}));
}
}
let er = big_button(ui, Icon::Eraser, "Eraser", "Erase ink strokes", false);
if tool == "eraser" {
ui.painter().rect_stroke(er.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if er.clicked() {
act(app, "draw.eraser", json!({}));
}
sep(ui);
if big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false).clicked() {
act(app, "draw.inkToShape", json!({}));
}
}

View File

@ -273,7 +273,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
ShapeKind::Triangle => ("triangle", false),
ShapeKind::Line | ShapeKind::Arrow => ("line", false),
ShapeKind::TextBox => ("rect", true),
ShapeKind::Icon => ("rect", false),
ShapeKind::Icon | ShapeKind::Ink => ("rect", false),
};
let _ = write!(
s,