Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).

| Date | What landed |
|---|---|
| 2026-10-15 | Charts: GridCraft spreadsheet data editor window with live sync, chart.title and chart.data live editing, and data labels for Line/Area charts |
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
Expand Down
19 changes: 17 additions & 2 deletions crates/engine/src/cmd/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart…",
["Insert", "Illustrations"],
None,
"{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}]}",
"{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}], title?: string, editData?: bool}",
has_slide,
chart
),
Expand Down Expand Up @@ -109,10 +109,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Data",
["Chart Design", "Data"],
None,
"{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}",
"{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], title?: string, id?}",
has_selection,
chart_data
),
cmd!("chart.title", "Chart Title", ["Chart Design", "Chart Layouts"], None, "{title: string|null, id?}", has_selection, chart_title_cmd),
cmd!(
"chart.options",
"Chart Elements",
Expand Down Expand Up @@ -1251,6 +1252,9 @@ fn chart(s: &mut Session, p: &Value) -> Result<Value> {
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7));
let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?;
if bool_param(p, "editData").unwrap_or(false) {
s.ui_requests.push(crate::UiRequest::Dialog { id: "chartData".into(), params: json!({"id": id}) });
}
Ok(json!({"id": id}))
}

Expand Down Expand Up @@ -1736,6 +1740,9 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
.collect()
});
with_chart(s, p, "chart.data", |c| {
if let Some(t) = p.get("title").and_then(Value::as_str) {
c.title = Some(t.to_string());
}
if let Some(cs) = &cats {
c.categories = cs.clone();
}
Expand All @@ -1761,6 +1768,14 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
})
}

fn chart_title_cmd(s: &mut Session, p: &Value) -> Result<Value> {
with_chart(s, p, "chart.title", |c| {
c.title = p.get("title").and_then(Value::as_str).map(String::from);
c.raw = None;
Ok(())
})
}

fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
Expand Down
84 changes: 84 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3576,3 +3576,87 @@ fn shape_ids_beyond_u32_are_rejected_not_wrapped() {
assert!(s.execute("shape.sites", &json!({"id": a.0})).is_ok());
assert!(s.execute("shape.connect", &json!({"from": a.0, "to": b.0})).is_ok());
}

