Browse / github.com/storytold/gridcraft / patch
Improve chart formatting and legend positioning
Improve chart formatting and legend positioning This pull request improves chart editing in GridCraft. Changes: - configurable chart font family and font size; - configurable X/Y axis-number font size; - configurable axis-title, legend, and main-title typography; - freely draggable chart legend; - improved chart text rendering; - tests covering chart text styles and rendering. All workspace tests pass. Note: XLSX persistence for the new custom typography and legend position will require a follow-up implementation. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- naceurh (imported from pull request #2742, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +819 −137 in 35 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a94639e23c26d5c5d8d84137
How it went
No other operator has reported using it yet.
Diff
raw · crates/chart/src/lib.rs crates/chart/src/render.rs crates/chart/src/render/frame.rs crates/chart/src/render/legend.rs crates/chart/src/resolve.rs crates/chart/src/tests.rs crates/engine/src/chart_svg.rs crates/engine/src/cmd/extra.rs crates/engine/src/cmd/extra/chart.rs crates/engine/src/cmd/forecast.rs crates/engine/src/cmd/insert.rs crates/engine/src/cmd/insert/chart.rs crates/engine/src/cmd/pivot/charts.rs crates/engine/src/cmd/print/draw.rs crates/engine/src/io/tests.rs crates/engine/src/tests.rs crates/model/src/features.rs crates/ui-egui/src/app/fonts.rs crates/ui-egui/src/chartview.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/charts.rs crates/ui-egui/src/dialogs/show.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/cursor.rs crates/ui-egui/src/grid/interact.rs crates/ui-egui/src/panes.rs crates/xlsx/src/chart.rs crates/xlsx/src/chartex.rs crates/xlsx/src/tests/chart_book_names.rs crates/xlsx/src/tests/fixtures.rs crates/xlsx/src/tests/literal_category_roundtrip.rs crates/xlsx/src/tests/multilevel_category_roundtrip.rs crates/xlsx/src/tests/roundtrip.rs crates/xlsx/src/tests/smooth_roundtrip.rs crates/xlsx/src/tests/trendline_roundtrip.rs
@@ -95,6 +95,9 @@ pub enum Prim { y: f32, text: String, size: f32,+ /// Optional font family. `None` uses the renderer's default chart font.+ #[serde(default)]+ font: Option<String>, color: Rgba, bold: bool, align: HAlign,@@ -106,6 +109,12 @@ pub enum Prim { /// Measures text width (the UI passes real font metrics; tests use a fixed-width approximation). pub trait Measure { fn text_width(&self, text: &str, size: f32, bold: bool) -> f32;++ /// Measures a text run using an optional chart font. Implementations that do not distinguish+ /// font families may use the default measurement, which keeps existing exporters compatible.+ fn text_width_with_font(&self, text: &str, size: f32, bold: bool, _font: Option<&str>) -> f32 {+ self.text_width(text, size, bold)+ } } /// Fixed-width approximation: `0.55 × size` per character (bold 10% wider).@@ -3,7 +3,7 @@ use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};+use gridcraft_model::{Chart, ChartKind, ChartTextStyle, ChartTextStyles, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind}; use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};@@ -48,6 +48,16 @@ const MAX_LABELS: usize = 200; struct Ctx<'a> { out: Vec<Prim>, m: &'a dyn Measure,+ text_styles: &'a ChartTextStyles,+}++#[derive(Clone, Copy)]+pub(super) enum TextRole {+ General,+ Title,+ AxisLabels,+ AxisTitles,+ Legend, } impl Ctx<'_> {@@ -55,6 +65,60 @@ impl Ctx<'_> { let w = self.m.text_width(s, size, false); if w.is_finite() { w.max(0.0) } else { 0.0 } }+ fn style(&self, role: TextRole) -> &ChartTextStyle {+ match role {+ TextRole::General => &self.text_styles.general,+ TextRole::Title => &self.text_styles.title,+ TextRole::AxisLabels => &self.text_styles.axis_labels,+ TextRole::AxisTitles => &self.text_styles.axis_titles,+ TextRole::Legend => &self.text_styles.legend,+ }+ }+ fn font(&self, role: TextRole) -> Option<String> {+ self.style(role).font.clone().or_else(|| self.text_styles.general.font.clone())+ }+ fn size(&self, role: TextRole, fallback: f32) -> f32 {+ self.style(role).size.or(self.text_styles.general.size).unwrap_or(fallback).clamp(1.0, 200.0)+ }+ fn tw_role(&self, s: &str, fallback: f32, role: TextRole) -> f32 {+ let size = self.size(role, fallback);+ let font = self.font(role);+ let w = self.m.text_width_with_font(s, size, false, font.as_deref());+ if w.is_finite() { w.max(0.0) } else { 0.0 }+ }+ fn fit_role(&self, s: &str, fallback: f32, maxw: f32, role: TextRole) -> String {+ if self.tw_role(s, fallback, role) <= maxw {+ return s.to_string();+ }+ let chars: Vec<char> = s.chars().collect();+ let mut n = chars.len().min(400);+ while n > 0 {+ n -= 1;+ let t: String = chars.iter().take(n).collect::<String>() + "…";+ if self.tw_role(&t, fallback, role) <= maxw {+ return if n == 0 { String::new() } else { t };+ }+ }+ String::new()+ }+ #[allow(clippy::too_many_arguments)]+ fn text_role(&mut self, role: TextRole, x: f32, y: f32, s: &str, fallback: f32, color: Rgba, align: HAlign, valign: VAlign) {+ if s.is_empty() {+ return;+ }+ self.out.push(Prim::Text {+ x,+ y,+ text: s.to_string(),+ size: self.size(role, fallback),+ font: self.font(role),+ color,+ bold: false,+ align,+ valign,+ rotation: 0.0,+ });+ } fn fit(&self, s: &str, size: f32, maxw: f32) -> String { if self.tw(s, size) <= maxw { return s.to_string();@@ -75,7 +139,18 @@ impl Ctx<'_> { if s.is_empty() { return; }- self.out.push(Prim::Text { x, y, text: s.to_string(), size, color, bold: false, align, valign, rotation: 0.0 });+ self.out.push(Prim::Text {+ x,+ y,+ text: s.to_string(),+ size,+ font: self.font(TextRole::General),+ color,+ bold: false,+ align,+ valign,+ rotation: 0.0,+ }); } fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: Rgba, stroke: Option<(Rgba, f32)>) { self.out.push(Prim::Rect { x, y, w, h, fill: Some(fill), stroke, radius: 0.0 });@@ -128,21 +203,21 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) return vec![]; } let data = sanitize(data);- let mut ctx = Ctx { out: Vec::new(), m };+ let mut ctx = Ctx { out: Vec::new(), m, text_styles: &chart.text }; ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 }); let pad = (w.min(h) * 0.04).clamp(2.0, 10.0); let mut area = Bx { x: pad, y: pad, w: w - 2.0 * pad, h: h - 2.0 * pad }; if let Some(t) = chart_title(chart, &data) {- let size = TITLE_SIZE.min(h * 0.12).max(4.0);- let t = ctx.fit(&t, size, area.w);- ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top);+ let size = ctx.size(TextRole::Title, TITLE_SIZE).min(h * 0.12).max(4.0);+ let t = ctx.fit_role(&t, TITLE_SIZE, area.w, TextRole::Title);+ ctx.text_role(TextRole::Title, w / 2.0, area.y, &t, TITLE_SIZE, TEXT, HAlign::Center, VAlign::Top); let used = size * 1.3 + 4.0; area.y += used; area.h -= used; } let entries = legend_entries(chart, &data); if !entries.is_empty() && area.ok() {- area = draw_legend(&mut ctx, &entries, chart.legend, area);+ area = draw_legend(&mut ctx, &entries, chart.legend, area, chart.legend_position); } if area.ok() && !data.series.is_empty() { draw_kind(&mut ctx, chart, &data, area);@@ -152,6 +227,9 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { use ChartKind::*;+ let independent_numeric_line = matches!(chart.kind, Line | LineMarkers)+ && data.series.len() > 1+ && data.series.iter().all(|s| s.x.as_ref().is_some_and(|xs| !xs.is_empty())); match chart.kind { Pie => pie(ctx, chart, data, area, 0.0, false), Doughnut => pie(ctx, chart, data, area, 0.5, false),@@ -167,6 +245,13 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { match chart.kind { BarClustered | BarStacked | BarStacked100 => cartesian(ctx, chart, data, area, true), Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),+ Line | LineMarkers if independent_numeric_line => {+ // Reuse the scatter renderer so each series keeps its own numeric X range.+ // ScatterLines also draws the connecting segments and markers.+ let mut numeric_chart = chart.clone();+ numeric_chart.kind = ScatterLines;+ scatter(ctx, &numeric_chart, data, area);+ } Histogram => histogram(ctx, chart, data, area), Waterfall => waterfall(ctx, chart, data, area), BoxWhisker => box_whisker(ctx, chart, data, area),@@ -244,11 +329,11 @@ pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> { } Some(Prim::Circle { cx: x, cy: y, r, fill, stroke: clamp_stroke(stroke) }) }- Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {+ Prim::Text { x, y, text, size, font, color, bold, align, valign, rotation } => { if !(fin(x) && fin(y) && fin(size) && fin(rotation)) || text.is_empty() { return None; }- Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), color, bold, align, valign, rotation })+ Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), font, color, bold, align, valign, rotation }) } }) .collect()@@ -41,20 +41,22 @@ pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option<String> { } pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {- let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;+ let title_size = ctx.size(TextRole::AxisTitles, AXIS_TITLE_SIZE);+ let lh = title_size * 1.4 + 2.0; if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {- let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);- ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom);+ let t = ctx.fit_role(t, AXIS_TITLE_SIZE, area.w, TextRole::AxisTitles);+ ctx.text_role(TextRole::AxisTitles, area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); area.h -= lh; } if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {- let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h);+ let t = ctx.fit_role(t, AXIS_TITLE_SIZE, area.h, TextRole::AxisTitles); if !t.is_empty() { ctx.out.push(Prim::Text { x: area.x, y: area.cy(), text: t,- size: AXIS_TITLE_SIZE,+ size: title_size,+ font: ctx.font(TextRole::AxisTitles), color: TEXT, bold: false, align: HAlign::Center,@@ -98,16 +100,16 @@ impl ValAxis<'_> { /// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx {- let ls = LABEL_SIZE;+ let ls = ctx.size(TextRole::AxisLabels, LABEL_SIZE); let lh = ls * 1.4; let ylabels = y.labels();- let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);+ let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max); if !horizontal { let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); let y2labels = y2.map(ValAxis::labels); let right = match (&y2labels, &cat) { (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),- (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),+ (None, CatAxis::Numeric(s, f)) => (ctx.tw_role(&f.format(s.max, Some(s.step)), LABEL_SIZE, TextRole::AxisLabels) / 2.0).min(area.w * 0.2), _ => 4.0, }; let levels = match &cat {@@ -123,12 +125,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti if grid { ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); }- ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle);+ ctx.text_role(TextRole::AxisLabels, plot.x - 4.0, yy, t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); } if let (Some(a), Some(l)) = (y2, &y2labels) { for (v, t) in l { let yy = plot.bottom() - a.scale.frac(*v) * plot.h;- ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle);+ ctx.text_role(TextRole::AxisLabels, plot.right() + 4.0, yy, t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); } } let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };@@ -138,11 +140,20 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti CatAxis::Labels(l, outer) => { let n = l.len().max(1); let cw = plot.w / n as f32;- let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);+ let lw = l.iter().take(2000).map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max); let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); for (j, t) in l.iter().enumerate().step_by(k) {- let t = ctx.fit(t, ls, cw * k as f32 - 2.0);- ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);+ let t = ctx.fit_role(t, LABEL_SIZE, cw * k as f32 - 2.0, TextRole::AxisLabels);+ ctx.text_role(+ TextRole::AxisLabels,+ plot.x + (j as f32 + 0.5) * cw,+ plot.bottom() + 3.0,+ &t,+ LABEL_SIZE,+ TEXT,+ HAlign::Center,+ VAlign::Top,+ ); } // Outer levels, one row each below the labels: a group's name centred under its // categories, and a separator at each group boundary down through the rows.@@ -151,8 +162,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti for (first, end) in level_groups(level, n) { let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {- let t = ctx.fit(t, ls, x1 - x0 - 2.0);- ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);+ let t = ctx.fit_role(t, LABEL_SIZE, x1 - x0 - 2.0, TextRole::AxisLabels);+ ctx.text_role(TextRole::AxisLabels, (x0 + x1) / 2.0, top, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); } if first > 0 { ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);@@ -164,12 +175,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti CatAxis::Numeric(s, f) => { let ticks = s.ticks(); let labels: Vec<String> = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect();- let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);+ let lw = labels.iter().map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max); let slot = plot.w / ticks.len().max(1) as f32; let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); for (v, t) in ticks.iter().zip(&labels).step_by(k) { let xx = plot.x + s.frac(*v) * plot.w;- ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);+ ctx.text_role(TextRole::AxisLabels, xx, plot.bottom() + 3.0, t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); } if s.min <= 0.0 && s.max >= 0.0 { let xx = plot.x + s.frac(0.0) * plot.w;@@ -183,12 +194,14 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti CatAxis::Labels(l, o) => (l.to_vec(), o), CatAxis::Numeric(..) => (vec![], &[]), };- let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);+ let lw = (labels.iter().take(2000).map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); // Outer levels stand in columns left of the labels, each as wide as its longest name.- let level_w: Vec<f32> =- outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();+ let level_w: Vec<f32> = outer+ .iter()+ .map(|l| (l.iter().take(2000).map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15))+ .collect(); let levels_w: f32 = level_w.iter().sum();- let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;+ let right = ylabels.last().map(|(_, t)| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; if !plot.ok() { return plot;@@ -202,7 +215,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); } if i % k == 0 {- ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);+ ctx.text_role(TextRole::AxisLabels, xx, plot.bottom() + 3.0, t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); } } let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };@@ -212,8 +225,17 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti let ch = plot.h / n as f32; let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); for (j, t) in labels.iter().enumerate().step_by(k) {- let t = ctx.fit(t, ls, lw - 6.0);- ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);+ let t = ctx.fit_role(t, LABEL_SIZE, lw - 6.0, TextRole::AxisLabels);+ ctx.text_role(+ TextRole::AxisLabels,+ plot.x - 4.0,+ plot.bottom() - (j as f32 + 0.5) * ch,+ &t,+ LABEL_SIZE,+ TEXT,+ HAlign::Right,+ VAlign::Middle,+ ); } // Outer levels: a group's name centred beside its categories, separators at the // boundaries running right to the axis.@@ -223,8 +245,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti for (first, end) in level_groups(level, n) { let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {- let t = ctx.fit(t, ls, w - 4.0);- ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);+ let t = ctx.fit_role(t, LABEL_SIZE, w - 4.0, TextRole::AxisLabels);+ ctx.text_role(TextRole::AxisLabels, (x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Middle); } if first > 0 { ctx.hline(x0, plot.x, y1, AXIS, 0.75);@@ -93,69 +93,87 @@ fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) } } -pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {- let size = LABEL_SIZE;+pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx, custom: Option<[f32; 2]>) -> Bx {+ if let Some([x, y]) = custom {+ let x = x.clamp(area.x, area.right());+ let y = y.clamp(area.y, area.bottom());+ let custom_area = Bx { x, y, w: (area.right() - x).max(4.0), h: (area.bottom() - y).max(4.0) };+ let _ = draw_horizontal_legend(ctx, entries, custom_area, false);+ return area;+ }+ match pos {+ LegendPos::None => area,+ LegendPos::Top | LegendPos::Bottom => {+ let used = draw_horizontal_legend(ctx, entries, area, pos == LegendPos::Bottom);+ if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }+ }+ LegendPos::Left | LegendPos::Right => draw_side_legend(ctx, entries, area, pos == LegendPos::Right),+ }+}++fn draw_horizontal_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], area: Bx, bottom: bool) -> f32 {+ let size = ctx.size(TextRole::Legend, LABEL_SIZE); let row_h = size * 1.6; let s = size * 0.8; let gap = 4.0; let spacing = size * 1.3;- let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);- match pos {- LegendPos::None => area,- LegendPos::Top | LegendPos::Bottom => {- let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);- let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];- let mut cur = 0.0f32;- for (i, e) in entries.iter().enumerate() {- let iw = item_w(ctx, e).min(area.w);- let need = if cur > 0.0 { spacing + iw } else { iw };- if cur > 0.0 && cur + need > area.w {- if rows.len() >= max_rows {- break;- }- rows.push(vec![]);- cur = 0.0;- }- cur += if cur > 0.0 { spacing + iw } else { iw };- if let Some(r) = rows.last_mut() {- r.push((i, iw));- }- }- let lh = rows.len() as f32 * row_h;- let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };- for (ri, row) in rows.iter().enumerate() {- let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;- let mut x = area.x + (area.w - total).max(0.0) / 2.0;- let yc = y0 + ri as f32 * row_h + row_h / 2.0;- for &(i, iw) in row {- if let Some(e) = entries.get(i) {- draw_swatch(ctx, e.2, x, yc, s, e.1);- let sx = swatch_w(e.2, s) + gap;- let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);- ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);- }- x += iw + spacing;- }+ let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw_role(&e.0, LABEL_SIZE, TextRole::Legend);+ let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);+ let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];+ let mut cur = 0.0f32;+ for (i, e) in entries.iter().enumerate() {+ let iw = item_w(ctx, e).min(area.w);+ let need = if cur > 0.0 { spacing + iw } else { iw };+ if cur > 0.0 && cur + need > area.w {+ if rows.len() >= max_rows {+ break; }- let used = lh + 4.0;- if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }+ rows.push(vec![]);+ cur = 0.0;+ }+ cur += if cur > 0.0 { spacing + iw } else { iw };+ if let Some(r) = rows.last_mut() {+ r.push((i, iw)); }- LegendPos::Left | LegendPos::Right => {- let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);- let max_rows = ((area.h / row_h).floor() as usize).max(1);- let n = entries.len().min(max_rows);- let lh = n as f32 * row_h;- let y0 = area.y + (area.h - lh).max(0.0) / 2.0;- let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };- for (i, e) in entries.iter().take(n).enumerate() {- let yc = y0 + i as f32 * row_h + row_h / 2.0;- draw_swatch(ctx, e.2, x0, yc, s, e.1);+ }+ let lh = rows.len() as f32 * row_h;+ let y0 = if bottom { area.bottom() - lh } else { area.y };+ for (ri, row) in rows.iter().enumerate() {+ let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;+ let mut x = area.x + (area.w - total).max(0.0) / 2.0;+ let yc = y0 + ri as f32 * row_h + row_h / 2.0;+ for &(i, iw) in row {+ if let Some(e) = entries.get(i) {+ draw_swatch(ctx, e.2, x, yc, s, e.1); let sx = swatch_w(e.2, s) + gap;- let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);- ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);+ let t = ctx.fit_role(&e.0, LABEL_SIZE, (iw - sx).max(0.0) + 0.5, TextRole::Legend);+ ctx.text_role(TextRole::Legend, x + sx, yc, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); }- let used = colw + 8.0;- if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }+ x += iw + spacing; } }+ lh + 4.0+}++fn draw_side_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], area: Bx, right: bool) -> Bx {+ let size = ctx.size(TextRole::Legend, LABEL_SIZE);+ let row_h = size * 1.6;+ let s = size * 0.8;+ let gap = 4.0;+ let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw_role(&e.0, LABEL_SIZE, TextRole::Legend);+ let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);+ let max_rows = ((area.h / row_h).floor() as usize).max(1);+ let n = entries.len().min(max_rows);+ let lh = n as f32 * row_h;+ let y0 = area.y + (area.h - lh).max(0.0) / 2.0;+ let x0 = if right { area.right() - colw } else { area.x };+ for (i, e) in entries.iter().take(n).enumerate() {+ let yc = y0 + i as f32 * row_h + row_h / 2.0;+ draw_swatch(ctx, e.2, x0, yc, s, e.1);+ let sx = swatch_w(e.2, s) + gap;+ let t = ctx.fit_role(&e.0, LABEL_SIZE, (colw - sx).max(0.0) + 0.5, TextRole::Legend);+ ctx.text_role(TextRole::Legend, x0 + sx, yc, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);+ }+ let used = colw + 8.0;+ if right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } }@@ -220,11 +220,26 @@ pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind { if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind } } +/// A line chart normally uses one shared category axis. When several series+/// provide different numeric category ranges, however, those ranges are really+/// independent X coordinates and must be preserved for numeric rendering.+fn has_independent_numeric_x(wb: &Workbook, sheet: usize, chart: &Chart) -> bool {+ if !matches!(chart.kind, ChartKind::Line | ChartKind::LineMarkers) || chart.series.len() < 2 {+ return false;+ }+ let formulas: Vec<&str> = chart.series.iter().filter_map(|s| s.categories.as_deref()).collect();+ if formulas.len() != chart.series.len() || formulas.windows(2).all(|pair| pair[0] == pair[1]) {+ return false;+ }+ formulas.iter().all(|formula| !eval_list(wb, sheet, formula).iter().any(Value::is_text))+}+ /// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X /// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the /// default palette derived from the workbook theme accents). pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {- let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);+ let numeric_x =+ matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble) || has_independent_numeric_x(wb, sheet, chart); let mut series = Vec::new(); let mut categories: Option<Vec<String>> = None; let mut category_levels = Vec::new();@@ -49,6 +49,8 @@ fn chart(kind: ChartKind) -> Chart { y_title: Some("Revenue".into()), source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, } }@@ -381,6 +383,80 @@ fn scatter_with_text_x_values_numbers_the_points() { } } +#[test]+fn line_chart_with_independent_numeric_x_uses_a_numeric_axis() {+ let mut wb = Workbook::new();+ let sh = wb.sheet_mut(0).unwrap();+ for (row, (x1, y1, x2, y2)) in [(0.0, 1.0, 0.0, 2.0), (1.0, 3.0, 2.0, 4.0), (4.0, 2.0, 4.0, 1.0)].into_iter().enumerate() {+ let r = row as u32;+ sh.set_value(CellRef::new(r, 0), Value::Number(x1));+ sh.set_value(CellRef::new(r, 1), Value::Number(y1));+ sh.set_value(CellRef::new(r, 2), Value::Number(x2));+ sh.set_value(CellRef::new(r, 3), Value::Number(y2));+ }+ let mut c = chart(ChartKind::Line);+ c.data_labels = false;+ c.series = vec![+ Series {+ categories: Some("Sheet1!$A$1:$A$3".into()),+ values: "Sheet1!$B$1:$B$3".into(),+ color: Some(Color::Rgb(0x010203)),+ ..Default::default()+ },+ Series {+ categories: Some("Sheet1!$C$1:$C$3".into()),+ values: "Sheet1!$D$1:$D$3".into(),+ color: Some(Color::Rgb(0x040506)),+ ..Default::default()+ },+ ];++ let d = resolve(&wb, 0, &c);+ assert_eq!(d.series[0].x, Some(vec![Some(0.0), Some(1.0), Some(4.0)]));+ assert_eq!(d.series[1].x, Some(vec![Some(0.0), Some(2.0), Some(4.0)]));+ let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);+ let lines: Vec<&Vec<[f32; 2]>> = prims+ .iter()+ .filter_map(|p| match p {+ Prim::Line { pts, color, .. } if *color == [1, 2, 3, 255] => Some(pts),+ _ => None,+ })+ .collect();+ assert!(!lines.is_empty());+ let pts = lines.iter().max_by_key(|pts| pts.len()).copied().unwrap();+ let first = pts[1][0] - pts[0][0];+ let second = pts[2][0] - pts[1][0];+ assert!(second > first * 2.0, "line X coordinates are still category-spaced: {pts:?}");+}++#[test]+fn chart_text_styles_reach_title_axis_labels_and_legend_primitives() {+ let mut c = chart(ChartKind::Line);+ c.data_labels = false;+ c.text.title.font = Some("Test Title Font".into());+ c.text.title.size = Some(22.0);+ c.text.axis_labels.font = Some("Test Axis Font".into());+ c.text.axis_labels.size = Some(15.0);+ c.text.legend.font = Some("Test Legend Font".into());+ c.text.legend.size = Some(13.0);+ let prims = render(&c, &sample(ChartKind::Line), 640.0, 400.0, &ApproxMeasure);+ let title = prims.iter().find_map(|p| match p {+ Prim::Text { text, size, font, .. } if text == "Quarterly Sales" => Some((*size, font.clone())),+ _ => None,+ });+ assert_eq!(title, Some((22.0, Some("Test Title Font".into()))));+ let axis_label = prims.iter().find_map(|p| match p {+ Prim::Text { size, font, .. } if (*size - 15.0).abs() < f32::EPSILON => Some((*size, font.clone())),+ _ => None,+ });+ assert_eq!(axis_label, Some((15.0, Some("Test Axis Font".into()))));+ let legend_label = prims.iter().find_map(|p| match p {+ Prim::Text { text, size, font, .. } if text == "North" => Some((*size, font.clone())),+ _ => None,+ });+ assert_eq!(legend_label, Some((13.0, Some("Test Legend Font".into()))));+}+ fn pixel(img: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [img[i], img[i + 1], img[i + 2], img[i + 3]]@@ -68,7 +68,7 @@ fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String { paint(&mut out, *fill, *stroke); out.push_str("/>"); }- Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {+ Prim::Text { x, y, text, size, font, color, bold, align, valign, rotation } => { // Baseline offsets as in the PDF export. let dy = match valign { VAlign::Top => size * 0.78,@@ -81,6 +81,9 @@ fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String { HAlign::Right => "end", }; let _ = write!(out, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));+ if let Some(font) = font.as_deref() {+ let _ = write!(out, " font-family=\"{}\"", xml_attr(font));+ } if *bold { out.push_str(" font-weight=\"bold\""); }@@ -132,6 +135,10 @@ fn hex(c: Rgba) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } +fn xml_attr(s: &str) -> String {+ s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")+}+ fn opacity(attr: &str, c: Rgba) -> String { if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) } }@@ -163,6 +170,7 @@ mod tests { y: 6.0, text: "A<B".into(), size: 10.0,+ font: None, color: red, bold: true, align: HAlign::Center,@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> { "Select Data…", ["Chart Design", "Data"], None,- "{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",+ "{chart?, series?: [{name?, x?, y}], range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}", has_doc, select_data ),@@ -37,7 +37,8 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> { Some("right") => LegendPos::Right, _ => LegendPos::Bottom, }- }+ };+ c.legend_position = None; } "dataLabels" => c.data_labels = on, "gridlines" => c.gridlines = on,@@ -124,6 +125,7 @@ pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> { _ => (LegendPos::Right, false, true, false), }; c.legend = legend;+ c.legend_position = None; c.data_labels = labels; c.gridlines = grid; if axes {@@ -161,6 +163,35 @@ pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result<Json> { pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> { let d = s.doc()?;+ // Explicit series keep each series' name, X range and Y range independent. This is the+ // representation needed by scatter charts whose curves do not share one X axis.+ let explicit_series = match p.get("series") {+ None => None,+ Some(Json::Array(items)) => {+ if items.is_empty() {+ return Err(bad("chart.selectData", "`series` must contain at least one series"));+ }+ if items.len() > 4096 {+ return Err(bad("chart.selectData", "too many chart series"));+ }+ let mut series = Vec::with_capacity(items.len());+ for item in items {+ let obj = item.as_object().ok_or_else(|| bad("chart.selectData", "each series must be an object"))?;+ let name = obj.get("name").and_then(Json::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);+ let x = obj.get("x").and_then(Json::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);+ let y = obj+ .get("y")+ .and_then(Json::as_str)+ .map(str::trim)+ .filter(|v| !v.is_empty())+ .ok_or_else(|| bad("chart.selectData", "each series needs a non-empty Y range"))?+ .to_string();+ series.push(Series { name, categories: x, values: y, ..Series::default() });+ }+ Some(series)+ }+ Some(_) => return Err(bad("chart.selectData", "`series` must be an array")),+ }; // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. let rebuilt = match str_param(p, "range") { Some(range) => {@@ -205,10 +236,15 @@ pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> { .unwrap_or_default(); remove.sort_unstable(); remove.dedup();- if rebuilt.is_none() && categories.is_none() && remove.is_empty() {- return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));+ if explicit_series.is_none() && rebuilt.is_none() && categories.is_none() && remove.is_empty() {+ return Err(bad("chart.selectData", "missing `series`, `range`, `categories` or `removeSeries`")); } with_chart(s, p, move |c| {+ if let Some(series) = explicit_series {+ c.series = series;+ c.source = None;+ c.by_rows = false;+ } if let Some((series, source, by_rows)) = rebuilt { c.series = series; crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series);@@ -430,6 +430,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); cx.wb.sheets.insert(at, Arc::new(sheet));@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> { "Chart Properties", ["Chart Design"], None,- "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",+ "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, legendPosition?: [x,y], text?: {general|title|axisLabels|axisTitles|legend: {font?, size?}}, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", has_doc, chart_set ),@@ -263,6 +263,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { y_title: None, source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())), by_rows,+ text: Default::default(),+ legend_position: None, pivot: None, }; Ok((sheet, chart))@@ -337,6 +339,34 @@ pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> { if let Some(t) = p.get("yTitle") { c.y_title = t.as_str().map(str::to_string); }+ if let Some(text) = p.get("text").and_then(Json::as_object) {+ let apply = |key: &str, style: &mut gridcraft_model::ChartTextStyle| {+ let Some(v) = text.get(key).and_then(Json::as_object) else { return };+ if let Some(font) = v.get("font") {+ style.font = font.as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);+ }+ if let Some(size) = v.get("size").and_then(Json::as_f64).filter(|v| v.is_finite()) {+ style.size = Some((size as f32).clamp(4.0, 72.0));+ }+ };+ apply("general", &mut c.text.general);+ apply("title", &mut c.text.title);+ apply("axisLabels", &mut c.text.axis_labels);+ apply("axisTitles", &mut c.text.axis_titles);+ apply("legend", &mut c.text.legend);+ }+ if let Some(pos) = p.get("legendPosition") {+ if pos.is_null() {+ c.legend_position = None;+ } else if let Some(pos) = pos.as_array()+ && pos.len() == 2+ && let (Some(x), Some(y)) = (pos[0].as_f64(), pos[1].as_f64())+ && x.is_finite()+ && y.is_finite()+ {+ c.legend_position = Some([x.clamp(0.0, c.anchor.width as f64) as f32, y.clamp(0.0, c.anchor.height as f64) as f32]);+ }+ } if let Some(at) = at { c.anchor.cell = at; }@@ -68,6 +68,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: Some(link), }; edit(s, |cx| {@@ -135,7 +135,7 @@ pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf) let (px, py) = xf.p(*cx, *cy); pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); }- Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {+ Prim::Text { x, y, text, size, color, bold, align, valign, rotation, .. } => { let font = if *bold { Font::HelveticaBold } else { Font::Helvetica }; let size = size * k; let w = gridcraft_pdf::text_width(font, size, text);@@ -29,7 +29,10 @@ fn save_fails_upfront_when_the_temp_name_is_taken() { let victim = dir.join("victim.txt"); std::fs::write(&victim, b"original").unwrap(); let dest = dir.join("book.xlsx");+ #[cfg(unix)] std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap();+ #[cfg(windows)]+ std::os::windows::fs::symlink_file(&victim, dir.join("book.xlsx.sctmp")).unwrap(); let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err(); assert_eq!(std::fs::read(&victim).unwrap(), b"original"); assert!(!dir.join("book.xlsx").exists(), "{err}");@@ -200,6 +200,34 @@ fn chart_category_axis_from_blank_corner_and_select_data() { assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]); } +#[test]+fn chart_select_data_accepts_independent_series_ranges() {+ let mut s = s();+ s.execute("range.setValues", json!({"range": "A1", "values": [[0, 10], [1, 20], [2, 30]]})).unwrap();+ s.execute("range.setValues", json!({"range": "D1", "values": [[100, 7], [200, 9], [300, 11]]})).unwrap();+ s.execute("insert.chart", json!({"type": "scatter", "range": "A1:B3"})).unwrap();++ s.execute(+ "chart.selectData",+ json!({+ "series": [+ {"name": "First curve", "x": "Sheet1!$A$1:$A$3", "y": "Sheet1!$B$1:$B$3"},+ {"name": "Second curve", "x": "Sheet1!$D$1:$D$3", "y": "Sheet1!$E$1:$E$3"}+ ]+ }),+ )+ .unwrap();++ let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();+ assert_eq!(c.series.len(), 2);+ assert_eq!(c.series[0].name.as_deref(), Some("First curve"));+ assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$1:$A$3"));+ assert_eq!(c.series[0].values, "Sheet1!$B$1:$B$3");+ assert_eq!(c.series[1].name.as_deref(), Some("Second curve"));+ assert_eq!(c.series[1].categories.as_deref(), Some("Sheet1!$D$1:$D$3"));+ assert_eq!(c.series[1].values, "Sheet1!$E$1:$E$3");+}+ #[test] fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() { let mut s = s();@@ -540,6 +540,37 @@ impl Trendline { } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]+pub struct ChartTextStyle {+ /// Font family override. `None` follows the workbook/UI fallback font.+ #[serde(default)]+ pub font: Option<String>,+ /// Font size in chart points. `None` keeps the automatic size for the role.+ #[serde(default)]+ pub size: Option<f32>,+}++impl Default for ChartTextStyle {+ fn default() -> Self {+ Self { font: None, size: None }+ }+}++#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+pub struct ChartTextStyles {+ /// Global fallback for all chart text.+ #[serde(default)]+ pub general: ChartTextStyle,+ #[serde(default)]+ pub title: ChartTextStyle,+ #[serde(default)]+ pub axis_labels: ChartTextStyle,+ #[serde(default)]+ pub axis_titles: ChartTextStyle,+ #[serde(default)]+ pub legend: ChartTextStyle,+}+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Chart { pub id: u32,@@ -556,6 +587,12 @@ pub struct Chart { /// Source range when created from a selection (for Switch Row/Column). pub source: Option<String>, pub by_rows: bool,+ /// Typography overrides for the chart title, axes, legend and general labels.+ #[serde(default)]+ pub text: ChartTextStyles,+ /// Optional free legend position in chart coordinates: top-left `(x, y)`.+ #[serde(default)]+ pub legend_position: Option<[f32; 2]>, /// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report /// whenever the PivotTable is refreshed or its layout changes. #[serde(default)]@@ -26,6 +26,19 @@ impl SheetApp { names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); names.push(d.wb.theme.major_font.clone()); names.push(d.wb.theme.minor_font.clone());+ names.extend(d.wb.sheets.iter().flat_map(|sh| {+ sh.charts.iter().flat_map(|c| {+ [+ c.text.general.font.clone(),+ c.text.title.font.clone(),+ c.text.axis_labels.font.clone(),+ c.text.axis_titles.font.clone(),+ c.text.legend.font.clone(),+ ]+ .into_iter()+ .flatten()+ })+ })); // Text boxes and shapes name their own typefaces. names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); }@@ -24,6 +24,13 @@ impl gridcraft_chart::Measure for EguiMeasure<'_> { let font = if bold { theme::ui_bold(size * self.z) } else { theme::ui_font(size * self.z) }; self.painter.layout_no_wrap(text.to_string(), font, Color32::BLACK).size().x / self.z.max(0.01) }++ fn text_width_with_font(&self, text: &str, size: f32, bold: bool, family: Option<&str>) -> f32 {+ let font = family+ .map(|name| FontId::new(size * self.z, theme::cell_family(name, bold, false)))+ .unwrap_or_else(|| if bold { theme::ui_bold(size * self.z) } else { theme::ui_font(size * self.z) });+ self.painter.layout_no_wrap(text.to_string(), font, Color32::BLACK).size().x / self.z.max(0.01)+ } } /// Paints `chart` (not necessarily on the sheet: the Recommended Charts preview) scaled to fit@@ -118,8 +125,11 @@ pub fn paint_prims(p: &Painter, origin: Pos2, prims: &[Prim], z: f32) { p.circle_stroke(pt(*cx, *cy), r * z, Stroke::new(w * z.max(0.5), rgba(*c))); } }- Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {- let font: FontId = if *bold { theme::ui_bold(size * z) } else { theme::ui_font(size * z) };+ Prim::Text { x, y, text, size, font, color, bold, align, valign, rotation } => {+ let font: FontId = font+ .as_deref()+ .map(|name| FontId::new(size * z, theme::cell_family(name, *bold, false)))+ .unwrap_or_else(|| if *bold { theme::ui_bold(size * z) } else { theme::ui_font(size * z) }); let galley = p.layout_no_wrap(text.clone(), font, rgba(*color)); let sz = galley.size(); let ax = match align {@@ -492,6 +502,45 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> None } +/// Returns the chart legend under the pointer and its current chart-local origin. The hit box is+/// deliberately generous because the rendered legend can wrap when the chart is narrow.+pub fn legend_drag_info(geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(u32, Pos2, [f32; 2])> {+ for ch in sh.charts.iter().rev() {+ if ch.legend == gridcraft_engine::model::LegendPos::None {+ continue;+ }+ let r = anchor_rect(geo, sh, &ch.anchor);+ if !r.contains(p) {+ continue;+ }+ let w = ch.anchor.width.max(1.0);+ let h = ch.anchor.height.max(1.0);+ let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);+ let title_used = ch.title.as_deref().filter(|t| !t.trim().is_empty()).map(|_| 14.0_f32.min(h * 0.12).max(4.0) * 1.3 + 4.0).unwrap_or(0.0);+ let area = egui::Rect::from_min_size(pos2(pad, pad + title_used), vec2((w - 2.0 * pad).max(4.0), (h - 2.0 * pad - title_used).max(4.0)));+ let origin = if let Some(origin) = ch.legend_position {+ origin+ } else {+ match ch.legend {+ gridcraft_engine::model::LegendPos::Top => [area.left(), area.top()],+ gridcraft_engine::model::LegendPos::Bottom => [area.left(), (area.bottom() - 28.0).max(area.top())],+ gridcraft_engine::model::LegendPos::Left => [area.left(), (area.center().y - 28.0).max(area.top())],+ gridcraft_engine::model::LegendPos::Right => [(area.right() - (w * 0.35)).max(area.left()), (area.center().y - 28.0).max(area.top())],+ gridcraft_engine::model::LegendPos::None => continue,+ }+ };+ let side = matches!(ch.legend, gridcraft_engine::model::LegendPos::Left | gridcraft_engine::model::LegendPos::Right);+ let box_w = if side { (w * 0.4).max(80.0) } else { (w * 0.85).max(100.0) };+ let box_h = if side { (h * 0.45).max(32.0) } else { 34.0 };+ let local = pos2((p.x - r.left()) / geo.z.max(0.01), (p.y - r.top()) / geo.z.max(0.01));+ let hit = egui::Rect::from_min_size(pos2(origin[0], origin[1]), vec2(box_w, box_h));+ if hit.contains(local) {+ return Some((ch.id, p, origin));+ }+ }+ None+}+ fn selection_frame(p: &Painter, r: Rect) { let t = Tokens::get(p.ctx()); p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside);@@ -930,11 +930,23 @@ impl Dialog { let sh = doc.wb.sheet(si)?; let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?; let data = gridcraft_chart::resolve(&doc.wb, si, ch);- let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();+ let series: Vec<Json> = ch+ .series+ .iter()+ .enumerate()+ .map(|(i, s)| {+ json!({+ "name": s.name.clone().unwrap_or_else(|| data.series.get(i).map(|x| x.name.clone()).unwrap_or_else(|| format!("Series{}", i + 1))),+ "x": s.categories.clone().unwrap_or_default(),+ "y": s.values.clone(),+ })+ })+ .collect(); let cats = ch.series.first().and_then(|x| x.categories.clone()).unwrap_or_default();- Some((ch.source.clone().unwrap_or_default(), cats, names))+ let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();+ Some((ch.source.clone().unwrap_or_default(), cats, series, names)) });- let Some((range, cats, names)) = found else {+ let Some((range, cats, series, names)) = found else { app.message = Some(("Select Data".into(), "Select a chart first.".into())); return None; };@@ -942,9 +954,23 @@ impl Dialog { Dialog::custom( "selectData", "Select Data Source",- json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}),+ json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "series": series, "series0": series, "remove": []}), ) }+ "chartSeries" => {+ let mut d = Dialog::custom(+ "chartSeries",+ "Edit Series",+ json!({+ "index": p("index"),+ "name": p("name"),+ "x": p("x"),+ "y": p("y"),+ }),+ );+ d.focus_first_field = false;+ d+ } "journal" => Dialog::custom("journal", "Action Journal", json!({})), "macros" => { let mut d = Dialog::custom("macros", "Macros", json!({}));@@ -1029,6 +1055,23 @@ impl Dialog { mod tests { use super::*; + #[test]+ fn chart_series_range_picker_replaces_an_existing_reference() {+ let mut d = Dialog::custom("chartSeries", "Edit Series", json!({"x": "=Sheet1!$C$3:$C$130"}));+ d.pick = Some(RefPick {+ key: "x",+ sheet: String::new(),+ caret: 19,+ point: Some((1, 19)),+ anchor: CellRef::new(0, 0),+ refocus: false,+ applies_to: String::new(),+ });+ d.pick_reference(CellRef::new(2, 4), "Sheet1", false);+ d.pick_reference(CellRef::new(129, 4), "Sheet1", true);+ assert_eq!(d.values["x"], json!("=Sheet1!$E$3:$E$130"));+ }+ #[test] fn remove_duplicates_prefills_the_selection_and_submits_the_edited_range() { let mut session = gridcraft_engine::Session::default();@@ -122,57 +122,100 @@ pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut } } -/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed.+/// Draws the compact chart data dialog. Each series is edited in its own dialog so long+/// references do not force the parent dialog to become excessively wide. pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { let label = ui.label(label); let mut v = text(d, key);- if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() {+ if ui.add(egui::TextEdit::singleline(&mut v).desired_width(960.0)).labelled_by(label.id).changed() { d.values.insert(key.into(), json!(v)); } }; edit(ui, d, "range", "Chart data range:"); ui.add_space(6.0); ui.strong("Legend Entries (Series)");- let names: Vec<String> =- d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default();- let mut remove: Vec<u64> =- d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default();- let kept = names.len().saturating_sub(remove.len());- egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| {- for (i, name) in names.iter().enumerate() {- let i = i as u64;- let removed = remove.contains(&i);- ui.horizontal(|ui| {- let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() };- ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) });- if removed {- if ui.small_button("Keep").clicked() {- remove.retain(|x| *x != i);- }- } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() {- remove.push(i);- }- });+ ui.label("Use Select… to edit the legend title and the independent X/Y ranges for each curve.");+ let mut rows: Vec<(String, String, String)> = d+ .values+ .get("series")+ .and_then(Json::as_array)+ .map(|items| {+ items+ .iter()+ .map(|item| {+ (+ item.get("name").and_then(Json::as_str).unwrap_or("").to_string(),+ item.get("x").and_then(Json::as_str).unwrap_or("").to_string(),+ item.get("y").and_then(Json::as_str).unwrap_or("").to_string(),+ )+ })+ .collect()+ })+ .unwrap_or_default();+ egui::Grid::new("chart_series_editor").striped(true).num_columns(5).spacing([6.0, 4.0]).show(ui, |ui| {+ ui.strong("Legend title");+ ui.strong("X values");+ ui.strong("Y values");+ ui.strong("");+ ui.strong("");+ ui.end_row();+ let mut remove = None;+ let mut edit = None;+ let can_remove = rows.len() > 1;+ for (i, (name, x, y)) in rows.iter_mut().enumerate() {+ ui.add_sized([150.0, 24.0], egui::Label::new(egui::RichText::new(name.as_str()).strong()));+ ui.add_sized([175.0, 24.0], egui::Label::new(egui::RichText::new(short_ref(x)).monospace()));+ ui.add_sized([175.0, 24.0], egui::Label::new(egui::RichText::new(short_ref(y)).monospace()));+ if ui.button("Select…").clicked() {+ edit = Some(i);+ }+ if ui.add_enabled(can_remove, egui::Button::new("Remove").small()).clicked() {+ remove = Some(i);+ }+ ui.end_row();+ }+ if let Some(i) = remove {+ rows.remove(i);+ }+ if let Some(i) = edit {+ d.values.insert("series".into(), json!(rows.iter().map(|(name, x, y)| json!({"name": name, "x": x, "y": y})).collect::<Vec<_>>()));+ let (name, x, y) = rows.get(i).cloned().unwrap_or_default();+ app.open_dialog("chartSeries", json!({"index": i, "name": name, "x": x, "y": y})); } });- d.values.insert("remove".into(), json!(remove));- ui.add_space(6.0);- edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");+ if ui.button("Add Series").clicked() {+ let i = rows.len();+ let name = format!("Series{}", i + 1);+ rows.push((name.clone(), String::new(), String::new()));+ app.open_dialog("chartSeries", json!({"index": i, "name": name, "x": "", "y": ""}));+ }+ let series: Vec<Json> = rows.iter().map(|(name, x, y)| json!({"name": name, "x": x, "y": y})).collect();+ d.values.insert("series".into(), json!(series));+ let uses_independent_xy = rows.len() > 1 && rows.iter().all(|(_, x, y)| !x.trim().is_empty() && !y.trim().is_empty());+ if !uses_independent_xy {+ ui.add_space(6.0);+ edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");+ } ok_cancel(ui, confirm, open); if *confirm { *confirm = false; let mut params = serde_json::Map::new(); params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null));+ let series_changed = d.values.get("series") != d.values.get("series0"); let range_changed = text(d, "range") != text(d, "range0");- if range_changed {+ if series_changed {+ params.insert("series".into(), d.values.get("series").cloned().unwrap_or_else(|| json!([])));+ } else if range_changed { params.insert("range".into(), json!(text(d, "range")));- } else if !remove.is_empty() {- // A new range rebuilds the series; removals only apply to the current ones.+ }+ // Keep the pre-existing command path usable for automation and older callers that still+ // set the legacy `remove` field directly.+ if let Some(remove) = d.values.get("remove").and_then(Json::as_array).filter(|items| !items.is_empty()) { params.insert("removeSeries".into(), json!(remove)); }- if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) {+ if !series_changed && (text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty())) { params.insert("categories".into(), json!(text(d, "categories"))); } if params.len() == 1 {@@ -185,3 +228,87 @@ pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, } } }++fn short_ref(value: &str) -> String {+ const MAX: usize = 25;+ let value = value.strip_prefix('=').unwrap_or(value);+ if value.chars().count() <= MAX {+ return value.to_string();+ }+ let end: String = value.chars().skip(value.chars().count().saturating_sub(MAX - 1)).collect();+ format!("…{end}")+}++/// Edits one series at a time. While X or Y picking is active, clicks and drags on the sheet are+/// routed through Dialog::pick_reference, exactly like Excel's range selector.+pub(super) fn chart_series(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {+ ui.label("Legend title");+ let mut name = series_text(d, "name");+ if ui.add(egui::TextEdit::singleline(&mut name).desired_width(360.0)).changed() {+ d.values.insert("name".into(), json!(name));+ }+ ui.add_space(6.0);+ chart_series_field(ui, d, "x", "X values");+ chart_series_field(ui, d, "y", "Y values");+ ui.add_space(8.0);+ ok_cancel(ui, confirm, open);+ if *confirm {+ *confirm = false;+ if series_text(d, "y").trim().is_empty() {+ app.message = Some((d.title.clone(), "Y values cannot be empty.".into()));+ return;+ }+ let name = series_text(d, "name");+ let x = series_text(d, "x").trim_start_matches('=').to_string();+ let y = series_text(d, "y").trim_start_matches('=').to_string();+ if let Some(parent) = d.back.as_mut()+ && let Some(index) = d.values.get("index").and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok())+ && let Some(series) = parent.values.get_mut("series").and_then(Json::as_array_mut)+ && let Some(row) = series.get_mut(index).and_then(Json::as_object_mut)+ {+ row.insert("name".into(), json!(name));+ row.insert("x".into(), json!(x));+ row.insert("y".into(), json!(y));+ }+ *open = false;+ }+}++fn series_text(d: &Dialog, key: &str) -> String {+ d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string()+}++fn chart_series_field(ui: &mut egui::Ui, d: &mut Dialog, key: &'static str, label: &str) {+ ui.label(label);+ ui.horizontal(|ui| {+ let mut value = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();+ let r = ui.add(egui::TextEdit::singleline(&mut value).desired_width(360.0));+ if r.changed() {+ d.values.insert(key.into(), json!(value));+ }+ if ui.button("Select…").clicked() {+ // Point mode is formula-based. Existing chart references are stored without the+ // leading `=`, so add it before handing the field to the shared range picker.+ if !value.trim().is_empty() && !value.trim_start().starts_with('=') {+ value = format!("={value}");+ d.values.insert(key.into(), json!(value.clone()));+ }+ let pointed = crate::editor::formula_refs(&value).last().map(|(start, end, ..)| (*end, Some((*start, *end))));+ let (caret, point) = pointed.unwrap_or((value.chars().count(), None));+ d.pick = Some(super::RefPick {+ key,+ // Chart series keep an explicit sheet qualifier, including for the active sheet.+ sheet: String::new(),+ caret,+ point,+ anchor: gridcraft_engine::core::CellRef::new(0, 0),+ refocus: false,+ applies_to: String::new(),+ });+ }+ let typed = r.changed();+ if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) {+ super::show::track_pick(pick, &r, typed);+ }+ });+}@@ -122,12 +122,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "solver" => 460.0, "zoom" => 220.0, "pasteSpecial" => 480.0,+ "selectData" => 760.0,+ "chartSeries" => 520.0, _ => 380.0, }; let window = egui::Window::new(app.ui.language.tr(&d.title)) .id(egui::Id::unique("dialog").with(&d.name)) .collapsible(false)- .resizable(false)+ .resizable(d.name == "selectData" || d.name == "chartSeries") .default_width(width) // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. .pivot(egui::Align2::CENTER_CENTER)@@ -154,6 +156,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "manageRules" => manage_rules(app, ui), "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),+ "chartSeries" => charts::chart_series(app, ui, &mut d, &mut confirm, &mut open), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),@@ -459,7 +462,7 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) { /// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a /// reference in it (the click took the focus), its caret after the reference.-fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {+pub(super) fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { if typed { pick.point = None; }@@ -131,6 +131,12 @@ pub enum Drag { orig: (CellRef, f32, f32, f32, f32), resize: bool, },+ /// Dragging a chart legend inside its chart.+ ChartLegend {+ id: u32,+ start: Pos2,+ orig: [f32; 2],+ }, } /// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo@@ -49,7 +49,7 @@ fn drag_cursor(drag: &Drag) -> Option<CursorIcon> { Drag::Select => Some(CursorIcon::Cell), Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), Drag::Fill { .. } => Some(CursorIcon::Crosshair),- Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),+ Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } | Drag::ChartLegend { .. } => Some(CursorIcon::Move), Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),@@ -122,6 +122,13 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo app.grid.pane = Some("formatChart".into()); } if primary_drag {+ if kind == "chart"+ && let Some((legend_id, start, orig)) = crate::chartview::legend_drag_info(geo, sh, p)+ && legend_id == id+ {+ app.grid.drag = Drag::ChartLegend { id, start, orig };+ return;+ } let a = object_anchor(sh, kind, id); let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; if let Some(a) = a {@@ -516,6 +523,11 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo let _ = app.session.run("object.move", params); coalesce_last_undo(app); }+ Drag::ChartLegend { id, start, orig } => {+ let d = (p - start) / geo.z.max(0.01);+ let _ = app.session.run("chart.set", json!({"chart": id, "legendPosition": [orig[0] + d.x, orig[1] + d.y]}));+ coalesce_last_undo(app);+ } Drag::None => {} } }@@ -200,12 +200,20 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } }); });+ ui.collapsing("Fonts & Text", |ui| {+ ui.label("Enter a font family name, or leave it empty to use the automatic font.");+ chart_text_style_row(app, ui, id, "General", "general", &chart.text.general, 10.0);+ chart_text_style_row(app, ui, id, "Chart title", "title", &chart.text.title, 14.0);+ chart_text_style_row(app, ui, id, "Axis numbers", "axisLabels", &chart.text.axis_labels, 9.0);+ chart_text_style_row(app, ui, id, "Axis titles", "axisTitles", &chart.text.axis_titles, 10.0);+ chart_text_style_row(app, ui, id, "Legend", "legend", &chart.text.legend, 9.0);+ }); ui.collapsing("Legend", |ui| { ui.horizontal(|ui| { for (l, v) in [("None", "none"), ("Bottom", "bottom"), ("Top", "top"), ("Left", "left"), ("Right", "right")] { let cur = format!("{:?}", chart.legend).eq_ignore_ascii_case(v); if ui.selectable_label(cur, l).clicked() {- app.run_or_alert("chart.set", json!({"chart": id, "legend": v}));+ app.run_or_alert("chart.set", json!({"chart": id, "legend": v, "legendPosition": Json::Null})); } } });@@ -291,3 +299,33 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } }); }++fn chart_text_style_row(+ app: &mut SheetApp,+ ui: &mut Ui,+ chart: u32,+ label: &str,+ key: &str,+ style: &gridcraft_engine::model::ChartTextStyle,+ fallback_size: f32,+) {+ ui.horizontal(|ui| {+ ui.label(label);+ let font_id = egui::Id::unique(("gridcraft.chart_font", chart, key));+ if let Some(font) = crate::widgets::committed_text(ui, font_id, style.font.as_deref().unwrap_or(""), None) {+ let mut role = serde_json::Map::new();+ role.insert("font".into(), if font.trim().is_empty() { Json::Null } else { json!(font.trim()) });+ let mut text = serde_json::Map::new();+ text.insert(key.into(), Json::Object(role));+ app.run_or_alert("chart.set", json!({"chart": chart, "text": text}));+ }+ let mut size = style.size.unwrap_or(fallback_size);+ if ui.add(egui::DragValue::new(&mut size).prefix("Size ").range(4.0..=72.0)).changed() {+ let mut role = serde_json::Map::new();+ role.insert("size".into(), json!(size));+ let mut text = serde_json::Map::new();+ text.insert(key.into(), Json::Object(role));+ app.run_or_alert("chart.set", json!({"chart": chart, "text": text}));+ }+ });+}@@ -274,6 +274,8 @@ pub fn read_chart(root: &El) -> Option<Chart> { y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title), source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: root.path(&["pivotSource", "name"]).map(|n| strip_book(n.text.trim()).to_string()).filter(|n| !n.is_empty()), }) }@@ -136,6 +136,8 @@ pub fn read_chartex(root: &El) -> Option<Chart> { y_title: axis_title("1"), source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }) }@@ -81,6 +81,8 @@ fn chart_names_are_written_qualified() { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new("My Data"))];@@ -542,6 +542,8 @@ fn chartex_parts_as_excel_expects_them() { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); wb.sheets = vec![std::sync::Arc::new(sheet)];@@ -47,6 +47,8 @@ fn literal_category_with_separators_survives_save() { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); wb.sheets = vec![Arc::new(s)];@@ -47,6 +47,8 @@ fn workbook(categories: &str, values: &str) -> Workbook { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() }@@ -413,6 +413,8 @@ fn sample() -> Workbook { y_title: Some("Amount".into()), source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }; for kind in [@@ -35,6 +35,8 @@ fn smooth_line_setting_round_trips() { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); let mut smooth_series = base_series.clone();@@ -53,6 +55,8 @@ fn smooth_line_setting_round_trips() { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, }); wb.sheets = vec![Arc::new(s)];@@ -59,6 +59,8 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart { y_title: None, source: None, by_rows: false,+ text: Default::default(),+ legend_position: None, pivot: None, } }