From f76a0beca3f9fcf123576dc4493fc95c85b259e1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 01:20:40 -0400 Subject: [PATCH] =?UTF-8?q?M8:=20Draw=20tab=20=E2=80=94=20pen,=20highlight?= =?UTF-8?q?er,=20eraser,=20ink=20strokes=20and=20Ink=20to=20Shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 134 ++++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 4 + crates/engine/src/tests.rs | 17 ++++ crates/model/src/features.rs | 3 + crates/ui-egui/src/chartview.rs | 9 +++ crates/ui-egui/src/grid.rs | 39 ++++++++++ crates/ui-egui/src/ribbon.rs | 30 +++++-- crates/xlsx/src/drawing.rs | 2 +- 9 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/draw.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs new file mode 100644 index 0000000..d611059 --- /dev/null +++ b/crates/engine/src/cmd/draw.rs @@ -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 { + 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 { + 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 { + 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 { + 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::>().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 { + 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:?}")})) + }) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d4317d9..1f615b7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5e9b68f..0871a9b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -196,6 +196,10 @@ pub struct Session { pub recording: Option, /// Windows hidden with View › Hide. pub hidden_windows: Vec, + /// 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 994e063..da0bae3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 5c378db..3a99e8c 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -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)] diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index b8b3529..af6bdf0 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -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 = 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 }); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5091da3..7c3db38 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -77,6 +77,8 @@ pub struct GridState { pub renaming_tab: Option, pub rename_text: String, pub list_picker: Option, + /// 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 = 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 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cdfdfee..b76ed36 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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!({})); } } diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index a5445fd..45aef38 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -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,