#[test]
fn chart_first_five_types_and_title_data_flow() {
let mut s = session();

// 1. Test inserting chart with editData: true vs false
s.ui_requests.clear();
let r_default = s.execute("insert.chart", &json!({"type": "column"})).unwrap();
let id_default = r_default["id"].as_u64().unwrap();
assert!(id_default > 0);
assert!(
!s.ui_requests.iter().any(|req| matches!(req, UiRequest::Dialog { id: d_id, .. } if d_id == "chartData")),
"programmatic insert.chart should NOT open chartData dialog by default"
);

s.ui_requests.clear();
let r_ui = s.execute("insert.chart", &json!({"type": "column", "editData": true})).unwrap();
let id_ui = r_ui["id"].as_u64().unwrap();
assert!(
s.ui_requests.iter().any(|req| matches!(req, UiRequest::Dialog { id: d_id, .. } if d_id == "chartData")),
"insert.chart with editData: true should open chartData dialog"
);

// 2. Test chart.title command
s.execute("chart.title", &json!({"id": id_ui, "title": "Quarterly Performance"})).unwrap();
let st = s.doc().unwrap();
let sh = st.shape(deckcraft_model::ShapeId(id_ui as u32)).unwrap();
let deckcraft_model::ShapeKind::Chart(c) = &sh.kind else { panic!("not chart") };
assert_eq!(c.title.as_deref(), Some("Quarterly Performance"));

// Clear title
s.execute("chart.title", &json!({"id": id_ui, "title": null})).unwrap();
let st = s.doc().unwrap();
let sh = st.shape(deckcraft_model::ShapeId(id_ui as u32)).unwrap();
let deckcraft_model::ShapeKind::Chart(c) = &sh.kind else { panic!("not chart") };
assert_eq!(c.title, None);

// 3. Test chart.data with title, categories, and series
let new_cats = vec!["Jan", "Feb", "Mar"];
let new_series = vec![json!({"name": "Product A", "values": [12.5, 18.0, 24.3]}), json!({"name": "Product B", "values": [8.0, 14.2, 19.5]})];
s.execute(
"chart.data",
&json!({
"id": id_ui,
"title": "Q1 Product Sales",
"categories": new_cats,
"series": new_series
}),
)
.unwrap();

let st = s.doc().unwrap();
let sh = st.shape(deckcraft_model::ShapeId(id_ui as u32)).unwrap();
let deckcraft_model::ShapeKind::Chart(c) = &sh.kind else { panic!("not chart") };
assert_eq!(c.title.as_deref(), Some("Q1 Product Sales"));
assert_eq!(c.categories, vec!["Jan", "Feb", "Mar"]);
assert_eq!(c.series[0].name, "Product A");
assert_eq!(c.series[0].values, vec![Some(12.5), Some(18.0), Some(24.3)]);

// 4. Test switching between all FIRST_FIVE chart types via chart.type
for (t_name, expected_kind) in [
("bar", deckcraft_model::chart::ChartType::Bar),
("line", deckcraft_model::chart::ChartType::Line),
("area", deckcraft_model::chart::ChartType::Area),
("pie", deckcraft_model::chart::ChartType::Pie),
("column", deckcraft_model::chart::ChartType::Column),
] {
s.execute("chart.type", &json!({"id": id_ui, "type": t_name})).unwrap();
let st = s.doc().unwrap();
let sh = st.shape(deckcraft_model::ShapeId(id_ui as u32)).unwrap();
let deckcraft_model::ShapeKind::Chart(c) = &sh.kind else { panic!("not chart") };
assert_eq!(c.kind, expected_kind, "chart type should match {t_name}");
}

// 5. Test double click on chart opens chartData dialog
s.ui_requests.clear();
let xfrm = s.doc().unwrap().shape(deckcraft_model::ShapeId(id_ui as u32)).unwrap().xfrm.unwrap();
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None };
s.pointer(ev(tools::PointerKind::DoubleClick, xfrm.x + 20.0, xfrm.y + 20.0)).unwrap();
assert!(
s.ui_requests.iter().any(|req| matches!(req, UiRequest::Dialog { id: d_id, .. } if d_id == "chartData")),
"double clicking chart must trigger chartData dialog"
);
}
4 changes: 4 additions & 0 deletions crates/engine/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,10 @@ impl Session {
_ => Ok(Value::Null),
}
}
ShapeKind::Chart(_) => {
self.ui_requests.push(crate::UiRequest::Dialog { id: "chartData".into(), params: json!({"id": id.0}) });
Ok(Value::Null)
}
_ => {
self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() });
Ok(Value::Null)
Expand Down
3 changes: 3 additions & 0 deletions crates/model/src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ impl ChartType {
Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap
}

/// The first five primary chart types: Clustered Column, Clustered Bar, Line, Area, Pie.
pub const FIRST_FIVE: [ChartType; 5] = [ChartType::Column, ChartType::Bar, ChartType::Line, ChartType::Area, ChartType::Pie];

pub const MAIN: [ChartType; 12] = [
ChartType::Column,
ChartType::Bar,
Expand Down
18 changes: 18 additions & 0 deletions crates/render/src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
}
}
if c.data_labels {
for (i, q) in pts.iter().enumerate() {
if let Some(Some(v)) = s.values.get(i) {
label(
ctx,
rctx,
m,
&c.data_label(s, *v),
Rect::new(q.x - 25.0, q.y - base * 1.4, q.x + 25.0, q.y),
base * 0.85,
Align::Center,
Anchor::Bottom,
text_col,
false,
);
}
}
}
}
// A category position (0-based, fractional) as a slot, the way `slot` places the bars.
let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back };
Expand Down
21 changes: 21 additions & 0 deletions crates/render/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,27 @@ fn effects_tables_charts_render() {
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
}

#[test]
fn first_five_charts_render_with_data_labels() {
for kind in ChartType::FIRST_FIVE {
let mut chart = Chart::sample(kind);
chart.title = Some(format!("Test {}", kind.label()));
chart.data_labels = true;
chart.gridlines = true;
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 300.0)),
kind: ShapeKind::Chart(Box::new(chart)),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (960, 540));
let non_white = img.pixels.as_chunks::<4>().0.iter().filter(|px| px[0] < 250 || px[1] < 250 || px[2] < 250).count();
assert!(non_white > 100, "chart {kind:?} should draw visible ink: {non_white}");
}
}

#[test]
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
Expand Down
Loading