Skip to main content

model/
legend.rs

1//! Legend model — the whole legend is **one rich inline document**
2//! ([`Legend::content`]).
3//!
4//! Design points:
5//! - **Symbols are fixed-width rich segments.** A line sample is a drawn
6//!   horizontal rule whose em dash text is only a fallback, while a scatter
7//!   sample is a geometric-shape glyph (`●`, `■`, …) centered in its field.
8//!   Each symbol form occupies the same 2 em total advance and carries a
9//!   per-segment `RichSegment.color` override, so marks line up and use the
10//!   same layout path on screen and in PNG export.
11//! - **Everything is explicit in the SSoT.** Line breaks are `'\n'` segments,
12//!   and symbols remain inline segment sequences, so a one-line legend,
13//!   mid-text symbols, and any custom arrangement are all explicit.
14//! - **Font and font size are live.** `content.font` / `content.font_size`
15//!   (and per-segment overrides) are consumed at draw time, so SSoT edits
16//!   after composition still apply.
17//!
18//! Composition helpers cover the common cases: [`symbol_segments`] /
19//! [`series_symbol_segments`] build a colored symbol, [`append_legend_entry`]
20//! appends "fixed-width symbol + space + tab + label" as a new line. Fully
21//! custom layouts edit `content.segments` directly.
22
23use crate::color::Color;
24use crate::data_config::{DataRenderType, ScatterShape, SeriesConfig};
25use crate::line::LineStylePreset;
26use crate::text::{RichSegment, RichText, rich_segments_from_text};
27use std::ops::Range;
28
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum LegendCorner {
32    TopLeft,
33    TopRight,
34    BottomLeft,
35    BottomRight,
36}
37
38/// Sample kinds for the convenience builders (mirrors what a series draws).
39#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub enum LegendEntryKind {
42    /// Line sample only.
43    Line,
44    /// Point sample only.
45    Scatter,
46    /// Line + point sample.
47    LineScatter,
48}
49
50/// The compact legend character for a [`ScatterShape`]. Exact geometric
51/// glyphs are used only when the bundled Liberation Sans face covers them;
52/// pentagon/hexagon/octagon fall back to familiar marker-code letters.
53pub fn scatter_shape_char(shape: &ScatterShape) -> char {
54    match shape {
55        ScatterShape::Circle => '○',
56        ScatterShape::CircleFilled => '●',
57        ScatterShape::Square => '□',
58        ScatterShape::SquareFilled => '■',
59        ScatterShape::Triangle => '▲',
60        ScatterShape::TriangleFilled => '▲',
61        ScatterShape::TriangleDown => '▼',
62        ScatterShape::TriangleDownFilled => '▼',
63        ScatterShape::TriangleLeft => '◄',
64        ScatterShape::TriangleLeftFilled => '◄',
65        ScatterShape::TriangleRight => '►',
66        ScatterShape::TriangleRightFilled => '►',
67        ScatterShape::Diamond => '◊',
68        ScatterShape::DiamondFilled => '♦',
69        ScatterShape::Cross => '×',
70        ScatterShape::CrossFilled => '×',
71        ScatterShape::Plus => '+',
72        ScatterShape::PlusFilled => '+',
73        ScatterShape::Pentagon => 'p',
74        ScatterShape::PentagonFilled => 'p',
75        ScatterShape::Hexagon => 'h',
76        ScatterShape::HexagonFilled => 'h',
77        ScatterShape::Octagon => '8',
78        ScatterShape::OctagonFilled => '8',
79        ScatterShape::Star => '*',
80        ScatterShape::StarFilled => '*',
81    }
82}
83
84/// Every symbol form spans exactly this many em (× legend font size), so
85/// marks line up regardless of shape: a lone line, a lone glyph, and a
86/// line-through-glyph all advance the pen by the same fixed field.
87pub const SYMBOL_FIELD_EM: f32 = 2.0;
88const LEGEND_DASH_NOMINAL_FONT_PX: f32 = 14.0;
89
90fn rule_dash_em(style: LineStylePreset) -> Option<Vec<f32>> {
91    let pattern = style.pattern();
92    if pattern.is_empty() {
93        None
94    } else {
95        Some(
96            pattern
97                .iter()
98                .map(|v| v / LEGEND_DASH_NOMINAL_FONT_PX)
99                .collect(),
100        )
101    }
102}
103
104#[cfg(test)]
105mod legend_update_tests {
106    use super::*;
107    use crate::data_config::DataLineStyleConfig;
108    use crate::line::LineStylePreset;
109    use crate::text::TextAlign;
110
111    fn line_cfg(color: Color, line_style: LineStylePreset) -> SeriesConfig {
112        SeriesConfig {
113            series_id: "s".into(),
114            source_id: None,
115            label: None,
116            x_column: "x".into(),
117            y_column: "y".into(),
118            render_type: DataRenderType::Line {
119                line: DataLineStyleConfig {
120                    line_style,
121                    line_color: color,
122                    line_width: 1.0,
123                },
124            },
125        }
126    }
127
128    fn empty_content() -> RichText {
129        RichText {
130            segments: Vec::new(),
131            color: Color::BLACK,
132            font_size: 14.0,
133            font: String::new(),
134            align: TextAlign::Left,
135        }
136    }
137
138    #[test]
139    fn dashed_series_symbol_carries_rule_dash() {
140        let red = Color::new(1.0, 0.0, 0.0, 1.0);
141        let segs = series_symbol_segments(&line_cfg(red, LineStylePreset::Dash));
142        assert_eq!(segs.len(), 1);
143        assert!(segs[0].rule);
144        assert_eq!(segs[0].color, Some(red));
145        assert_eq!(
146            segs[0].rule_dash.as_deref(),
147            Some(&[8.0 / 14.0, 4.0 / 14.0][..])
148        );
149    }
150
151    #[test]
152    fn symbol_update_preserves_user_text_and_label_newlines() {
153        let red = Color::new(1.0, 0.0, 0.0, 1.0);
154        let blue = Color::new(0.0, 0.0, 1.0, 1.0);
155        let green = Color::new(0.0, 0.8, 0.0, 1.0);
156        let mut content = empty_content();
157        append_legend_entry(
158            &mut content,
159            series_symbol_segments(&line_cfg(red, LineStylePreset::Solid)),
160            "line 1\nline 2",
161        );
162        append_legend_entry(
163            &mut content,
164            series_symbol_segments(&line_cfg(blue, LineStylePreset::Solid)),
165            "beta",
166        );
167
168        let series = [
169            line_cfg(green, LineStylePreset::Dash),
170            line_cfg(red, LineStylePreset::Dot),
171        ];
172        assert_eq!(
173            update_legend_symbols_preserving_text(&mut content, &series),
174            2
175        );
176        let text: String = content.segments.iter().map(|s| s.text).collect();
177
178        assert_eq!(legend_entry_count(&content), 2);
179        assert!(text.contains("line 1\nline 2"));
180        assert!(text.contains("beta"));
181        assert_eq!(content.segments[0].color, Some(green));
182        assert!(content.segments[0].rule_dash.is_some());
183    }
184
185    #[test]
186    fn label_update_and_remove_touch_only_target_entry() {
187        let mut content = empty_content();
188        append_legend_entry(
189            &mut content,
190            series_symbol_segments(&line_cfg(Color::BLACK, LineStylePreset::Solid)),
191            "alpha",
192        );
193        append_legend_entry(
194            &mut content,
195            series_symbol_segments(&line_cfg(Color::BLACK, LineStylePreset::Solid)),
196            "beta",
197        );
198
199        set_legend_entry_label(
200            &mut content,
201            1,
202            series_symbol_segments(&line_cfg(Color::BLACK, LineStylePreset::Solid)),
203            rich_segments_from_text("gamma"),
204        );
205        let text: String = content.segments.iter().map(|s| s.text).collect();
206        assert!(text.contains("alpha"));
207        assert!(text.contains("gamma"));
208        assert!(!text.contains("beta"));
209
210        assert!(remove_legend_entry(&mut content, 0));
211        let text: String = content.segments.iter().map(|s| s.text).collect();
212        assert!(!text.contains("alpha"));
213        assert!(text.contains("gamma"));
214        assert_eq!(legend_entry_count(&content), 1);
215    }
216
217    #[test]
218    fn custom_legend_without_auto_entry_shape_is_left_untouched() {
219        let mut content = RichText::plain("custom legend text", Color::BLACK, 14.0, "");
220        let before = content.clone();
221        assert_eq!(
222            update_legend_symbols_preserving_text(
223                &mut content,
224                &[line_cfg(Color::BLACK, LineStylePreset::Solid)]
225            ),
226            0
227        );
228        assert_eq!(content, before);
229        assert!(!remove_legend_entry(&mut content, 0));
230    }
231}
232
233/// Line sample: a drawn rule filling the whole symbol field — exact length,
234/// unlike any dash glyph.
235fn line_symbol(color: Color, style: LineStylePreset) -> Vec<RichSegment> {
236    let seg = match rule_dash_em(style) {
237        Some(dash) => RichSegment::dashed_rule(SYMBOL_FIELD_EM, Some(color), dash),
238        None => RichSegment::rule(SYMBOL_FIELD_EM, Some(color)),
239    };
240    vec![seg]
241}
242
243/// Point sample: the shape's glyph centered in the symbol field.
244fn scatter_symbol(shape: &ScatterShape, color: Color) -> Vec<RichSegment> {
245    vec![RichSegment::fielded(
246        scatter_shape_char(shape),
247        SYMBOL_FIELD_EM,
248        Some(color),
249    )]
250}
251
252/// Line + point sample: rule — glyph — rule, the three fields summing to
253/// exactly [`SYMBOL_FIELD_EM`] so the combined mark is as long as a lone
254/// line mark.
255fn line_scatter_symbol(shape: &ScatterShape, color: Color) -> Vec<RichSegment> {
256    line_scatter_symbol_with_style(shape, color, LineStylePreset::Solid)
257}
258
259fn line_scatter_symbol_with_style(
260    shape: &ScatterShape,
261    color: Color,
262    style: LineStylePreset,
263) -> Vec<RichSegment> {
264    let glyph_em = 0.7;
265    let rule_em = (SYMBOL_FIELD_EM - glyph_em) * 0.5;
266    let rule = |width_em| match rule_dash_em(style) {
267        Some(dash) => RichSegment::dashed_rule(width_em, Some(color), dash),
268        None => RichSegment::rule(width_em, Some(color)),
269    };
270    vec![
271        rule(rule_em),
272        RichSegment::fielded(scatter_shape_char(shape), glyph_em, Some(color)),
273        rule(rule_em),
274    ]
275}
276
277/// Symbol segments for the convenience-builder [`LegendEntryKind`]: each
278/// segment carries `color: Some(color)` so the mark keeps the series color
279/// independent of the document-level text color.
280pub fn symbol_segments(kind: &LegendEntryKind, color: Color) -> Vec<RichSegment> {
281    match kind {
282        LegendEntryKind::Line => line_symbol(color, LineStylePreset::Solid),
283        LegendEntryKind::Scatter => scatter_symbol(&ScatterShape::CircleFilled, color),
284        LegendEntryKind::LineScatter => line_scatter_symbol(&ScatterShape::CircleFilled, color),
285    }
286}
287
288/// Blank mark of exactly [`SYMBOL_FIELD_EM`] — a label-only legend row.
289///
290/// Used for a heatmap, whose colour is the colourbar's to report and not a
291/// single swatch's. It is a spacer rather than an empty vec because every series
292/// must still produce exactly one entry: legend entry `i` **is** series `i`
293/// (`update_legend_symbols_preserving_text` zips the two positionally, and the
294/// web layer passes series indices to `set_legend_entry_label`), so a skipped
295/// entry would re-point every later row's symbol at the wrong series. Full field
296/// width also keeps the tab-aligned symbol column the same width on every row.
297fn blank_symbol() -> Vec<RichSegment> {
298    vec![RichSegment::fielded(' ', SYMBOL_FIELD_EM, None)]
299}
300
301/// Symbol segments matching a series declaration: line / scatter / combined,
302/// shape and color taken from the render type's sub-styles.
303pub fn series_symbol_segments(cfg: &SeriesConfig) -> Vec<RichSegment> {
304    match &cfg.render_type {
305        DataRenderType::Line { line } => line_symbol(line.line_color, line.line_style),
306        // A bar reads as a filled swatch; the glyph is already in the shape
307        // table, so no new symbol machinery.
308        DataRenderType::Histogram { bar } => {
309            scatter_symbol(&ScatterShape::SquareFilled, bar.fill_color)
310        }
311        // Contour lines are lines. When a fill is under them the line is still
312        // the mark — the fill's colours belong to the colourbar.
313        DataRenderType::Contour { contour, .. }
314        | DataRenderType::HeatmapContour { contour, .. } => {
315            line_symbol(contour.line.line_color, contour.line.line_style)
316        }
317        DataRenderType::Heatmap { .. } => blank_symbol(),
318        DataRenderType::Scatter { scatter }
319        | DataRenderType::ScatterErrorbarX { scatter, .. }
320        | DataRenderType::ScatterErrorbarY { scatter, .. }
321        | DataRenderType::ScatterErrorbarXY { scatter, .. } => {
322            scatter_symbol(&scatter.point_shape, scatter.point_color)
323        }
324        DataRenderType::ScatterLine { scatter, line }
325        | DataRenderType::LineScatterErrorbarX { scatter, line, .. }
326        | DataRenderType::LineScatterErrorbarY { scatter, line, .. }
327        | DataRenderType::LineScatterErrorbarXY { scatter, line, .. } => {
328            line_scatter_symbol_with_style(&scatter.point_shape, line.line_color, line.line_style)
329        }
330    }
331}
332
333/// Append one "entry" to the legend document: a `'\n'` segment when content
334/// already exists, then the symbol segments, one space, a `'\t'` column
335/// separator, then the label (sub/superscript unicode and `'\n'` are
336/// interpreted by the segment mapper).
337///
338/// The `'\t'` makes the symbol field a table column: the text engine sizes
339/// each label column consistently across rows. The symbol sequence itself
340/// always totals [`SYMBOL_FIELD_EM`], whether it is a rule, a centered glyph,
341/// or rule + glyph + rule. Callers wanting a one-line legend can extend
342/// `content.segments` themselves without the `'\n'`.
343pub fn append_legend_entry(content: &mut RichText, symbol: Vec<RichSegment>, label: &str) {
344    append_legend_entry_rich(content, symbol, rich_segments_from_text(label));
345}
346
347/// Append one legend entry using already-styled label segments.
348pub fn append_legend_entry_rich(
349    content: &mut RichText,
350    symbol: Vec<RichSegment>,
351    label: Vec<RichSegment>,
352) {
353    if !content.segments.is_empty() {
354        content.segments.push(RichSegment::plain('\n'));
355    }
356    content.segments.extend(symbol);
357    content.segments.push(RichSegment::plain(' '));
358    content.segments.push(RichSegment::plain('\t'));
359    content.segments.extend(label);
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
363struct LegendEntrySpan {
364    symbol: Range<usize>,
365    text: Range<usize>,
366    remove: Range<usize>,
367}
368
369fn is_plain_space(seg: &RichSegment) -> bool {
370    seg.text == ' '
371        && !seg.bold
372        && !seg.italic
373        && !seg.underline
374        && !seg.superscript
375        && !seg.subscript
376        && !seg.greek
377        && seg.color.is_none()
378        && seg.font_size.is_none()
379        && seg.field_em.is_none()
380        && !seg.rule
381        && seg.rule_dash.is_none()
382}
383
384fn symbol_anchor(segments: &[RichSegment], start: usize) -> Option<(usize, usize)> {
385    if start >= segments.len() || segments[start].field_em.is_none() {
386        return None;
387    }
388
389    let mut i = start;
390    while i < segments.len() && segments[i].field_em.is_some() {
391        i += 1;
392    }
393    let symbol_end = i;
394    while i < segments.len() && is_plain_space(&segments[i]) {
395        i += 1;
396    }
397    (i < segments.len() && segments[i].text == '\t').then_some((symbol_end, i))
398}
399
400fn legend_entry_spans(content: &RichText) -> Vec<LegendEntrySpan> {
401    let segments = &content.segments;
402    let mut anchors = Vec::new();
403    let mut i = 0;
404    while i < segments.len() {
405        if let Some((symbol_end, tab)) = symbol_anchor(segments, i) {
406            anchors.push((i, symbol_end, tab));
407            i = tab + 1;
408        } else {
409            i += 1;
410        }
411    }
412
413    let mut spans = Vec::with_capacity(anchors.len());
414    for (idx, &(symbol_start, symbol_end, tab)) in anchors.iter().enumerate() {
415        let next_start = anchors.get(idx + 1).map(|(start, _, _)| *start);
416        let mut text_end = next_start.unwrap_or(segments.len());
417        if next_start.is_some() && text_end > tab + 1 && segments[text_end - 1].text == '\n' {
418            text_end -= 1;
419        }
420
421        let remove_start = if symbol_start > 0 && segments[symbol_start - 1].text == '\n' {
422            symbol_start - 1
423        } else {
424            symbol_start
425        };
426        let remove_end = if idx == 0 {
427            next_start
428                .filter(|&start| start > 0 && segments[start - 1].text == '\n')
429                .unwrap_or(text_end)
430        } else {
431            text_end
432        };
433
434        spans.push(LegendEntrySpan {
435            symbol: symbol_start..symbol_end,
436            text: tab + 1..text_end,
437            remove: remove_start..remove_end,
438        });
439    }
440    spans
441}
442
443/// Number of auto-format legend entries recognized in a rich legend document.
444pub fn legend_entry_count(content: &RichText) -> usize {
445    legend_entry_spans(content).len()
446}
447
448/// Replace recognized entry symbols in order and keep every user text segment
449/// untouched. Custom layouts that do not expose the auto symbol+tab shape are
450/// left unchanged.
451pub fn update_legend_symbols_preserving_text(
452    content: &mut RichText,
453    series: &[SeriesConfig],
454) -> usize {
455    let spans = legend_entry_spans(content);
456    let mut replaced = 0;
457    for (span, cfg) in spans.into_iter().zip(series.iter()).rev() {
458        content
459            .segments
460            .splice(span.symbol, series_symbol_segments(cfg));
461        replaced += 1;
462    }
463    replaced
464}
465
466/// Set one recognized legend entry label while preserving the surrounding
467/// document. If the entry does not exist, append a new auto-format entry.
468pub fn set_legend_entry_label(
469    content: &mut RichText,
470    entry_index: usize,
471    symbol: Vec<RichSegment>,
472    label: Vec<RichSegment>,
473) {
474    let spans = legend_entry_spans(content);
475    if let Some(span) = spans.get(entry_index) {
476        content.segments.splice(span.text.clone(), label);
477    } else {
478        append_legend_entry_rich(content, symbol, label);
479    }
480}
481
482/// Remove one recognized auto-format legend entry. Returns false when the
483/// document does not expose that entry shape.
484pub fn remove_legend_entry(content: &mut RichText, entry_index: usize) -> bool {
485    let spans = legend_entry_spans(content);
486    let Some(span) = spans.get(entry_index) else {
487        return false;
488    };
489    content.segments.drain(span.remove.clone());
490    true
491}
492
493/// Legend box rendered in one corner of the chart's data area.
494#[derive(Debug, Clone, PartialEq)]
495#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
496pub struct Legend {
497    pub visible: bool,
498    /// The whole legend as one rich document. `'\n'` segments break lines;
499    /// symbol fields are fixed-width inline segments with color overrides.
500    /// Line symbols draw rules across their fields and retain an em dash only
501    /// as fallback text; scatter glyphs are centered in their fields. Thus
502    /// line breaks, symbol positions, and mid-text symbols remain explicit in
503    /// the SSoT. Font + font_size of this RichText are consumed at draw time.
504    pub content: RichText,
505    pub corner: LegendCorner,
506    /// Visual offset from the corner anchor (drag-to-move lands here).
507    pub offset_x: f32,
508    pub offset_y: f32,
509    /// Inset from the data_area inner corner + padding inside the box.
510    pub padding: f32,
511    /// Box background and border.
512    pub bg_color: Color,
513    pub border_color: Color,
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::data_config::DataLineStyleConfig;
520    use crate::line::LineStylePreset;
521    use crate::text::TextAlign;
522
523    fn line_cfg(color: Color) -> SeriesConfig {
524        line_cfg_style(color, LineStylePreset::Solid)
525    }
526
527    fn line_cfg_style(color: Color, line_style: LineStylePreset) -> SeriesConfig {
528        SeriesConfig {
529            series_id: "s".into(),
530            source_id: None,
531            label: None,
532            x_column: "x".into(),
533            y_column: "y".into(),
534            render_type: DataRenderType::Line {
535                line: DataLineStyleConfig {
536                    line_style,
537                    line_color: color,
538                    line_width: 1.0,
539                },
540            },
541        }
542    }
543
544    fn empty_content() -> RichText {
545        RichText {
546            segments: Vec::new(),
547            color: Color::BLACK,
548            font_size: 14.0,
549            font: String::new(),
550            align: TextAlign::Left,
551        }
552    }
553
554    #[test]
555    fn series_symbol_takes_series_color_as_override() {
556        let red = Color::new(1.0, 0.0, 0.0, 1.0);
557        let segs = series_symbol_segments(&line_cfg(red));
558        assert_eq!(segs.len(), 1); // "—"
559        assert_eq!(segs[0].text, '—');
560        assert_eq!(segs[0].color, Some(red));
561        assert_eq!(segs[0].font_size, None); // size stays live via the document
562    }
563
564    #[test]
565    fn scatter_symbols_map_every_shape_to_a_glyph() {
566        for shape in [
567            ScatterShape::Circle,
568            ScatterShape::Square,
569            ScatterShape::Triangle,
570            ScatterShape::TriangleDown,
571            ScatterShape::TriangleLeft,
572            ScatterShape::TriangleRight,
573            ScatterShape::Diamond,
574            ScatterShape::Cross,
575            ScatterShape::Plus,
576            ScatterShape::Pentagon,
577            ScatterShape::Hexagon,
578            ScatterShape::Octagon,
579            ScatterShape::Star,
580            ScatterShape::CircleFilled,
581            ScatterShape::SquareFilled,
582            ScatterShape::TriangleFilled,
583            ScatterShape::TriangleDownFilled,
584            ScatterShape::TriangleLeftFilled,
585            ScatterShape::TriangleRightFilled,
586            ScatterShape::DiamondFilled,
587            ScatterShape::CrossFilled,
588            ScatterShape::PlusFilled,
589            ScatterShape::PentagonFilled,
590            ScatterShape::HexagonFilled,
591            ScatterShape::OctagonFilled,
592            ScatterShape::StarFilled,
593        ] {
594            let c = scatter_shape_char(&shape);
595            assert!(!c.is_control(), "{shape:?} → {c}");
596        }
597    }
598
599    #[test]
600    fn kind_symbols_carry_color_override_on_every_segment() {
601        let blue = Color::new(0.0, 0.0, 1.0, 1.0);
602        for kind in [
603            LegendEntryKind::Line,
604            LegendEntryKind::Scatter,
605            LegendEntryKind::LineScatter,
606        ] {
607            let segs = symbol_segments(&kind, blue);
608            assert!(!segs.is_empty());
609            assert!(segs.iter().all(|s| s.color == Some(blue)), "{kind:?}");
610        }
611    }
612
613    #[test]
614    fn two_appends_put_explicit_newline_between_entries() {
615        let mut content = empty_content();
616        append_legend_entry(
617            &mut content,
618            symbol_segments(&LegendEntryKind::Line, Color::BLACK),
619            "a",
620        );
621        append_legend_entry(
622            &mut content,
623            symbol_segments(&LegendEntryKind::Line, Color::BLACK),
624            "b",
625        );
626
627        let newlines = content.segments.iter().filter(|s| s.text == '\n').count();
628        assert_eq!(newlines, 1);
629        // Entry shape: symbol, space, '\t' column break, label — twice.
630        // The tab gives every entry the same symbol-field width at draw time.
631        let texts: String = content.segments.iter().map(|s| s.text).collect();
632        assert_eq!(texts, "— \ta\n— \tb");
633        // Labels inherit the document color (no override).
634        assert!(
635            content
636                .segments
637                .iter()
638                .filter(|s| s.text == 'a')
639                .all(|s| s.color.is_none())
640        );
641    }
642
643    #[test]
644    fn label_newlines_stay_explicit_in_content() {
645        let mut content = empty_content();
646        append_legend_entry(
647            &mut content,
648            symbol_segments(&LegendEntryKind::Line, Color::BLACK),
649            "line 1\nline 2",
650        );
651        let newlines = content.segments.iter().filter(|s| s.text == '\n').count();
652        assert_eq!(newlines, 1);
653    }
654
655    #[test]
656    fn one_line_composition_when_caller_joins_without_newline() {
657        // A fully one-line legend: the caller extends the document directly
658        // instead of letting `append_legend_entry` insert the '\n'.
659        let red = Color::new(1.0, 0.0, 0.0, 1.0);
660        let mut content = empty_content();
661        append_legend_entry(
662            &mut content,
663            symbol_segments(&LegendEntryKind::Line, red),
664            "a",
665        );
666        content.segments.push(RichSegment::plain(' '));
667        content
668            .segments
669            .extend(symbol_segments(&LegendEntryKind::Scatter, red));
670        content.segments.push(RichSegment::plain(' '));
671        content.segments.extend(rich_segments_from_text("b"));
672
673        assert!(content.segments.iter().all(|s| s.text != '\n'));
674        let texts: String = content.segments.iter().map(|s| s.text).collect();
675        assert_eq!(texts, "— \ta ● b");
676    }
677
678    fn field_series(render_type: DataRenderType) -> SeriesConfig {
679        SeriesConfig {
680            series_id: "f".into(),
681            source_id: None,
682            label: None,
683            x_column: "x".into(),
684            y_column: "y".into(),
685            render_type,
686        }
687    }
688
689    fn bar(color: Color) -> DataRenderType {
690        DataRenderType::Histogram {
691            bar: crate::data_config::DataBarStyleConfig {
692                fill_color: color,
693                border_color: Color::BLACK,
694                border_width: 1.0,
695                baseline: 0.0,
696                gap_px: 1.0,
697                width_ratio: 1.0,
698                orientation: crate::data_config::BarOrientation::Vertical,
699                bar_style_overrides: None,
700            },
701        }
702    }
703
704    fn contour(color: Color) -> crate::data_config::ContourConfig {
705        crate::data_config::ContourConfig {
706            levels: vec![1.0],
707            line: DataLineStyleConfig {
708                line_style: LineStylePreset::Dash,
709                line_color: color,
710                line_width: 1.0,
711            },
712            per_level_color: None,
713            labels: None,
714        }
715    }
716
717    fn matrix() -> crate::data_config::MatrixRef {
718        crate::data_config::MatrixRef {
719            columns: vec!["z0".into()],
720            orientation: crate::data_config::MatrixOrientation::ColumnsAreX,
721            grid_layout: crate::data_config::GridLayout::Centers,
722        }
723    }
724
725    fn fill() -> crate::data_config::FieldFillConfig {
726        crate::data_config::FieldFillConfig {
727            mode: crate::data_config::FillMode::Continuous,
728            shading: crate::data_config::Shading::Flat,
729            opacity: 1.0,
730        }
731    }
732
733    // A bar reads as a swatch, a contour as a line — in the colours those
734    // primitives are actually drawn with.
735    #[test]
736    fn field_series_symbols_come_from_the_primitive_that_is_drawn() {
737        let swatch = series_symbol_segments(&field_series(bar(Color::from_rgb8(1, 2, 3))));
738        assert_eq!(swatch.len(), 1);
739        assert_eq!(swatch[0].color, Some(Color::from_rgb8(1, 2, 3)));
740        assert_eq!(
741            swatch[0].text,
742            scatter_shape_char(&crate::data_config::ScatterShape::SquareFilled)
743        );
744
745        let level_color = Color::from_rgb8(4, 5, 6);
746        for render_type in [
747            DataRenderType::Contour {
748                matrix: matrix(),
749                contour: contour(level_color),
750            },
751            DataRenderType::HeatmapContour {
752                matrix: matrix(),
753                fill: fill(),
754                contour: contour(level_color),
755            },
756        ] {
757            let mark = series_symbol_segments(&field_series(render_type));
758            assert_eq!(mark.len(), 1, "a contour mark is one rule");
759            assert_eq!(mark[0].color, Some(level_color));
760            // Dashed, because the contour line is — the mark reports the style
761            // the line is drawn with, not a generic rule.
762            assert!(mark[0].rule, "a contour mark is a drawn rule");
763            assert!(mark[0].rule_dash.is_some(), "dashed contour, dashed mark");
764        }
765    }
766
767    // A heatmap has no swatch of its own: its colours are the colourbar's. The
768    // mark is blank — but it is still exactly one full-width segment, because
769    // legend entry `i` is series `i` and the symbol column is tab-aligned across
770    // rows. An empty vec would re-point every later row at the wrong series.
771    #[test]
772    fn a_heatmap_gets_a_blank_full_width_mark_not_no_entry() {
773        let heatmap = field_series(DataRenderType::Heatmap {
774            matrix: matrix(),
775            fill: fill(),
776        });
777        let mark = series_symbol_segments(&heatmap);
778        assert_eq!(mark.len(), 1);
779        assert_eq!(mark[0].field_em, Some(SYMBOL_FIELD_EM));
780        assert_eq!(mark[0].text, ' ');
781
782        // One entry per series, in series order, with the heatmap in the middle.
783        let series = [
784            line_cfg(Color::from_rgb8(10, 0, 0)),
785            heatmap,
786            field_series(bar(Color::from_rgb8(0, 10, 0))),
787        ];
788        let mut content = RichText {
789            segments: Vec::new(),
790            color: Color::BLACK,
791            font_size: 12.0,
792            font: String::new(),
793            align: TextAlign::Left,
794        };
795        for cfg in &series {
796            append_legend_entry(&mut content, series_symbol_segments(cfg), "label");
797        }
798        assert_eq!(legend_entry_count(&content), series.len());
799        assert_eq!(
800            update_legend_symbols_preserving_text(&mut content, &series),
801            series.len(),
802            "every series must own exactly one entry, or later symbols shift"
803        );
804    }
805}