From 0545cee1e7ff49ddb9bf446cb1deef6fbfcabdf4 Mon Sep 17 00:00:00 2001 From: yefan911 Date: Sat, 17 Oct 2026 22:46:57 -0600 Subject: [PATCH] Charts: GridCraft spreadsheet data editor, live data/title editing, and first 5 chart types Co-Authored-By: Claude Code --- ROADMAP.md | 1 + crates/engine/src/cmd/insert.rs | 19 ++- crates/engine/src/tests.rs | 84 ++++++++++++ crates/engine/src/tools.rs | 4 + crates/model/src/chart.rs | 3 + crates/render/src/chart.rs | 18 +++ crates/render/src/tests.rs | 21 +++ crates/ui-egui/src/dialogs.rs | 218 +++++++++++++++++++++++++++----- crates/ui-egui/src/panes.rs | 41 ++++++ crates/ui-egui/src/ribbon.rs | 37 +++++- docs/gaps.md | 1 + 11 files changed, 411 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8ea976e6c..f27d23e5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 149957df5..12277612c 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "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 ), @@ -109,10 +109,11 @@ pub fn specs() -> Vec { "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", @@ -1251,6 +1252,9 @@ fn chart(s: &mut Session, p: &Value) -> Result { 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})) } @@ -1736,6 +1740,9 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { .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(); } @@ -1761,6 +1768,14 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { }) } +fn chart_title_cmd(s: &mut Session, p: &Value) -> Result { + 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 { // 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)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a333cb86f..38e292be8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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" + ); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f1acd9e9a..4dd775826 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 36dd9708e..9ca6a6492 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -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, diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac1857807..4713e30ce 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -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 }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 05d7c91ab..35727e5c7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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: diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 576a22979..08c29c1a1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -79,12 +79,13 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; let mut close = false; - let mut window = egui::Window::new(title(&d.id)) - .id(egui::Id::new(("dialog", d.id.clone()))) - .collapsible(false) - .resizable(false) - .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) - .open(&mut open); + let is_chart = d.id == "chartData"; + let mut window = egui::Window::new(title(&d.id)).id(egui::Id::new(("dialog", d.id.clone()))).collapsible(false).open(&mut open); + if is_chart { + window = window.resizable(true).default_size(vec2(580.0, 420.0)).min_size(vec2(440.0, 280.0)); + } else { + window = window.resizable(false).anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)); + } // Two columns of colour rows plus Save must fit the first frame (Theme Colors), as must Save // in Theme Fonts. A later resize clips the buttons (the window size sticks) and clicks miss them. if d.id == "themeColors" { @@ -94,6 +95,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { } window.show(ctx, |ui| { ui.set_min_width(match d.id.as_str() { + "chartData" => 460.0, "themeColors" => 700.0, "themeFonts" => 400.0, _ => 340.0, @@ -1967,56 +1969,194 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "chartData" => { + let target_id = d.params.get("id").and_then(Value::as_u64).map(|v| deckcraft_model::ShapeId(v as u32)); let ch = app.session.active().and_then(|s| { - s.selected_shapes() - .into_iter() - .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) + let shape = if let Some(tid) = target_id { + s.shape(tid).cloned() + } else { + s.selected_shapes().into_iter().find(|x| matches!(x.kind, deckcraft_model::ShapeKind::Chart(_))).cloned() + }; + shape.and_then(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) }); let Some((c, id)) = ch else { ui.label("Select a chart first."); let (ok, cancel) = buttons(ui, "Close"); return ok || cancel; }; - ui.label("Edit the data: first row = series names, first column = categories."); - let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { + + // GridCraft header banner + let gc_green = Color32::from_rgb(0x10, 0x7C, 0x41); + egui::Frame::NONE.fill(gc_green).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("📊 GridCraft Spreadsheet Data").color(Color32::WHITE).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new("Live chart sync").color(Color32::from_rgb(0xD0, 0xF0, 0xDD)).small()); + }); + }); + }); + ui.add_space(6.0); + + // Chart Title editor + let mut cur_title = c.title.clone().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Chart Title:").strong()); + let te = ui.add(egui::TextEdit::singleline(&mut cur_title).hint_text("Enter title…").desired_width(ui.available_width() - 80.0)); + if te.changed() { + let _ = app.run("chart.title", json!({"id": id.0, "title": cur_title})); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); + } + }); + ui.add_space(4.0); + + // Chart Type quick selector (first 5 primary types) + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("Type:").strong()); + for (lbl, kind) in [ + ("Clustered Column", deckcraft_model::chart::ChartType::Column), + ("Clustered Bar", deckcraft_model::chart::ChartType::Bar), + ("Line", deckcraft_model::chart::ChartType::Line), + ("Area", deckcraft_model::chart::ChartType::Area), + ("Pie", deckcraft_model::chart::ChartType::Pie), + ] { + let sel = c.kind == kind; + if ui.selectable_label(sel, lbl).clicked() { + let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{kind:?}")})); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); + } + } + }); + ui.separator(); + + // Spreadsheet data grid with two-way undo sync + let cur_rev = app.session.active().map(|s| s.revision).unwrap_or(0); + let last_rev = d.params.get("revision").and_then(Value::as_u64); + let mut grid: Vec> = if last_rev != Some(cur_rev) || d.params.get("grid").is_none() { let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; for (i, cat) in c.categories.iter().enumerate() { let mut row = vec![cat.clone()]; for s in &c.series { - row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); + let v_str = s + .values + .get(i) + .copied() + .flatten() + .map(|v| if (v - v.round()).abs() < 1e-9 && v.abs() < 1e15 { format!("{}", v.round() as i64) } else { format!("{v}") }) + .unwrap_or_default(); + row.push(v_str); } g.push(row); } + d.params["grid"] = json!(g); + d.params["revision"] = json!(cur_rev); g - }); - egui::Grid::new("chartdata").striped(true).show(ui, |ui| { - for row in grid.iter_mut() { - for cell in row.iter_mut() { - ui.add(egui::TextEdit::singleline(cell).desired_width(70.0)); + } else { + serde_json::from_value(d.params["grid"].clone()).unwrap_or_default() + }; + + let num_cols = grid.first().map(|r| r.len()).unwrap_or(2); + let mut cell_changed = false; + + egui::ScrollArea::both().max_height(200.0).show(ui, |ui| { + egui::Grid::new("gridcraft_chart_table").striped(true).spacing(vec2(2.0, 2.0)).show(ui, |ui| { + // Header row with column letters (Corner, A, B, C...) + ui.label(egui::RichText::new("").small()); + for col_idx in 0..num_cols { + let col_letter = (b'A' + (col_idx as u8).min(25)) as char; + let hdr_txt = if col_idx == 0 { format!("{col_letter} (Categories)") } else { format!("{col_letter}") }; + let hdr_color = if col_idx == 0 { Color32::from_rgb(0x70, 0x30, 0xA0) } else { Color32::from_rgb(0x10, 0x7C, 0x41) }; + ui.label(egui::RichText::new(hdr_txt).color(hdr_color).small().strong()); } ui.end_row(); - } + + // Data rows with row numbers (1, 2, 3...) + for (r_idx, row) in grid.iter_mut().enumerate() { + let row_num = format!("{}", r_idx + 1); + ui.label(egui::RichText::new(row_num).color(Color32::from_gray(120)).small().strong()); + + for (c_idx, cell) in row.iter_mut().enumerate() { + let width = if c_idx == 0 { 95.0 } else { 75.0 }; + let is_header = r_idx == 0 || c_idx == 0; + let te = ui.add(egui::TextEdit::singleline(cell).desired_width(width).text_color(if is_header { + Color32::from_gray(20) + } else { + Color32::BLACK + })); + if te.changed() { + cell_changed = true; + } + } + ui.end_row(); + } + }); }); + + if cell_changed { + apply_chart_grid(app, id, &grid); + d.params["grid"] = json!(grid); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); + } + + ui.add_space(4.0); ui.horizontal(|ui| { - if ui.button("+ Row").clicked() { + if ui.button("+ Add Row (Category)").clicked() { let n = grid.first().map(|r| r.len()).unwrap_or(2); - grid.push(vec![String::new(); n]); + let cat_num = grid.len(); + let mut new_row = vec![format!("Q{cat_num}")]; + new_row.resize(n, "0".to_string()); + grid.push(new_row); + apply_chart_grid(app, id, &grid); + d.params["grid"] = json!(grid); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); } - if ui.button("+ Series").clicked() { + if grid.len() > 2 && ui.button("- Remove Row").clicked() { + grid.pop(); + apply_chart_grid(app, id, &grid); + d.params["grid"] = json!(grid); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); + } + ui.separator(); + if ui.button("+ Add Series").clicked() { + let s_num = grid.first().map(|r| r.len()).unwrap_or(1); + for (r_idx, r) in grid.iter_mut().enumerate() { + if r_idx == 0 { + r.push(format!("Series {s_num}")); + } else { + r.push("1.0".to_string()); + } + } + apply_chart_grid(app, id, &grid); + d.params["grid"] = json!(grid); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); + } + if num_cols > 2 && ui.button("- Remove Series").clicked() { for r in grid.iter_mut() { - r.push(String::new()); + r.pop(); } + apply_chart_grid(app, id, &grid); + d.params["grid"] = json!(grid); + d.params["revision"] = json!(app.session.active().map(|s| s.revision).unwrap_or(0)); } }); - d.params = json!({"grid": grid}); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); - let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); - let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); - run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); - } - ok || cancel + + ui.separator(); + let mut close_dialog = false; + ui.horizontal(|ui| { + if ui.button("📋 Copy Table (TSV)").on_hover_text("Copy data to clipboard as tab-separated values for spreadsheets").clicked() { + let mut tsv = String::new(); + for row in &grid { + tsv.push_str(&row.join("\t")); + tsv.push('\n'); + } + ui.ctx().copy_text(tsv); + app.set_status("Chart data copied to clipboard (TSV)"); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Close").clicked() { + close_dialog = true; + } + }); + }); + close_dialog } "trim" => { let seed = trim_seed(app.session.active()); @@ -2602,6 +2742,22 @@ fn pretty_equation(s: &str) -> String { out.replace("sqrt", "√").replace("<=", "≤").replace(">=", "≥").replace("!=", "≠").replace("pi", "π").replace("+-", "±") } +fn apply_chart_grid(app: &mut SlideApp, id: deckcraft_model::ShapeId, grid: &[Vec]) { + let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); + let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); + let series: Vec = names + .iter() + .enumerate() + .map(|(k, n)| { + json!({ + "name": n, + "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>() + }) + }) + .collect(); + let _ = app.run("chart.data", json!({"id": id.0, "categories": cats, "series": series})); +} + pub fn about(_app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.vertical_centered(|ui| { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 39bab0785..6f703aba9 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -231,6 +231,11 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { { app.ui.format_tab = "picture".into(); } + if matches!(sh.kind, ShapeKind::Chart(_)) + && widgets::icon_toggle(ui, Icon::Chart, "Chart Options", true, app.ui.format_tab == "chart").clicked() + { + app.ui.format_tab = "chart".into(); + } }); ui.separator(); match app.ui.format_tab.as_str() { @@ -440,6 +445,42 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "picture.reset", json!({})); } } + "chart" => { + if let ShapeKind::Chart(c) = &sh.kind { + let c = (**c).clone(); + let id = sh.id; + ui.label(egui::RichText::new("Chart Title").font(theme::bold(13.0))); + let mut cur_title = c.title.clone().unwrap_or_default(); + if ui.text_edit_singleline(&mut cur_title).changed() { + run(app, "chart.title", json!({"id": id.0, "title": cur_title})); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Chart Type").font(theme::bold(13.0))); + for k in deckcraft_model::chart::ChartType::FIRST_FIVE { + if ui.selectable_label(c.kind == k, k.label()).clicked() { + run(app, "chart.type", json!({"id": id.0, "type": format!("{k:?}")})); + } + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Chart Elements").font(theme::bold(13.0))); + let mut dl = c.data_labels; + if ui.checkbox(&mut dl, "Data Labels").changed() { + run(app, "chart.options", json!({"id": id.0, "dataLabels": dl})); + } + let mut gl = c.gridlines; + if ui.checkbox(&mut gl, "Gridlines").changed() { + run(app, "chart.options", json!({"id": id.0, "gridlines": gl})); + } + let mut has_legend = c.legend.is_some(); + if ui.checkbox(&mut has_legend, "Show Legend").changed() { + run(app, "chart.options", json!({"id": id.0, "legend": if has_legend { json!("b") } else { Value::Null }})); + } + ui.add_space(8.0); + if ui.button("📊 Edit Data in GridCraft").clicked() { + run(app, "app.dialog", json!({"id": "chartData", "id_param": id.0})); + } + } + } _ => { // Fill & Line. ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3e43a68b9..2026bd1d2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1240,7 +1240,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { if let Some(k) = k && ui.button(k.label()).clicked() { - run(app, "insert.chart", json!({"type": format!("{k:?}")})); + run(app, "insert.chart", json!({"type": format!("{k:?}"), "editData": true})); } } }); @@ -3037,8 +3037,11 @@ fn table_layout(app: &mut SlideApp, ui: &mut Ui) { } fn chart_design(app: &mut SlideApp, ui: &mut Ui) { + let ch = app.session.active().and_then(|d| { + d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Chart(c) = &s.kind { Some(((**c).clone(), s.id)) } else { None }) + }); group(ui, |ui| { - let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); + let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", ch.is_some()); egui::Popup::menu(&r).show(|ui| { for k in chart_menu_types() { let Some(k) = k else { @@ -3047,7 +3050,7 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { }; if ui.button(k.label()).clicked() { // Replace the chart's type keeping its data. - if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { + if let Some((_, id)) = &ch { let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); } } @@ -3055,6 +3058,34 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { }); big(app, ui, Icon::EditData, "Edit\nData", "app.dialog", json!({"id": "chartData"})); }); + if let Some((c, id)) = ch { + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + let mut dl = c.data_labels; + if ui.checkbox(&mut dl, "Data Labels").changed() { + let _ = app.run("chart.options", json!({"id": id.0, "dataLabels": dl})); + } + let mut gl = c.gridlines; + if ui.checkbox(&mut gl, "Gridlines").changed() { + let _ = app.run("chart.options", json!({"id": id.0, "gridlines": gl})); + } + let mut has_legend = c.legend.is_some(); + if ui.checkbox(&mut has_legend, "Legend").changed() { + let _ = app.run("chart.options", json!({"id": id.0, "legend": if has_legend { json!("b") } else { Value::Null }})); + } + }); + ui.horizontal(|ui| { + ui.label("Title:"); + let mut title_txt = c.title.clone().unwrap_or_default(); + let te = ui.add(egui::TextEdit::singleline(&mut title_txt).desired_width(140.0)); + if te.changed() { + let _ = app.run("chart.title", json!({"id": id.0, "title": title_txt})); + } + }); + }); + }); + } arrange_group(app, ui); } diff --git a/docs/gaps.md b/docs/gaps.md index 4e5649512..22f680782 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -355,6 +355,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Charts: GridCraft spreadsheet data editor with live sync, chart.title and chart.data live editing, and data labels for Line/Area charts | | 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames | | 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed | | 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |