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:
parent
41e14619bd
commit
f76a0beca3
|
|
@ -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:?}")}))
|
||||
})
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue