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