Skip to main content

ggplot_rs/guide/
legend.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3use crate::guide::config::GuideLegend;
4use crate::render::backend::{
5    DrawBackend, LineStyle, Linetype, PointStyle, RectStyle, TextAnchor, TextStyle,
6};
7use crate::render::{Rect, RenderError};
8use crate::scale::ScaleSet;
9use crate::theme::{LegendPosition, Theme};
10
11/// Which aesthetics should generate legends.
12pub(crate) const LEGEND_AESTHETICS: &[Aesthetic] = &[
13    Aesthetic::Color,
14    Aesthetic::Fill,
15    Aesthetic::Shape,
16    Aesthetic::Linetype,
17    Aesthetic::Size,
18    Aesthetic::Alpha,
19    Aesthetic::Linewidth,
20];
21
22/// Whether `a` is one of [`LEGEND_AESTHETICS`] (#241: `has_legend_mapping`'s
23/// own `is_legend_aes` check, in `plot.rs`'s layout-building pass, had
24/// drifted into a second, incomplete copy of this list — missing
25/// `Linewidth`, so a plot mapping only `linewidth` reserved *no* legend
26/// space at all, and its legend then drew with nothing held back for it,
27/// running off the canvas). Delegating to this one list means the two
28/// checks — "should this aesthetic reserve layout space" and "should this
29/// aesthetic actually draw a legend" — can't drift apart again.
30pub(crate) fn is_legend_aesthetic(a: &Aesthetic) -> bool {
31    LEGEND_AESTHETICS.contains(a)
32}
33
34/// The aesthetics that would get a legend, in the same order and with the
35/// same color/fill de-duplication `draw_legend` and `draw_legend_planned`
36/// use. Shared with `layout_built` (B3-2), so the "is this the single
37/// discrete guide D7 requires" check is the same one the drawing code
38/// uses.
39pub(crate) fn legend_scales_for(
40    scales: &ScaleSet,
41    suppressed: &std::collections::HashSet<Aesthetic>,
42) -> Vec<Aesthetic> {
43    let mut legend_scales: Vec<Aesthetic> = Vec::new();
44    for aes in LEGEND_AESTHETICS {
45        if suppressed.contains(aes) {
46            continue;
47        }
48        if let Some(scale) = scales.get(aes) {
49            if !scale.breaks().is_empty() {
50                legend_scales.push(aes.clone());
51            }
52        }
53    }
54    legend_scales
55}
56
57/// Group `legend_scales` into mergeable sets (#239: ggplot2 merges `fill`
58/// and `colour` guides mapped to the same measure through the same scale
59/// spec into one legend with a combined key, rather than drawing one per
60/// aesthetic — and, conversely, draws *two* legends when they don't
61/// actually match, which an earlier, cruder rule here got wrong: it
62/// unconditionally dropped `Fill`'s own legend whenever `Color` was also
63/// mapped, even to an unrelated variable with a different title and
64/// breaks). Two aesthetics merge when their scales share the same
65/// effective title (`guide.title`, else `scale.name()`), the same
66/// discrete/continuous-ness, and *identical* `breaks()` (same positions
67/// and labels, in the same order) — the same practical "do these two
68/// guides look identical" test ggplot2 itself applies; a binned scale's
69/// breaks already carry its counts in their labels, so two binned scales
70/// with the same counts per bin merge too. Order preserved: each group
71/// keeps the position its first member had in `legend_scales`.
72pub(crate) fn group_mergeable_legends(
73    scales: &ScaleSet,
74    legend_scales: &[Aesthetic],
75    guide: &GuideLegend,
76) -> Vec<Vec<Aesthetic>> {
77    struct Key {
78        title: String,
79        discrete: bool,
80        breaks: Vec<(f64, String)>,
81    }
82    let mut groups: Vec<(Key, Vec<Aesthetic>)> = Vec::new();
83    for aes in legend_scales {
84        let Some(scale) = scales.get(aes) else {
85            continue;
86        };
87        let breaks = scale.breaks();
88        let title = guide
89            .title
90            .clone()
91            .unwrap_or_else(|| scale.name().to_string());
92        let discrete = scale.is_discrete();
93        match groups
94            .iter_mut()
95            .find(|(k, _)| k.title == title && k.discrete == discrete && k.breaks == breaks)
96        {
97            Some((_, members)) => members.push(aes.clone()),
98            None => groups.push((
99                Key {
100                    title,
101                    discrete,
102                    breaks,
103                },
104                vec![aes.clone()],
105            )),
106        }
107    }
108    groups.into_iter().map(|(_, members)| members).collect()
109}
110
111/// Draw all legends for the plot.
112///
113/// No longer called internally: `compose::capture_legend` (the only
114/// caller) was rerouted to `draw_legend_planned` in 0.23 so a `PlotGrid`'s
115/// collected legend gets the same B3-2 wrap/grid treatment a single
116/// plot's legend does (sipemu/ggplot-rs#155). `draw_legend_planned` is
117/// crate-internal (`pub(crate)`), so there is no direct public
118/// replacement — legend drawing is otherwise handled automatically by
119/// `GGPlot`'s own render methods, which is the supported way to draw a
120/// legend; this function was never meant to be called by consumers for
121/// that purpose and will be removed in a later minor release.
122#[deprecated(
123    since = "0.23.0",
124    note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
125             sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
126             by GGPlot's render methods. Will be removed in a later minor release."
127)]
128pub fn draw_legend(
129    scales: &ScaleSet,
130    theme: &Theme,
131    plot_area: &Rect,
132    backend: &mut dyn DrawBackend,
133    guide: &GuideLegend,
134    suppressed: &std::collections::HashSet<Aesthetic>,
135) -> Result<(), RenderError> {
136    if matches!(theme.legend_position, LegendPosition::None) {
137        return Ok(());
138    }
139
140    // Collect all aesthetics that have a scale with breaks
141    let mut legend_scales: Vec<&Aesthetic> = Vec::new();
142    for aes in LEGEND_AESTHETICS {
143        // Skip suppressed aesthetics
144        if suppressed.contains(aes) {
145            continue;
146        }
147        if let Some(scale) = scales.get(aes) {
148            if !scale.breaks().is_empty() {
149                // Don't duplicate Color/Fill if both exist with same breaks
150                if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
151                    continue;
152                }
153                legend_scales.push(aes);
154            }
155        }
156    }
157
158    if legend_scales.is_empty() {
159        return Ok(());
160    }
161
162    // Compute legend origin based on position. This deprecated path never
163    // wraps a legend onto more than one row, so `legend_extra_rows` is
164    // always 0 — see `legend_position`'s doc comment. It also predates
165    // #225's `Inside` justification and keeps the old top-left anchor
166    // (`legend_size_hint: (0.0, 0.0)`) rather than estimate it, since
167    // it's unused internally and has no direct public replacement to
168    // keep behaviourally current.
169    let (legend_x, legend_y, mut is_horizontal) =
170        legend_position(theme, plot_area, 0, (0.0, 0.0), 0.0, plot_area);
171    // legend.direction overrides the auto layout from the position.
172    if let Some(dir) = theme.legend_direction {
173        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
174    }
175
176    let mut offset_y = legend_y;
177    let mut offset_x = legend_x;
178
179    for aes in &legend_scales {
180        let scale = scales.get(aes).unwrap();
181
182        if scale.is_discrete() {
183            if is_horizontal {
184                let width = draw_discrete_legend_at(
185                    scales,
186                    &[((*aes).clone(), scale)],
187                    theme,
188                    offset_x,
189                    offset_y,
190                    backend,
191                    guide,
192                    true,
193                )?;
194                offset_x += width + theme.legend_spacing * 2.0;
195            } else {
196                let height = draw_discrete_legend_at(
197                    scales,
198                    &[((*aes).clone(), scale)],
199                    theme,
200                    offset_x,
201                    offset_y,
202                    backend,
203                    guide,
204                    false,
205                )?;
206                offset_y += height + theme.legend_spacing * 2.0;
207            }
208        } else {
209            // Continuous legend (colorbar) — only for color/fill
210            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
211                let height = draw_continuous_legend_at(
212                    scale,
213                    theme,
214                    offset_x,
215                    offset_y,
216                    backend,
217                    guide,
218                    is_horizontal,
219                )?;
220                if is_horizontal {
221                    // #211: the bar is now drawn along x (wide, not
222                    // narrow) — step by its own length, not the old
223                    // vertical-bar width-plus-side-label estimate.
224                    let (bar_len, _) = continuous_bar_dims(theme, true);
225                    offset_x += bar_len + theme.legend_spacing * 2.0;
226                } else {
227                    offset_y += height + theme.legend_spacing * 2.0;
228                }
229            } else {
230                // Continuous size/alpha — draw as discrete-like with sampled breaks
231                let height = draw_discrete_legend_at(
232                    scales,
233                    &[((*aes).clone(), scale)],
234                    theme,
235                    offset_x,
236                    offset_y,
237                    backend,
238                    guide,
239                    is_horizontal,
240                )?;
241                if is_horizontal {
242                    offset_x += theme.legend_key_width
243                        + theme.legend_text.size * 6.0
244                        + theme.legend_spacing * 2.0;
245                } else {
246                    offset_y += height + theme.legend_spacing * 2.0;
247                }
248            }
249        }
250    }
251
252    Ok(())
253}
254
255/// How many rows beyond the first a horizontal legend wraps onto — `max`
256/// across guides sharing one row (several discrete guides side by side),
257/// `sum` for guides stacked vertically instead. Exactly `layout_built`'s
258/// own `extras.legend_extra_rows` computation, so anything derived from
259/// it here can't disagree with the `plot_area.y` that count already
260/// produced.
261fn legend_rows_beyond_first(
262    is_horizontal: bool,
263    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
264) -> usize {
265    if is_horizontal {
266        legend_plans
267            .values()
268            .map(|p| p.rows.saturating_sub(1))
269            .max()
270            .unwrap_or(0)
271    } else {
272        legend_plans
273            .values()
274            .map(|p| p.rows.saturating_sub(1))
275            .sum()
276    }
277}
278
279/// A horizontal (`Top`/`Bottom`) legend's own band height —
280/// `legend_margin.top` plus `(1 + legend_extra_rows)` row-heights
281/// (`legend_key_height + legend_spacing` each) plus `legend_margin.bottom`
282/// — exactly what `PlotLayout::compute_with_extras` reserves for it
283/// (#233).
284pub(crate) fn legend_band_height(theme: &Theme, legend_extra_rows: usize) -> f64 {
285    theme.legend_margin.top
286        + (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
287        + theme.legend_margin.bottom
288}
289
290/// #233: how much `renderer.rs`'s title/subtitle drawing must additionally
291/// lift itself above `plot_area.y` so neither overlaps a `Top` legend —
292/// the same idea as its existing `x_axis_lift` (a top x-axis also
293/// reserves space above the panel). Zero when there's no legend to draw,
294/// or its position isn't `Top`: byte-identical to before in every other
295/// case.
296///
297/// #577: `legend_band_height` alone only covers the flat discrete-row
298/// case — a continuous, horizontally-drawn Color/Fill colourbar *with its
299/// own title* needs the same extra height `plot.rs` already reserves in
300/// `plot_area.y` via `continuous_horizontal_legend_extra_height` (#211).
301/// Without adding that extra here too, this lift undershot by exactly
302/// that amount, so the plot's own title/subtitle landed on top of the
303/// legend's title instead of above it. `guide` is the same
304/// [`GuideLegend`] `draw_legend_planned` draws with, so "does this guide
305/// have a title" can't disagree between the two.
306pub(crate) fn top_legend_lift(
307    theme: &Theme,
308    scales: &ScaleSet,
309    suppressed: &std::collections::HashSet<Aesthetic>,
310    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
311    guide: &GuideLegend,
312) -> f64 {
313    if !matches!(theme.legend_position, LegendPosition::Top) {
314        return 0.0;
315    }
316    let legend_scales = legend_scales_for(scales, suppressed);
317    if legend_scales.is_empty() {
318        return 0.0;
319    }
320    let is_horizontal = match theme.legend_direction {
321        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
322        None => true, // Top is always the horizontal auto-position.
323    };
324    let base = legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans));
325    let colorbar_extra = if is_horizontal {
326        legend_scales
327            .iter()
328            .find_map(|aes| {
329                if !matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
330                    return None;
331                }
332                let scale = scales.get(aes)?;
333                if scale.is_discrete() {
334                    return None;
335                }
336                let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
337                let bar_height = continuous_horizontal_bar_height(theme, !title.is_empty());
338                Some((bar_height - theme.legend_key_height).max(0.0))
339            })
340            .unwrap_or(0.0)
341    } else {
342        0.0
343    };
344    base + colorbar_extra
345}
346
347/// Estimate the on-canvas footprint `(width, height)` of every legend
348/// scale about to be drawn, stacked the same way `draw_legend_planned`
349/// stacks them (each one's own height summed, the widest width shared)
350/// — used only by `legend_position`'s `Inside` arm (#225) to place the
351/// *whole* legend block by justification instead of always anchoring
352/// its top-left corner there. Mirrors each draw function's own returned
353/// height/width formula exactly (`draw_continuous_legend_at`,
354/// `draw_discrete_legend_at`, `draw_discrete_legend_grid`) without
355/// drawing anything. A `legend_plans` entry (an explicit `nrow`/`ncol`
356/// grid — auto-wrap never applies to a non-horizontal legend like
357/// `Inside`) is honoured for an accurate multi-row/-column estimate;
358/// everything else uses the single-column formula.
359fn estimate_legend_box(
360    scales: &ScaleSet,
361    legend_scales: &[Aesthetic],
362    theme: &Theme,
363    guide: &GuideLegend,
364    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
365) -> (f64, f64) {
366    let mut total_h = 0.0_f64;
367    let mut max_w = 0.0_f64;
368    for aes in legend_scales {
369        let Some(scale) = scales.get(aes) else {
370            continue;
371        };
372        let breaks = scale.breaks();
373        if breaks.is_empty() {
374            continue;
375        }
376        let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
377        let title_offset = if !title.is_empty() {
378            theme.legend_title.size + 4.0
379        } else {
380            0.0
381        };
382        // #236: a size legend's key box (and the row height it sits in)
383        // grows to fit its largest key — see `size_key_diameter`.
384        let diameter = size_key_diameter(aes, scales, scale, &breaks);
385        let key_width = theme.legend_key_width.max(diameter);
386        let key_height = theme.legend_key_height.max(key_width);
387        // The widest label and the title as drawn (a fixed six-character
388        // guess let a long label or title run off a right-corner legend).
389        let label_w = breaks
390            .iter()
391            .map(|(_, l)| crate::render::text::text_width(l, theme.legend_text.size))
392            .fold(0.0_f64, f64::max);
393        let title_w = crate::render::text::text_width(title, theme.legend_title.size);
394        let single_col_width = (key_width + theme.legend_spacing + label_w).max(title_w);
395        let is_continuous_bar =
396            matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
397        let (w, h) = if is_continuous_bar {
398            // The bottom break label is centred on the bar's end: its lower
399            // half hangs below the bar (#377).
400            (
401                single_col_width,
402                title_offset + key_height * 8.0 + theme.legend_text.size / 2.0,
403            )
404        } else if let Some(plan) = legend_plans.get(aes) {
405            let w = if plan.col_pitch > 0.0 {
406                plan.cols as f64 * plan.col_pitch
407            } else {
408                single_col_width
409            };
410            (w, title_offset + plan.rows as f64 * key_height)
411        } else {
412            (
413                single_col_width,
414                title_offset + breaks.len() as f64 * key_height,
415            )
416        };
417        total_h += h;
418        max_w = max_w.max(w);
419    }
420    (max_w, total_h)
421}
422
423/// Clamp a `[pos, pos + size]` span inside `[lo, hi]` — `pos` alone when
424/// `size` already overflows `[lo, hi]` (a legend taller/wider than the
425/// canvas: nothing to do but left/top-align it and let it overflow, same
426/// as #225's old unconditional top-left anchor did for every `Inside`
427/// legend).
428fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
429    if size >= hi - lo {
430        lo
431    } else {
432        pos.clamp(lo, hi - size)
433    }
434}
435
436/// Compute legend origin based on position setting.
437///
438/// `legend_extra_rows` is #233's `LayoutExtras::legend_extra_rows` — a
439/// horizontal legend wrapped onto more than one row by `plan_legend`'s
440/// auto-wrap/explicit grid. Only `Top` reads it: `PlotLayout::
441/// compute_with_extras` reserves a `legend_band_height` row directly
442/// above `plot_area` (ending exactly at `plot_area.y`), so backing off
443/// `plot_area.y` by that same band content (everything but
444/// `legend_margin.top`, which is the gap *above* where drawing starts)
445/// lands the legend's first row exactly inside its reserved band — after
446/// the title/subtitle and the plot's own outer margin, rather than
447/// #233's bug (an absolute-from-canvas-origin `legend_margin.top`,
448/// overlapping both).
449///
450/// `legend_size_hint` (#225) is the legend's own estimated `(width,
451/// height)` — see `estimate_legend_box` — read only by `Inside`. ggplot2
452/// defaults `legend.justification.inside` to `legend.position.inside`:
453/// the legend's own fractional point `(fx, fy)` (measured from its own
454/// bottom-left, in the same `0..=1` convention `Inside`'s `(fx, fy)`
455/// already uses) lands exactly on the panel's point `(fx, fy)`, rather
456/// than always anchoring the legend's top-left corner there (#225's
457/// bug: a legend taller than roughly `(1 - fy) * plot_area.height` —
458/// trivially true for a tall continuous colourbar near `fy = 0`, "near
459/// the bottom" — ran off the canvas, and a discrete legend with enough
460/// levels did exactly the same). `canvas` is a last-resort clamp for a
461/// legend taller or wider than the *panel*: still on the canvas, even if
462/// that means spilling outside the panel itself.
463///
464/// Every other position ignores `legend_size_hint`; callers that don't
465/// have a real estimate in hand (the deprecated `draw_legend`, or where
466/// the returned `y` is unused) pass `(0.0, 0.0)` and `plot_area` itself
467/// as `canvas` — harmless, since neither matters there.
468///
469/// Returns (x, y, is_horizontal).
470pub(crate) fn legend_position(
471    theme: &Theme,
472    plot_area: &Rect,
473    legend_extra_rows: usize,
474    legend_size_hint: (f64, f64),
475    title_lift: f64,
476    canvas: &Rect,
477) -> (f64, f64, bool) {
478    match theme.legend_position {
479        LegendPosition::Right => (
480            plot_area.x + plot_area.width + theme.legend_margin.left,
481            plot_area.y + theme.legend_margin.top,
482            false,
483        ),
484        LegendPosition::Left => (
485            theme.legend_margin.left,
486            plot_area.y + theme.legend_margin.top,
487            false,
488        ),
489        LegendPosition::Top => (
490            plot_area.x + theme.legend_margin.left,
491            plot_area.y - legend_band_height(theme, legend_extra_rows) + theme.legend_margin.top,
492            true,
493        ),
494        LegendPosition::Bottom => (
495            plot_area.x + theme.legend_margin.left,
496            // Below the x axis — at most the 30px it used to assume, so an
497            // axis-less theme (`theme_void`) keeps the legend inside the band
498            // the layout reserved rather than off the canvas (#341).
499            plot_area.y
500                + plot_area.height
501                + theme.legend_margin.top
502                + crate::render::layout::PlotLayout::x_axis_band(theme, None).min(30.0),
503            true,
504        ),
505        LegendPosition::None => (0.0, 0.0, false),
506        LegendPosition::Inside(fx, fy) => {
507            let (legend_w, legend_h) = legend_size_hint;
508            // #377: inset by `legend_margin` from the panel edges, like
509            // every outside position, and place the *visible* box: the
510            // title is drawn centred on the origin's y, so its upper half
511            // (`lift`) sits above the origin.
512            let m = &theme.legend_margin;
513            let lift = title_lift;
514            let box_h = legend_h + lift;
515            let raw_x = plot_area.x + m.left + fx * (plot_area.width - m.left - m.right - legend_w);
516            // fy = 1 is the panel's top in ggplot-rs's convention (y grows
517            // down in SVG space, same flip the old formula already had).
518            let raw_top =
519                plot_area.y + m.top + (1.0 - fy) * (plot_area.height - m.top - m.bottom - box_h);
520            let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
521            let top = clamp_span(raw_top, box_h, canvas.y, canvas.y + canvas.height);
522            (x, top + lift, false)
523        }
524    }
525}
526
527/// How far an inside legend's title reaches above the legend origin (it is
528/// drawn centred on the origin's y): half the title's size when the first
529/// guide has a title, else nothing (#377).
530fn inside_title_lift(
531    scales: &ScaleSet,
532    representatives: &[Aesthetic],
533    theme: &Theme,
534    guide: &GuideLegend,
535) -> f64 {
536    let first = representatives
537        .iter()
538        .filter_map(|aes| scales.get(aes))
539        .find(|scale| !scale.breaks().is_empty());
540    match first {
541        Some(scale)
542            if theme.legend_title.visible
543                && !guide
544                    .title
545                    .as_deref()
546                    .unwrap_or_else(|| scale.name())
547                    .is_empty() =>
548        {
549            theme.legend_title.size / 2.0
550        }
551        _ => 0.0,
552    }
553}
554
555/// For an `Aesthetic::Size` scale, the largest mapped key's diameter
556/// across `breaks` (#236: ggplot2 draws each size key at its break's own
557/// mapped size and grows the key box to fit the largest one, rather than
558/// clamping every key to the fixed default swatch size — which made
559/// every key past a certain size draw identically, the reported bug).
560/// `0.0` for every other aesthetic, or if every mapped size already fits
561/// the theme's default `legend_key_width`, so a caller can just `.max()`
562/// it into the usual fixed size with no special-casing and no change to
563/// existing output when nothing needs the extra room.
564pub(crate) fn size_key_diameter(
565    aes: &Aesthetic,
566    scales: &ScaleSet,
567    scale: &dyn crate::scale::Scale,
568    breaks: &[(f64, String)],
569) -> f64 {
570    if *aes != Aesthetic::Size {
571        return 0.0;
572    }
573    let max_r = breaks
574        .iter()
575        .map(|(pos, label)| {
576            scale
577                .map_fraction_to_size(*pos)
578                .or_else(|| scales.map_size(&Value::Str(label.clone())))
579                .unwrap_or(3.0)
580        })
581        .fold(0.0_f64, f64::max);
582    max_r * 2.0
583}
584
585/// Draw a discrete legend at a given position. Returns the height used.
586#[allow(clippy::too_many_arguments)]
587/// Draw one discrete legend — or, when `group` has more than one entry
588/// (#239: guide merging), one *combined* legend whose every key overlays
589/// each aesthetic's own glyph at the same position (e.g. a filled swatch
590/// with a mapped-colour outline), the way ggplot2 merges `fill` and
591/// `colour` guides mapped to the same measure through the same scale
592/// spec into one legend instead of two. `group[0]` is the representative
593/// scale for the title, breaks and labels — `group_mergeable_legends`
594/// only ever groups aesthetics whose `breaks()` (and so their shared
595/// title) are already identical, so any member would do.
596fn draw_discrete_legend_at(
597    scales: &ScaleSet,
598    group: &[(Aesthetic, &dyn crate::scale::Scale)],
599    theme: &Theme,
600    legend_x: f64,
601    legend_y: f64,
602    backend: &mut dyn DrawBackend,
603    guide: &GuideLegend,
604    is_horizontal: bool,
605) -> Result<f64, RenderError> {
606    let (_, scale) = &group[0];
607    let mut breaks = scale.breaks();
608    if breaks.is_empty() {
609        return Ok(0.0);
610    }
611
612    // Apply guide reverse
613    if guide.reverse {
614        breaks.reverse();
615    }
616
617    // #236: a size legend's key box (and the row height it sits in) grows
618    // to fit its largest key — see `size_key_diameter`. A no-op (both
619    // `.max()`s keep the theme defaults) for every other aesthetic, and
620    // for a size legend whose largest key already fits them. At most one
621    // `group` member is ever `Aesthetic::Size` (`LEGEND_AESTHETICS` has
622    // only one), so taking the max across the group is exactly the same
623    // as the single-aesthetic case when `group.len() == 1`.
624    let diameter = group
625        .iter()
626        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
627        .fold(0.0_f64, f64::max);
628    let swatch_size = theme.legend_key_width.max(diameter);
629    let item_height = theme.legend_key_height.max(swatch_size);
630
631    // Draw legend title (guide title overrides scale name)
632    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
633    let legend_family = if theme.legend_title.family.is_empty() {
634        None
635    } else {
636        Some(theme.legend_title.family.clone())
637    };
638    let title_offset = if !title.is_empty() {
639        backend.draw_text(
640            title,
641            (legend_x, legend_y),
642            &TextStyle {
643                color: theme.legend_title.color,
644                size: theme.legend_title.size,
645                anchor: TextAnchor::Start,
646                angle: 0.0,
647                family: legend_family,
648                face: theme.legend_title.face,
649                bg_colour: None,
650                bg_r: 0.0,
651            },
652        )?;
653        theme.legend_title.size + 4.0
654    } else {
655        0.0
656    };
657
658    let items_y = legend_y + title_offset;
659
660    // Draw legend background
661    if theme.legend_background.visible {
662        let total_height = breaks.len() as f64 * item_height;
663        let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
664        if let Some(fill) = theme.legend_background.fill {
665            backend.draw_rect(
666                (legend_x - 2.0, items_y - 2.0),
667                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
668                &RectStyle {
669                    fill: Some(fill),
670                    stroke: theme.legend_background.color,
671                    stroke_width: theme.legend_background.width,
672                    alpha: 1.0,
673                    clip: false,
674                    corner_radius: 0.0,
675                },
676            )?;
677        }
678    }
679
680    // Horizontal legends lay keys left-to-right; vertical ones stack downward.
681    let mut cur_x = legend_x;
682    for (i, (pos, label)) in breaks.iter().enumerate() {
683        let x = if is_horizontal { cur_x } else { legend_x };
684        let y = if is_horizontal {
685            items_y
686        } else {
687            items_y + i as f64 * item_height
688        };
689        let center_x = x + swatch_size / 2.0;
690        let center_y = y + swatch_size / 2.0;
691
692        // Draw legend key background
693        if theme.legend_key.visible {
694            if let Some(fill) = theme.legend_key.fill {
695                backend.draw_rect(
696                    (x, y),
697                    (x + swatch_size, y + swatch_size),
698                    &RectStyle {
699                        fill: Some(fill),
700                        stroke: theme.legend_key.color,
701                        stroke_width: theme.legend_key.width,
702                        alpha: 1.0,
703                        clip: false,
704                        corner_radius: 0.0,
705                    },
706                )?;
707            }
708        }
709
710        // Draw every group member's own glyph, overlaid at the same
711        // position (#239) — a single swatch for the common, unmerged
712        // case (`group.len() == 1`).
713        draw_group_keys(
714            backend,
715            scales,
716            group,
717            theme,
718            guide,
719            label,
720            *pos,
721            (x, y, center_x, center_y),
722            swatch_size,
723        )?;
724
725        // Label
726        let label_family = if theme.legend_text.family.is_empty() {
727            None
728        } else {
729            Some(theme.legend_text.family.clone())
730        };
731        backend.draw_text(
732            label,
733            (x + swatch_size + theme.legend_spacing, center_y),
734            &TextStyle {
735                color: theme.legend_text.color,
736                size: theme.legend_text.size,
737                anchor: TextAnchor::Start,
738                angle: 0.0,
739                family: label_family,
740                face: theme.legend_text.face,
741                bg_colour: None,
742                bg_r: 0.0,
743            },
744        )?;
745        if is_horizontal {
746            // advance past swatch + spacing + estimated label width + gap
747            cur_x = x
748                + swatch_size
749                + theme.legend_spacing
750                + (label.chars().count() as f64) * theme.legend_text.size * 0.62
751                + theme.legend_spacing * 3.0;
752        }
753    }
754
755    if is_horizontal {
756        Ok(cur_x - legend_x) // width used
757    } else {
758        Ok(title_offset + breaks.len() as f64 * item_height)
759    }
760}
761
762/// #211: the bar's own `(length, thickness)` in pixels for a continuous
763/// colorbar — vertical keeps the original tall-and-narrow dims
764/// (`legend_key_width` thick, `legend_key_height * 8.0` long); horizontal
765/// swaps them (ggplot2's `guide_colourbar(direction = "horizontal")`),
766/// so the bar is wide-and-short instead. Shared by
767/// [`draw_continuous_legend_at`] (the real draw) and
768/// [`continuous_horizontal_bar_height`] (the layout-time estimate), so
769/// the two can't disagree.
770fn continuous_bar_dims(theme: &Theme, is_horizontal: bool) -> (f64, f64) {
771    if is_horizontal {
772        (theme.legend_key_height * 8.0, theme.legend_key_width)
773    } else {
774        (theme.legend_key_width, theme.legend_key_height * 8.0)
775    }
776}
777
778/// #211: the total height a *horizontal* continuous colorbar consumes —
779/// title line (if `has_title`) + bar thickness + its tick marks + one
780/// line of labels below. Used by `plot.rs` to reserve the Top/Bottom
781/// legend band's height before layout runs; mirrors
782/// [`draw_continuous_legend_at`]'s own math exactly so the reserved band
783/// and the actual draw can't disagree.
784pub(crate) fn continuous_horizontal_bar_height(theme: &Theme, has_title: bool) -> f64 {
785    let title_offset = if has_title {
786        theme.legend_title.size + 4.0
787    } else {
788        0.0
789    };
790    let (_, bar_thickness) = continuous_bar_dims(theme, true);
791    let tick_len = 3.0;
792    let label_line = theme.legend_text.size + 4.0;
793    title_offset + bar_thickness + tick_len + theme.legend_spacing + label_line
794}
795
796/// Draw a continuous colorbar legend at a given position. `is_horizontal`
797/// (#211) draws it wide × short — bar slices left to right from the
798/// scale's min to max, ticks/labels below the bar, matching ggplot2's
799/// `guide_colourbar(direction = "horizontal")` — instead of the default
800/// tall × narrow bar with ticks/labels to its right. Returns the height
801/// used.
802fn draw_continuous_legend_at(
803    scale: &dyn crate::scale::Scale,
804    theme: &Theme,
805    legend_x: f64,
806    legend_y: f64,
807    backend: &mut dyn DrawBackend,
808    guide: &GuideLegend,
809    is_horizontal: bool,
810) -> Result<f64, RenderError> {
811    let breaks = scale.breaks();
812    if breaks.is_empty() {
813        return Ok(0.0);
814    }
815
816    let (bar_width, bar_height) = continuous_bar_dims(theme, is_horizontal);
817
818    // Draw legend title (guide title overrides scale name)
819    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
820    let cont_family = if theme.legend_title.family.is_empty() {
821        None
822    } else {
823        Some(theme.legend_title.family.clone())
824    };
825    let title_offset = if !title.is_empty() {
826        backend.draw_text(
827            title,
828            (legend_x, legend_y),
829            &TextStyle {
830                color: theme.legend_title.color,
831                size: theme.legend_title.size,
832                anchor: TextAnchor::Start,
833                angle: 0.0,
834                family: cont_family,
835                face: theme.legend_title.face,
836                bg_colour: None,
837                bg_r: 0.0,
838            },
839        )?;
840        theme.legend_title.size + 4.0
841    } else {
842        0.0
843    };
844
845    let bar_top = legend_y + title_offset;
846
847    // Draw legend background
848    if theme.legend_background.visible {
849        let total_width = if is_horizontal {
850            bar_width
851        } else {
852            bar_width + theme.legend_spacing + theme.legend_text.size * 6.0
853        };
854        let total_height = if is_horizontal {
855            bar_height + theme.legend_spacing + theme.legend_text.size * 2.0
856        } else {
857            bar_height
858        };
859        if let Some(fill) = theme.legend_background.fill {
860            backend.draw_rect(
861                (legend_x - 2.0, bar_top - 2.0),
862                (legend_x + total_width + 2.0, bar_top + total_height + 2.0),
863                &RectStyle {
864                    fill: Some(fill),
865                    stroke: theme.legend_background.color,
866                    stroke_width: theme.legend_background.width,
867                    alpha: 1.0,
868                    clip: false,
869                    corner_radius: 0.0,
870                },
871            )?;
872        }
873    }
874
875    // Draw gradient bar as N thin slices — stacked top to bottom (max at
876    // the top) when vertical, side by side left to right (min on the
877    // left, like an x-axis) when horizontal.
878    // Use data-domain values to avoid double-normalization in map_to_color()
879    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
880    let n_slices = 50;
881    if is_horizontal {
882        let slice_width = bar_width / n_slices as f64;
883        for i in 0..n_slices {
884            let t = i as f64 / n_slices as f64;
885            let data_val = data_min + t * (data_max - data_min);
886            let color = scale
887                .map_to_color(&Value::Float(data_val))
888                .unwrap_or((127, 127, 127));
889            let sx = legend_x + i as f64 * slice_width;
890            backend.draw_rect(
891                (sx, bar_top),
892                (sx + slice_width + 0.5, bar_top + bar_height),
893                &RectStyle {
894                    fill: Some(color),
895                    stroke: None,
896                    stroke_width: 0.0,
897                    alpha: 1.0,
898                    clip: false,
899                    corner_radius: 0.0,
900                },
901            )?;
902        }
903    } else {
904        let slice_height = bar_height / n_slices as f64;
905        for i in 0..n_slices {
906            let t = 1.0 - i as f64 / n_slices as f64;
907            let data_val = data_min + t * (data_max - data_min);
908            let color = scale
909                .map_to_color(&Value::Float(data_val))
910                .unwrap_or((127, 127, 127));
911            let sy = bar_top + i as f64 * slice_height;
912            backend.draw_rect(
913                (legend_x, sy),
914                (legend_x + bar_width, sy + slice_height + 0.5),
915                &RectStyle {
916                    fill: Some(color),
917                    stroke: None,
918                    stroke_width: 0.0,
919                    alpha: 1.0,
920                    clip: false,
921                    corner_radius: 0.0,
922                },
923            )?;
924        }
925    }
926
927    // Draw border
928    // (Ticks reuse its colour and width even when the frame is hidden.)
929    let border = theme.legend_bar_border_line();
930    let border_style = LineStyle {
931        color: border.color,
932        width: border.width,
933        alpha: 1.0,
934        linetype: border.linetype,
935        ..Default::default()
936    };
937    if border.visible {
938        backend.draw_line(
939            &[
940                (legend_x, bar_top),
941                (legend_x + bar_width, bar_top),
942                (legend_x + bar_width, bar_top + bar_height),
943                (legend_x, bar_top + bar_height),
944                (legend_x, bar_top),
945            ],
946            &border_style,
947        )?;
948    }
949
950    // Draw tick marks and labels: to the bar's right (vertical, pos=1 at
951    // the top) or below it (horizontal, pos=0 on the left), matching the
952    // slice order drawn above.
953    let tick_len = 3.0;
954    let tick_family = if theme.legend_text.family.is_empty() {
955        None
956    } else {
957        Some(theme.legend_text.family.clone())
958    };
959    for (pos, label) in &breaks {
960        if is_horizontal {
961            let tick_x = legend_x + bar_width * pos;
962            backend.draw_line(
963                &[
964                    (tick_x, bar_top + bar_height),
965                    (tick_x, bar_top + bar_height + tick_len),
966                ],
967                &border_style,
968            )?;
969            backend.draw_text(
970                label,
971                (
972                    tick_x,
973                    bar_top + bar_height + tick_len + theme.legend_text.size,
974                ),
975                &TextStyle {
976                    color: theme.legend_text.color,
977                    size: theme.legend_text.size,
978                    anchor: TextAnchor::Middle,
979                    angle: 0.0,
980                    family: tick_family.clone(),
981                    face: theme.legend_text.face,
982                    bg_colour: None,
983                    bg_r: 0.0,
984                },
985            )?;
986        } else {
987            let tick_y = bar_top + bar_height * (1.0 - pos);
988            backend.draw_line(
989                &[
990                    (legend_x + bar_width, tick_y),
991                    (legend_x + bar_width + tick_len, tick_y),
992                ],
993                &border_style,
994            )?;
995            backend.draw_text(
996                label,
997                (
998                    legend_x + bar_width + tick_len + theme.legend_spacing,
999                    tick_y,
1000                ),
1001                &TextStyle {
1002                    color: theme.legend_text.color,
1003                    size: theme.legend_text.size,
1004                    anchor: TextAnchor::Start,
1005                    angle: 0.0,
1006                    family: tick_family.clone(),
1007                    face: theme.legend_text.face,
1008                    bg_colour: None,
1009                    bg_r: 0.0,
1010                },
1011            )?;
1012        }
1013    }
1014
1015    if is_horizontal {
1016        Ok(continuous_horizontal_bar_height(theme, !title.is_empty()))
1017    } else {
1018        Ok(title_offset + bar_height)
1019    }
1020}
1021
1022/// Draw one legend key's swatch (not its label) for `aes`, at `(x, y)`
1023/// (top-left) / `(center_x, center_y)` (centre), `swatch_size` square.
1024/// Shared by the unchanged [`draw_discrete_legend_at`] and the B3-2
1025/// planner-driven [`draw_legend_planned`], so both draw pixel-identical
1026/// swatches.
1027///
1028/// A key with no aesthetic-mapped colour of its own (`Shape`, `Linetype`,
1029/// `Size`, `Alpha`, `Linewidth` — `Color`/`Fill` always have one, from
1030/// `scales.map_color`) falls back to `theme.geom.ink`, then
1031/// `theme.legend_text.color` (#237: a *fixed* dark grey here was almost
1032/// invisible on a dark panel in a theme that sets `legend_text.color`
1033/// for readability but never thought to also set the less commonly used
1034/// `geom.ink`) — the same two-tier fallback `GeomPoint`/`GeomText`'s own
1035/// `apply_element_geom` effectively uses for an unmapped panel mark
1036/// (`ink` if the theme sets it, otherwise its own default), extended one
1037/// more tier since a key glyph has no mapped colour of its own to start
1038/// from the way a panel mark's default colour does. **Visible default
1039/// change:** the base theme's `legend_text.color` is pure black
1040/// (`(0, 0, 0)`), not the old fallback's dark grey (`(50, 50, 50)`) — a
1041/// small, intentional difference (documented in the CHANGELOG), not a
1042/// behaviour-preserving refactor.
1043#[allow(clippy::too_many_arguments)]
1044fn draw_legend_swatch(
1045    backend: &mut dyn DrawBackend,
1046    scales: &ScaleSet,
1047    aes: &Aesthetic,
1048    scale: &dyn crate::scale::Scale,
1049    theme: &Theme,
1050    label: &str,
1051    pos: f64,
1052    x: f64,
1053    y: f64,
1054    center_x: f64,
1055    center_y: f64,
1056    swatch_size: f64,
1057) -> Result<(), RenderError> {
1058    let value = Value::Str(label.to_string());
1059    match aes {
1060        Aesthetic::Color | Aesthetic::Fill => {
1061            let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
1062            let style = RectStyle {
1063                fill: Some(color),
1064                stroke: None,
1065                stroke_width: 0.0,
1066                alpha: 1.0,
1067                clip: false,
1068                corner_radius: 0.0,
1069            };
1070            // #206: the "no data" key draws its scale's na_pattern
1071            // instead of a flat swatch, when set.
1072            if scale.is_na_break(label) {
1073                if let Some(p) = scale.na_fill_pattern() {
1074                    return backend.draw_rect_pattern(
1075                        (x, y),
1076                        (x + swatch_size, y + swatch_size),
1077                        &p,
1078                        &style,
1079                    );
1080                }
1081            }
1082            backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
1083        }
1084        Aesthetic::Shape => {
1085            let shape = scales
1086                .map_shape(&value)
1087                .unwrap_or(crate::render::backend::PointShape::Circle);
1088            backend.draw_shape(
1089                (center_x, center_y),
1090                swatch_size / 3.0,
1091                &PointStyle {
1092                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1093                    alpha: 1.0,
1094                    filled: true,
1095                    shape,
1096                },
1097            )?;
1098        }
1099        Aesthetic::Linetype => {
1100            let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
1101            backend.draw_line(
1102                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1103                &LineStyle {
1104                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1105                    width: 1.5,
1106                    alpha: 1.0,
1107                    linetype: lt,
1108                    ..Default::default()
1109                },
1110            )?;
1111        }
1112        Aesthetic::Size => {
1113            let size = scale
1114                .map_fraction_to_size(pos)
1115                .or_else(|| scales.map_size(&value))
1116                .unwrap_or(3.0);
1117            backend.draw_shape(
1118                (center_x, center_y),
1119                size.min(swatch_size / 2.0),
1120                &PointStyle {
1121                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1122                    alpha: 1.0,
1123                    filled: true,
1124                    shape: crate::render::backend::PointShape::Circle,
1125                },
1126            )?;
1127        }
1128        Aesthetic::Alpha => {
1129            let alpha = scale
1130                .map_fraction_to_alpha(pos)
1131                .or_else(|| scales.map_alpha(&value))
1132                .unwrap_or(1.0);
1133            backend.draw_rect(
1134                (x, y),
1135                (x + swatch_size, y + swatch_size),
1136                &RectStyle {
1137                    fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
1138                    stroke: None,
1139                    stroke_width: 0.0,
1140                    alpha,
1141                    clip: false,
1142                    corner_radius: 0.0,
1143                },
1144            )?;
1145        }
1146        Aesthetic::Linewidth => {
1147            let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
1148            backend.draw_line(
1149                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1150                &LineStyle {
1151                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1152                    width: width.min(swatch_size),
1153                    alpha: 1.0,
1154                    linetype: Linetype::Solid,
1155                    ..Default::default()
1156                },
1157            )?;
1158        }
1159        _ => {}
1160    }
1161    Ok(())
1162}
1163
1164/// Draw one legend key for `group` (#410): every layer that shows one of the
1165/// group's aesthetics draws its own glyph ([`crate::guide::key::draw_key`]),
1166/// overlaid in layer order — a line layer and a point layer on one colour
1167/// mapping share one key. Layers without a glyph (and plots with no layer
1168/// info) draw the per-aesthetic swatch as before.
1169#[allow(clippy::too_many_arguments)]
1170fn draw_group_keys(
1171    backend: &mut dyn DrawBackend,
1172    scales: &ScaleSet,
1173    group: &[(Aesthetic, &dyn crate::scale::Scale)],
1174    theme: &Theme,
1175    guide: &GuideLegend,
1176    label: &str,
1177    pos: f64,
1178    (x, y, center_x, center_y): (f64, f64, f64, f64),
1179    swatch_size: f64,
1180) -> Result<(), RenderError> {
1181    use crate::guide::key::{draw_key, KeyProps};
1182    let in_group = |a: &Aesthetic| group.iter().any(|(g, _)| g == a);
1183    let layers: Vec<_> = guide
1184        .layer_keys
1185        .iter()
1186        .filter(|k| k.aesthetics.iter().any(in_group))
1187        .collect();
1188    let legacy = |backend: &mut dyn DrawBackend, only: Option<&[Aesthetic]>| {
1189        for (member_aes, member_scale) in group {
1190            if only.is_some_and(|o| !o.contains(member_aes)) {
1191                continue;
1192            }
1193            draw_legend_swatch(
1194                backend,
1195                scales,
1196                member_aes,
1197                *member_scale,
1198                theme,
1199                label,
1200                pos,
1201                x,
1202                y,
1203                center_x,
1204                center_y,
1205                swatch_size,
1206            )?;
1207        }
1208        Ok::<(), RenderError>(())
1209    };
1210    if layers.iter().all(|k| k.glyph.is_none()) {
1211        return legacy(backend, None);
1212    }
1213    let value = Value::Str(label.to_string());
1214    let ink = theme.geom.ink.unwrap_or(theme.legend_text.color);
1215    for k in layers {
1216        let Some(glyph) = k.glyph else {
1217            legacy(backend, Some(&k.aesthetics))?;
1218            continue;
1219        };
1220        let mut props = KeyProps::default();
1221        for (aes, scale) in group {
1222            if !k.aesthetics.contains(aes) {
1223                continue;
1224            }
1225            match aes {
1226                Aesthetic::Color => props.color = scales.map_color(aes, &value),
1227                Aesthetic::Fill => props.fill = scales.map_color(aes, &value),
1228                Aesthetic::Shape => props.shape = scales.map_shape(&value),
1229                Aesthetic::Linetype => props.linetype = scales.map_linetype(&value),
1230                Aesthetic::Size => {
1231                    props.size = scale
1232                        .map_fraction_to_size(pos)
1233                        .or_else(|| scales.map_size(&value))
1234                }
1235                Aesthetic::Alpha => {
1236                    props.alpha = scale
1237                        .map_fraction_to_alpha(pos)
1238                        .or_else(|| scales.map_alpha(&value))
1239                }
1240                Aesthetic::Linewidth => props.linewidth = scale.map_fraction_to_size(pos),
1241                _ => {}
1242            }
1243        }
1244        draw_key(backend, glyph, &props, ink, (x, y, swatch_size))?;
1245    }
1246    Ok(())
1247}
1248
1249// ─── B3-2: nrow/ncol/byrow, auto-wrap, "+N more" ───────────────────────────
1250
1251/// One legend key's grid placement, in the planner's own pixel space
1252/// (relative to the legend's own origin — `row` is 0-indexed top row,
1253/// `x_offset` is the horizontal pitch from the first column).
1254#[derive(Clone, Debug)]
1255pub(crate) struct LegendEntry {
1256    pub break_idx: usize,
1257    pub row: usize,
1258    pub x_offset: f64,
1259    /// A truncated-with-ellipsis display label (#159), set only when a
1260    /// vertical multi-column grid still doesn't fit the canvas after
1261    /// reducing its column count as far as it can go: the full label is
1262    /// still available (e.g. for a `<title>` tooltip) via the scale's own
1263    /// `breaks()[break_idx]`. `None` draws the break's label unchanged —
1264    /// the common, byte-identical case.
1265    pub label_override: Option<String>,
1266}
1267
1268/// A trailing "+{k} more" marker when the legend doesn't have room for
1269/// every key (D7). Not a mark (`draw_text` only — no swatch, no
1270/// `data-geom`/class).
1271#[derive(Clone, Debug)]
1272pub(crate) struct LegendOverflow {
1273    pub dropped: usize,
1274    pub row: usize,
1275    pub x_offset: f64,
1276    pub text: String,
1277}
1278
1279/// A non-default legend layout (B3-2): an explicit `nrow`/`ncol` grid, or
1280/// an auto-wrapped horizontal legend. `None` from [`plan_legend`] means
1281/// "nothing changes" — the caller keeps the 0.21 single-row/single-column
1282/// path verbatim, so a plot that doesn't need this is byte-identical.
1283#[derive(Clone, Debug)]
1284pub(crate) struct LegendPlan {
1285    pub rows: usize,
1286    pub entries: Vec<LegendEntry>,
1287    pub overflow: Option<LegendOverflow>,
1288    /// Columns in an explicit grid (always 1 for the horizontal auto-wrap
1289    /// path, which never adds columns). Review #158 finding 3: a vertical
1290    /// legend with `ncol > 1` needs `(cols − 1) · col_pitch` of extra
1291    /// width reserved, the same way extra rows are reserved for height.
1292    pub cols: usize,
1293    /// The per-column horizontal pitch an explicit grid uses (0 for the
1294    /// auto-wrap path, which doesn't have columns in this sense).
1295    pub col_pitch: f64,
1296}
1297
1298/// Per-key horizontal pitch (swatch + spacing + label + gap): the larger of
1299/// today's crude per-character advance (`draw_discrete_legend_at`'s
1300/// `cur_x` formula) and a `text_width`-based measurement, so a label that
1301/// ties under both estimates stays on the byte-identical single-row path.
1302fn legend_pitch(label: &str, theme: &Theme) -> f64 {
1303    let swatch = theme.legend_key_width;
1304    let spacing = theme.legend_spacing;
1305    let text_size = theme.legend_text.size;
1306    let legacy =
1307        swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
1308    let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
1309    legacy.max(measured)
1310}
1311
1312/// Plan a non-default legend layout (B3-2, D7). `avail_width` is the
1313/// horizontal room for a horizontal legend's flow-pack / overflow check
1314/// (`plot_area.x + plot_area.width - legend_x`); `avail_height` only feeds
1315/// the default `max_rows` formula. Returns `None` when nothing changes:
1316/// no explicit `nrow`/`ncol`, and (for a horizontal legend) the one-line
1317/// layout already fits — or the legend is vertical, where auto-wrap never
1318/// applies (D7 is horizontal-only; `nrow`/`ncol` still work on either).
1319pub(crate) fn plan_legend(
1320    breaks: &[(f64, String)],
1321    guide: &GuideLegend,
1322    theme: &Theme,
1323    avail_width: f64,
1324    avail_height: f64,
1325    is_horizontal: bool,
1326) -> Option<LegendPlan> {
1327    let n = breaks.len();
1328    if n == 0 {
1329        return None;
1330    }
1331
1332    // Explicit grid: ggplot2's `nrow`/`ncol`, column-major unless `byrow`.
1333    if guide.nrow.is_some() || guide.ncol.is_some() {
1334        let (rows, cols) = match (guide.nrow, guide.ncol) {
1335            (Some(nrow), Some(ncol)) => (nrow, ncol),
1336            (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
1337            (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
1338            (None, None) => unreachable!("checked above"),
1339        };
1340        let rows = rows.max(1);
1341        let cols = cols.max(1);
1342        let col_pitch = breaks
1343            .iter()
1344            .map(|(_, l)| legend_pitch(l, theme))
1345            .fold(0.0_f64, f64::max);
1346        let entries = (0..n)
1347            .map(|i| {
1348                let (row, col) = if guide.byrow {
1349                    (i / cols, i % cols)
1350                } else {
1351                    (i % rows, i / rows)
1352                };
1353                LegendEntry {
1354                    break_idx: i,
1355                    row,
1356                    x_offset: col as f64 * col_pitch,
1357                    label_override: None,
1358                }
1359            })
1360            .collect();
1361        return Some(LegendPlan {
1362            rows,
1363            entries,
1364            overflow: None,
1365            cols,
1366            col_pitch,
1367        });
1368    }
1369
1370    // Auto mode: D7 scopes auto-wrap to horizontal legends only.
1371    if !is_horizontal {
1372        return None;
1373    }
1374
1375    let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1376    let total_width: f64 = pitches.iter().sum();
1377    if total_width <= avail_width.max(0.0) {
1378        return None; // fits on one line — byte-identical to 0.21.
1379    }
1380
1381    let item_height = theme.legend_key_height.max(1.0);
1382    let row_limit = guide
1383        .max_rows
1384        .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1385        .max(1);
1386
1387    let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1388    let mut row = 0usize;
1389    let mut cur_x = 0.0_f64;
1390    for (i, &p) in pitches.iter().enumerate() {
1391        if cur_x > 0.0 && cur_x + p > avail_width {
1392            if row + 1 >= row_limit {
1393                break; // out of rows — the rest overflow into "+N more".
1394            }
1395            row += 1;
1396            cur_x = 0.0;
1397        }
1398        entries.push(LegendEntry {
1399            break_idx: i,
1400            row,
1401            x_offset: cur_x,
1402            label_override: None,
1403        });
1404        cur_x += p;
1405    }
1406
1407    if entries.len() >= n {
1408        return Some(LegendPlan {
1409            rows: row + 1,
1410            entries,
1411            overflow: None,
1412            cols: 1,
1413            col_pitch: 0.0,
1414        });
1415    }
1416
1417    // Overflow: make room for "+{k} more" on the last row, dropping
1418    // entries from its tail until it fits (or the row is empty).
1419    let mut dropped = n - entries.len();
1420    loop {
1421        let text = format!("+{dropped} more");
1422        let more_pitch = legend_pitch(&text, theme);
1423        let row_count = entries.iter().filter(|e| e.row == row).count();
1424        let last_x = entries
1425            .iter()
1426            .filter(|e| e.row == row)
1427            .map(|e| e.x_offset + pitches[e.break_idx])
1428            .fold(0.0_f64, f64::max);
1429        if last_x + more_pitch <= avail_width || row_count == 0 {
1430            return Some(LegendPlan {
1431                rows: row + 1,
1432                entries,
1433                overflow: Some(LegendOverflow {
1434                    dropped,
1435                    row,
1436                    x_offset: last_x,
1437                    text,
1438                }),
1439                cols: 1,
1440                col_pitch: 0.0,
1441            });
1442        }
1443        entries.pop();
1444        dropped += 1;
1445    }
1446}
1447
1448/// `Color`/`Fill` are the only aesthetics whose *continuous* legend draws
1449/// as an actual colourbar (`draw_continuous_legend_at`, fixed width). Every
1450/// other continuous legend (`Size`, `Linewidth`, `Alpha`, …) draws sampled
1451/// breaks the same way a discrete guide's keys do
1452/// (`draw_discrete_legend_at` / `draw_discrete_legend_grid`), so it needs
1453/// the same B3-2 wrap/grid planning a discrete guide gets rather than a
1454/// fixed-width reservation — sipemu/ggplot-rs#545: previously this wasn't
1455/// planned *at all*, so a `Size`/`Linewidth` legend with enough keys to
1456/// overflow a narrow `Top`/`Bottom` row just drew past the panel/canvas
1457/// edge with no wrap, shrink, or "+N more" to catch it.
1458pub(crate) fn is_colorbar_aesthetic(aes: &Aesthetic) -> bool {
1459    matches!(aes, Aesthetic::Color | Aesthetic::Fill)
1460}
1461
1462/// How many of a plot's legend scales get B3-2's wrap/grid planning — every
1463/// discrete guide, plus a continuous guide that isn't a colourbar (#545:
1464/// its sampled breaks wrap/shrink exactly like a discrete guide's keys do)
1465/// — used to split the available horizontal room among them when several
1466/// sit side by side (sipemu/ggplot-rs#155).
1467fn wrap_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1468    legend_scales
1469        .iter()
1470        .filter(|aes| {
1471            let is_discrete = scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false);
1472            is_discrete || !is_colorbar_aesthetic(aes)
1473        })
1474        .count()
1475        .max(1)
1476}
1477
1478/// How many of a plot's legend scales are a true colourbar (a continuous
1479/// `Color`/`Fill` scale) — each reserves its own fixed width share of a
1480/// horizontal row (#170 / review #167 finding 7: a bottom legend mixing a
1481/// colourbar with a discrete guide ignored the colourbar's share, so the
1482/// discrete guide's auto-wrap/grid was planned against the *whole* row and
1483/// could still run off the right edge).
1484fn colorbar_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1485    legend_scales
1486        .iter()
1487        .filter(|aes| {
1488            is_colorbar_aesthetic(aes) && !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false)
1489        })
1490        .count()
1491}
1492
1493/// The fixed horizontal width one colourbar guide reserves in a
1494/// horizontal row — the same per-guide step `draw_legend_planned` already
1495/// advances `offset_x` by once it has drawn one (see its `is_horizontal`
1496/// arm for a colourbar).
1497fn colorbar_guide_width(theme: &Theme) -> f64 {
1498    // #211: only used where a colourbar guide shares a *horizontal* row
1499    // with another one — its footprint there is the horizontal bar's
1500    // own length (ticks/labels sit below it now, not to its side), the
1501    // same width `draw_legend_planned` steps `offset_x` by.
1502    let (bar_len, _) = continuous_bar_dims(theme, true);
1503    bar_len + theme.legend_spacing * 2.0
1504}
1505
1506/// Plan every wrap-eligible legend scale's B3-2 layout (explicit
1507/// `nrow`/`ncol` grid, or auto-wrap) independently (sipemu/ggplot-rs#155):
1508/// previously this only ran for the plot's single discrete guide, leaving
1509/// a second simultaneous discrete guide (e.g. both `colour` and `shape`
1510/// mapped to discrete variables) always on the unwrapped 0.21 path. A
1511/// guide is wrap-eligible when it's discrete, or continuous but not a
1512/// colourbar (#545: `Size`/`Linewidth`/… sampled breaks wrap/shrink the
1513/// same way a discrete guide's keys do — only a true continuous
1514/// `Color`/`Fill` colourbar is exempt). When several wrap-eligible guides
1515/// sit side by side (`is_horizontal`), the available width is split evenly
1516/// among them first, so each one wraps against its own share rather than
1517/// the whole row (which would let it claim more room than its neighbours
1518/// leave). Guides that stack vertically instead each get the full
1519/// `avail_width`, since they don't compete for it. Returns one entry per
1520/// wrap-eligible guide that needs anything beyond the single-row/single-
1521/// column path — a guide absent from the map is byte-identical to
1522/// 0.21/0.22.
1523#[allow(clippy::too_many_arguments)]
1524pub(crate) fn plan_all_legends(
1525    scales: &ScaleSet,
1526    guide: &GuideLegend,
1527    theme: &Theme,
1528    suppressed: &std::collections::HashSet<Aesthetic>,
1529    avail_width: f64,
1530    avail_height: f64,
1531    is_horizontal: bool,
1532    warnings: &mut Vec<String>,
1533) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1534    let legend_scales = legend_scales_for(scales, suppressed);
1535    let n_wrap = wrap_guide_count(scales, &legend_scales);
1536    let per_guide_width = if is_horizontal {
1537        // #170: leave the wrap-planned guides only the width left over
1538        // after every colourbar guide's own fixed share (a continuous
1539        // Color/Fill legend sitting in the same row) — previously
1540        // `avail_width` was split among the discrete guides alone, so a
1541        // colourbar plus a discrete guide could together still overflow
1542        // the row. #545: a continuous, non-colourbar guide (e.g. `Size`/
1543        // `Linewidth`) is now one of the guides this width is split among
1544        // too, rather than drawing unconstrained.
1545        let n_colorbar = colorbar_guide_count(scales, &legend_scales);
1546        let colorbar_width = n_colorbar as f64 * colorbar_guide_width(theme);
1547        ((avail_width - colorbar_width) / n_wrap as f64).max(0.0)
1548    } else {
1549        avail_width
1550    };
1551
1552    let mut plans = std::collections::HashMap::new();
1553    for aes in &legend_scales {
1554        let Some(scale) = scales.get(aes) else {
1555            continue;
1556        };
1557        // #545: plan a continuous, non-colourbar guide (`Size`/
1558        // `Linewidth`/… sampled breaks) exactly like a discrete one —
1559        // only a true colourbar (continuous `Color`/`Fill`) is exempt
1560        // from B3-2's wrap/grid planning.
1561        if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
1562            continue;
1563        }
1564        let breaks = scale.breaks();
1565        let validated_guide = guide.validated(breaks.len(), warnings);
1566        // #271: size against `breaks_for_layout()`, not `breaks` itself —
1567        // identical for every scale except `ScaleFillBinned` under
1568        // `count_reserve`, where it reserves width for the widest count
1569        // digit width so the resulting row count doesn't depend on which
1570        // frame's counts happen to be measured.
1571        let layout_breaks = scale.breaks_for_layout();
1572        if let Some(plan) = plan_legend(
1573            &layout_breaks,
1574            &validated_guide,
1575            theme,
1576            per_guide_width,
1577            avail_height,
1578            is_horizontal,
1579        ) {
1580            plans.insert(aes.clone(), plan);
1581        }
1582    }
1583    plans
1584}
1585
1586/// Draw all legends for the plot, honouring each discrete guide's B3-2
1587/// `plan_legend` layout (explicit `nrow`/`ncol`, or an overflowing
1588/// horizontal legend's auto-wrap) when it has one — several simultaneous
1589/// discrete guides each get their own independent plan
1590/// (sipemu/ggplot-rs#155). A guide missing from `legend_plans` falls back
1591/// to the unchanged [`draw_legend`] path — same calls, same order — which
1592/// keeps this byte-identical to 0.21/0.22 for every plot nothing in
1593/// `legend_plans` touches. Used by `renderer.rs` (single and faceted) and
1594/// `compose::capture_legend` (PlotGrid-collected legends).
1595/// An inside legend's own bounding box ([`LegendPosition::Inside`] only —
1596/// every other position reserves a dedicated band outside `plot_area`, so
1597/// it can never overlap panel-corner furniture living inside it), without
1598/// drawing anything (#273). `renderer.rs` calls this *before* any geom
1599/// draws, so a corner-furniture geom drawn first (`annotation_scale`/
1600/// `annotation_attribution`/`bivariate_legend`) can still stack clear of
1601/// where the legend — drawn last, see `draw_legend_planned` — will land.
1602/// Mirrors that function's own prefix (`legend_scales_for` →
1603/// `group_mergeable_legends` → `estimate_legend_box` → `legend_position`,
1604/// with `legend_extra_rows` fixed at `0` since `Inside` never reads it)
1605/// so this never disagrees with where the legend is actually drawn.
1606pub(crate) fn inside_legend_box(
1607    scales: &ScaleSet,
1608    theme: &Theme,
1609    plot_area: &Rect,
1610    canvas: &Rect,
1611    guide: &GuideLegend,
1612    suppressed: &std::collections::HashSet<Aesthetic>,
1613    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1614) -> Option<Rect> {
1615    if !matches!(theme.legend_position, LegendPosition::Inside(..)) {
1616        return None;
1617    }
1618    let legend_scales = legend_scales_for(scales, suppressed);
1619    if legend_scales.is_empty() {
1620        return None;
1621    }
1622    let groups = group_mergeable_legends(scales, &legend_scales, guide);
1623    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
1624    let legend_size_hint =
1625        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
1626    let lift = inside_title_lift(scales, &representatives, theme, guide);
1627    let (legend_x, legend_y, _) =
1628        legend_position(theme, plot_area, 0, legend_size_hint, lift, canvas);
1629    // The visible box, title included (#377).
1630    Some(Rect {
1631        x: legend_x,
1632        y: legend_y - lift,
1633        width: legend_size_hint.0,
1634        height: legend_size_hint.1 + lift,
1635    })
1636}
1637
1638#[allow(clippy::too_many_arguments)]
1639pub(crate) fn draw_legend_planned(
1640    scales: &ScaleSet,
1641    theme: &Theme,
1642    plot_area: &Rect,
1643    // Review #167 finding 1: the width share each discrete guide was
1644    // *planned* against (the same `avail_width` passed to
1645    // `plan_all_legends`), not `plot_area.width`. For a single plot these
1646    // are the same value in practice, but a PlotGrid's collected legend
1647    // (`compose::capture_legend`) measures itself against a fake
1648    // zero-width panel so its origin lands at (0, 0) — stepping by
1649    // `plot_area.width / n` there always advanced by zero, so the second
1650    // discrete guide (e.g. `shape` after `colour`) was drawn directly on
1651    // top of the first instead of beside it.
1652    legend_avail_width: f64,
1653    backend: &mut dyn DrawBackend,
1654    guide: &GuideLegend,
1655    suppressed: &std::collections::HashSet<Aesthetic>,
1656    // Review #158 finding 2: each discrete guide's plan, computed exactly
1657    // once in `layout_built` (`BuiltPlot::legend_plans`) — drawn verbatim
1658    // here rather than recomputed against this (possibly shorter, post-
1659    // row-reservation) `plot_area`, so the drawn "+N more" always matches
1660    // the warning that plan already produced.
1661    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1662) -> Result<(), RenderError> {
1663    if matches!(theme.legend_position, LegendPosition::None) {
1664        return Ok(());
1665    }
1666
1667    let legend_scales = legend_scales_for(scales, suppressed);
1668    if legend_scales.is_empty() {
1669        return Ok(());
1670    }
1671    // #410: key glyphs are lines / points outside the panel.
1672    backend.set_clipping(false);
1673    let drawn = draw_legend_planned_inner(
1674        scales,
1675        theme,
1676        plot_area,
1677        legend_avail_width,
1678        backend,
1679        guide,
1680        legend_plans,
1681        legend_scales,
1682    );
1683    backend.set_clipping(true);
1684    drawn
1685}
1686
1687#[allow(clippy::too_many_arguments)]
1688fn draw_legend_planned_inner(
1689    scales: &ScaleSet,
1690    theme: &Theme,
1691    plot_area: &Rect,
1692    legend_avail_width: f64,
1693    backend: &mut dyn DrawBackend,
1694    guide: &GuideLegend,
1695    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1696    legend_scales: Vec<Aesthetic>,
1697) -> Result<(), RenderError> {
1698    // #239: group aesthetics whose legend would be visually identical
1699    // (same title, breaks, discrete/continuous-ness) into one combined
1700    // guide, rather than drawing one per aesthetic. Everything below that
1701    // used to iterate `legend_scales` one aesthetic at a time now
1702    // iterates `groups` instead; a group's first member (`representatives`)
1703    // stands in for the whole group wherever only one scale's own
1704    // breaks/title/discreteness is needed (sizing, the B3-2 wrap plan
1705    // lookup, …) — `group_mergeable_legends` only ever groups scales
1706    // whose `breaks()` already match, so any member would do.
1707    let groups = group_mergeable_legends(scales, &legend_scales, guide);
1708    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
1709
1710    // #233: `legend_position`'s `Top` arm needs the same wrapped-row
1711    // count `layout_built` fed `PlotLayout::compute_with_extras` to
1712    // produce this `plot_area` in the first place — recomputed here from
1713    // `legend_plans` (the exact plans that count came from) the same way
1714    // `layout_built` did, so it can never disagree with the band
1715    // `plot_area.y` was actually reserved against. `is_horizontal`'s
1716    // auto value only depends on the position itself (`Top`/`Bottom`),
1717    // overridable by `legend_direction` same as below — computed first
1718    // since `legend_position` needs it to pick `max` (several guides
1719    // sharing a row) vs. `sum` (several guides stacked), the same
1720    // either/or `layout_built` used.
1721    let is_horizontal_hint = match theme.legend_direction {
1722        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
1723        None => matches!(
1724            theme.legend_position,
1725            LegendPosition::Top | LegendPosition::Bottom
1726        ),
1727    };
1728    let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
1729    // #225: `Inside`'s justification needs the legend's own estimated
1730    // footprint and the full canvas to clamp against — cheap to compute
1731    // unconditionally (ignored by every other position). Estimated from
1732    // `representatives`, not every raw aesthetic, so a merged group's
1733    // height is counted once, not once per aesthetic in it.
1734    let legend_size_hint =
1735        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
1736    let canvas = backend.total_area();
1737    let (legend_x, legend_y, mut is_horizontal) = legend_position(
1738        theme,
1739        plot_area,
1740        legend_extra_rows,
1741        legend_size_hint,
1742        inside_title_lift(scales, &representatives, theme, guide),
1743        &canvas,
1744    );
1745    if let Some(dir) = theme.legend_direction {
1746        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
1747    }
1748    let n_wrap = wrap_guide_count(scales, &representatives);
1749    // #170: match `plan_all_legends`'s share — a colourbar guide (a
1750    // continuous Color/Fill legend) sharing this row has already claimed
1751    // its own fixed width, so step the wrap-planned guides by what's
1752    // left, not the whole `legend_avail_width`. #545: a continuous,
1753    // non-colourbar guide (`Size`/`Linewidth`/…) is one of the
1754    // wrap-planned guides this is split among now, not an unconstrained
1755    // extra.
1756    let n_colorbar = colorbar_guide_count(scales, &representatives);
1757    let colorbar_width = n_colorbar as f64 * colorbar_guide_width(theme);
1758    let per_guide_width = ((legend_avail_width - colorbar_width) / n_wrap as f64).max(0.0);
1759
1760    let mut offset_y = legend_y;
1761    let mut offset_x = legend_x;
1762
1763    for group in &groups {
1764        let representative = &group[0];
1765        let rep_scale = scales.get(representative).unwrap();
1766        let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
1767            .iter()
1768            .map(|a| (a.clone(), scales.get(a).unwrap()))
1769            .collect();
1770
1771        if !rep_scale.is_discrete() && is_colorbar_aesthetic(representative) {
1772            // A true colourbar (continuous Color/Fill) draws as a fixed-
1773            // width bar — not in scope for B3-2's wrap/grid planning.
1774            // Every other continuous legend (Size/Linewidth/… sampled
1775            // breaks, #545) falls through to the same wrap/grid-aware
1776            // path a discrete guide uses, below.
1777            //
1778            // #239: a merged continuous fill+colour pair draws one
1779            // bar — `group_mergeable_legends` only merges scales
1780            // whose breaks (so, in practice, colours) already match,
1781            // so the other member(s) would draw the identical bar
1782            // again.
1783            let height = draw_continuous_legend_at(
1784                rep_scale,
1785                theme,
1786                offset_x,
1787                offset_y,
1788                backend,
1789                guide,
1790                is_horizontal,
1791            )?;
1792            if is_horizontal {
1793                // #211: the bar is now drawn along x (wide, not
1794                // narrow) — step by its own length, not the old
1795                // vertical-bar width-plus-side-label estimate.
1796                let (bar_len, _) = continuous_bar_dims(theme, true);
1797                offset_x += bar_len + theme.legend_spacing * 2.0;
1798            } else {
1799                offset_y += height + theme.legend_spacing * 2.0;
1800            }
1801            continue;
1802        }
1803
1804        // A discrete guide, or a continuous non-colourbar guide (#545:
1805        // `Size`/`Linewidth`/… sampled breaks draw the same way a
1806        // discrete guide's keys do) — both get the same B3-2 wrap/grid
1807        // plan when `plan_all_legends` gave them one.
1808        match legend_plans.get(representative) {
1809            None => {
1810                if is_horizontal {
1811                    let width = draw_discrete_legend_at(
1812                        scales,
1813                        &scale_group,
1814                        theme,
1815                        offset_x,
1816                        offset_y,
1817                        backend,
1818                        guide,
1819                        true,
1820                    )?;
1821                    offset_x += width + theme.legend_spacing * 2.0;
1822                } else {
1823                    let height = draw_discrete_legend_at(
1824                        scales,
1825                        &scale_group,
1826                        theme,
1827                        offset_x,
1828                        offset_y,
1829                        backend,
1830                        guide,
1831                        false,
1832                    )?;
1833                    offset_y += height + theme.legend_spacing * 2.0;
1834                }
1835            }
1836            Some(plan) => {
1837                let height = draw_discrete_legend_grid(
1838                    scales,
1839                    &scale_group,
1840                    theme,
1841                    offset_x,
1842                    offset_y,
1843                    backend,
1844                    guide,
1845                    plan,
1846                )?;
1847                if is_horizontal {
1848                    // Each discrete guide gets an equal share of the row
1849                    // (sipemu/ggplot-rs#155) rather than the whole row,
1850                    // which only held for exactly one discrete guide.
1851                    offset_x += per_guide_width;
1852                } else {
1853                    offset_y += height + theme.legend_spacing * 2.0;
1854                }
1855            }
1856        }
1857    }
1858
1859    Ok(())
1860}
1861
1862/// Draw a discrete legend using a [`LegendPlan`] (explicit grid or
1863/// auto-wrap). Returns the total height used (including any "+N more"
1864/// row).
1865#[allow(clippy::too_many_arguments)]
1866/// Wrapped-grid counterpart of [`draw_discrete_legend_at`] — same `group`
1867/// merging (#239).
1868fn draw_discrete_legend_grid(
1869    scales: &ScaleSet,
1870    group: &[(Aesthetic, &dyn crate::scale::Scale)],
1871    theme: &Theme,
1872    legend_x: f64,
1873    legend_y: f64,
1874    backend: &mut dyn DrawBackend,
1875    guide: &GuideLegend,
1876    plan: &LegendPlan,
1877) -> Result<f64, RenderError> {
1878    let (_, scale) = &group[0];
1879    let mut breaks = scale.breaks();
1880    if breaks.is_empty() {
1881        return Ok(0.0);
1882    }
1883    if guide.reverse {
1884        breaks.reverse();
1885    }
1886
1887    // #236: same key-box growth as `draw_discrete_legend_at`.
1888    let diameter = group
1889        .iter()
1890        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
1891        .fold(0.0_f64, f64::max);
1892    let swatch_size = theme.legend_key_width.max(diameter);
1893    let item_height = theme.legend_key_height.max(swatch_size);
1894
1895    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1896    let legend_family = if theme.legend_title.family.is_empty() {
1897        None
1898    } else {
1899        Some(theme.legend_title.family.clone())
1900    };
1901    let title_offset = if !title.is_empty() {
1902        backend.draw_text(
1903            title,
1904            (legend_x, legend_y),
1905            &TextStyle {
1906                color: theme.legend_title.color,
1907                size: theme.legend_title.size,
1908                anchor: TextAnchor::Start,
1909                angle: 0.0,
1910                family: legend_family,
1911                face: theme.legend_title.face,
1912                bg_colour: None,
1913                bg_r: 0.0,
1914            },
1915        )?;
1916        theme.legend_title.size + 4.0
1917    } else {
1918        0.0
1919    };
1920    let items_y = legend_y + title_offset;
1921
1922    for entry in &plan.entries {
1923        let (pos, label) = &breaks[entry.break_idx];
1924        let x = legend_x + entry.x_offset;
1925        let y = items_y + entry.row as f64 * item_height;
1926        let center_x = x + swatch_size / 2.0;
1927        let center_y = y + swatch_size / 2.0;
1928
1929        if theme.legend_key.visible {
1930            if let Some(fill) = theme.legend_key.fill {
1931                backend.draw_rect(
1932                    (x, y),
1933                    (x + swatch_size, y + swatch_size),
1934                    &RectStyle {
1935                        fill: Some(fill),
1936                        stroke: theme.legend_key.color,
1937                        stroke_width: theme.legend_key.width,
1938                        alpha: 1.0,
1939                        clip: false,
1940                        corner_radius: 0.0,
1941                    },
1942                )?;
1943            }
1944        }
1945
1946        draw_group_keys(
1947            backend,
1948            scales,
1949            group,
1950            theme,
1951            guide,
1952            label,
1953            *pos,
1954            (x, y, center_x, center_y),
1955            swatch_size,
1956        )?;
1957
1958        let label_family = if theme.legend_text.family.is_empty() {
1959            None
1960        } else {
1961            Some(theme.legend_text.family.clone())
1962        };
1963        // #159: a label truncated to fit a vertical multi-column grid
1964        // still shows its full text via a `<title>` tooltip (backends
1965        // without tooltip support just render the truncated text).
1966        let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1967        backend.draw_text_with_title(
1968            display,
1969            (x + swatch_size + theme.legend_spacing, center_y),
1970            &TextStyle {
1971                color: theme.legend_text.color,
1972                size: theme.legend_text.size,
1973                anchor: TextAnchor::Start,
1974                angle: 0.0,
1975                family: label_family,
1976                face: theme.legend_text.face,
1977                bg_colour: None,
1978                bg_r: 0.0,
1979            },
1980            entry.label_override.as_deref().map(|_| label.as_str()),
1981        )?;
1982    }
1983
1984    // The overflow marker is text only — not a mark, no swatch.
1985    if let Some(ov) = &plan.overflow {
1986        let x = legend_x + ov.x_offset;
1987        let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1988        let label_family = if theme.legend_text.family.is_empty() {
1989            None
1990        } else {
1991            Some(theme.legend_text.family.clone())
1992        };
1993        backend.draw_text(
1994            &ov.text,
1995            (x, y),
1996            &TextStyle {
1997                color: theme.legend_text.color,
1998                size: theme.legend_text.size,
1999                anchor: TextAnchor::Start,
2000                angle: 0.0,
2001                family: label_family,
2002                face: theme.legend_text.face,
2003                bg_colour: None,
2004                bg_r: 0.0,
2005            },
2006        )?;
2007    }
2008
2009    Ok(title_offset + plan.rows as f64 * item_height)
2010}
2011
2012#[cfg(test)]
2013mod group_mergeable_legends_tests {
2014    use super::*;
2015    use crate::scale::color::RGBAColor;
2016    use crate::scale::manual::ScaleManual;
2017
2018    #[test]
2019    fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
2020        // #239: in the normal flow, `group_mergeable_legends` is only ever
2021        // handed `legend_scales_for`'s output, which already filters to
2022        // aesthetics present in `scales` — so the `let-else`'s `continue`
2023        // branch (an aesthetic that slipped through with no attached
2024        // scale) is otherwise unreachable from `draw_legend_planned`.
2025        // Exercise it directly: `Fill` is listed but has no scale.
2026        let mut scales = ScaleSet::new();
2027        scales.add(Box::new(ScaleManual::new(
2028            Aesthetic::Color,
2029            vec![("a", RGBAColor::new(200, 0, 0))],
2030        )));
2031        let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
2032        let guide = GuideLegend::default();
2033        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2034        assert_eq!(groups, vec![vec![Aesthetic::Color]]);
2035    }
2036}
2037
2038/// #211: direct unit coverage for the two private helpers
2039/// `draw_continuous_legend_at`'s vertical/horizontal split relies on —
2040/// exercised indirectly by every colourbar-drawing integration test
2041/// already, but called here directly too so their own branches/math
2042/// are pinned regardless of how the compiler happens to inline them.
2043#[cfg(test)]
2044mod continuous_bar_dims_tests {
2045    use super::*;
2046
2047    #[test]
2048    fn dims_swap_between_vertical_and_horizontal() {
2049        let theme = Theme::default();
2050        let (w, h) = continuous_bar_dims(&theme, false);
2051        assert_eq!(w, theme.legend_key_width);
2052        assert_eq!(h, theme.legend_key_height * 8.0);
2053
2054        let (w, h) = continuous_bar_dims(&theme, true);
2055        assert_eq!(w, theme.legend_key_height * 8.0);
2056        assert_eq!(h, theme.legend_key_width);
2057    }
2058
2059    #[test]
2060    fn horizontal_bar_height_adds_a_title_line_only_when_there_is_a_title() {
2061        let theme = Theme::default();
2062        let without_title = continuous_horizontal_bar_height(&theme, false);
2063        let with_title = continuous_horizontal_bar_height(&theme, true);
2064        assert!(with_title > without_title);
2065        assert!(((with_title - without_title) - (theme.legend_title.size + 4.0)).abs() < 1e-9);
2066
2067        // Matches `continuous_bar_dims`'s own horizontal thickness plus
2068        // the fixed tick/spacing/label overhead — not just a positive
2069        // number, the exact formula `draw_continuous_legend_at` relies
2070        // on to never disagree with what `plot.rs` reserves for it.
2071        let (_, thickness) = continuous_bar_dims(&theme, true);
2072        let tick_len = 3.0;
2073        let label_line = theme.legend_text.size + 4.0;
2074        assert!(
2075            (without_title - (thickness + tick_len + theme.legend_spacing + label_line)).abs()
2076                < 1e-9
2077        );
2078    }
2079}
2080
2081/// #273: `inside_legend_box` — the pre-draw measurement `renderer.rs` uses
2082/// to claim an inside legend's box before any corner-furniture geom draws.
2083#[cfg(test)]
2084mod inside_legend_box_tests {
2085    use super::*;
2086    use crate::scale::color::RGBAColor;
2087    use crate::scale::manual::ScaleManual;
2088
2089    fn scales_with_a_legend() -> ScaleSet {
2090        let mut scales = ScaleSet::new();
2091        scales.add(Box::new(ScaleManual::new(
2092            Aesthetic::Color,
2093            vec![
2094                ("a", RGBAColor::new(200, 0, 0)),
2095                ("b", RGBAColor::new(0, 0, 200)),
2096            ],
2097        )));
2098        scales
2099    }
2100
2101    fn areas() -> (Rect, Rect) {
2102        let plot_area = Rect {
2103            x: 10.0,
2104            y: 10.0,
2105            width: 200.0,
2106            height: 150.0,
2107        };
2108        let canvas = Rect {
2109            x: 0.0,
2110            y: 0.0,
2111            width: 220.0,
2112            height: 170.0,
2113        };
2114        (plot_area, canvas)
2115    }
2116
2117    #[test]
2118    fn non_inside_positions_claim_nothing() {
2119        let scales = scales_with_a_legend();
2120        let (plot_area, canvas) = areas();
2121        let guide = GuideLegend::default();
2122        let suppressed = std::collections::HashSet::new();
2123        let legend_plans = std::collections::HashMap::new();
2124        for pos in [
2125            LegendPosition::Right,
2126            LegendPosition::Left,
2127            LegendPosition::Top,
2128            LegendPosition::Bottom,
2129            LegendPosition::None,
2130        ] {
2131            let theme = Theme {
2132                legend_position: pos,
2133                ..Theme::default()
2134            };
2135            assert!(inside_legend_box(
2136                &scales,
2137                &theme,
2138                &plot_area,
2139                &canvas,
2140                &guide,
2141                &suppressed,
2142                &legend_plans
2143            )
2144            .is_none());
2145        }
2146    }
2147
2148    #[test]
2149    fn no_legend_scales_claims_nothing_even_when_inside() {
2150        let scales = ScaleSet::new(); // nothing mapped to a legend aesthetic
2151        let (plot_area, canvas) = areas();
2152        let theme = Theme {
2153            legend_position: LegendPosition::Inside(0.85, 0.85),
2154            ..Theme::default()
2155        };
2156        let guide = GuideLegend::default();
2157        let suppressed = std::collections::HashSet::new();
2158        let legend_plans = std::collections::HashMap::new();
2159        assert!(inside_legend_box(
2160            &scales,
2161            &theme,
2162            &plot_area,
2163            &canvas,
2164            &guide,
2165            &suppressed,
2166            &legend_plans
2167        )
2168        .is_none());
2169    }
2170
2171    #[test]
2172    fn inside_position_with_a_legend_claims_a_box_within_the_panel() {
2173        let scales = scales_with_a_legend();
2174        let (plot_area, canvas) = areas();
2175        let theme = Theme {
2176            legend_position: LegendPosition::Inside(0.85, 0.85),
2177            ..Theme::default()
2178        };
2179        let guide = GuideLegend::default();
2180        let suppressed = std::collections::HashSet::new();
2181        let legend_plans = std::collections::HashMap::new();
2182        let rect = inside_legend_box(
2183            &scales,
2184            &theme,
2185            &plot_area,
2186            &canvas,
2187            &guide,
2188            &suppressed,
2189            &legend_plans,
2190        )
2191        .expect("an Inside legend with a mapped scale claims a box");
2192        assert!(rect.width > 0.0 && rect.height > 0.0);
2193        assert!(rect.x >= canvas.x - 1e-6 && rect.x + rect.width <= canvas.x + canvas.width + 1e-6);
2194        assert!(
2195            rect.y >= canvas.y - 1e-6 && rect.y + rect.height <= canvas.y + canvas.height + 1e-6
2196        );
2197    }
2198
2199    #[test]
2200    fn matches_the_position_draw_legend_planned_itself_uses() {
2201        // Mirrors `draw_legend_planned`'s own prefix (`legend_scales_for`
2202        // → `group_mergeable_legends` → `estimate_legend_box` →
2203        // `legend_position`) — this must agree with it, or a geom drawn
2204        // before the legend could stack clear of the wrong spot.
2205        let scales = scales_with_a_legend();
2206        let (plot_area, canvas) = areas();
2207        let theme = Theme {
2208            legend_position: LegendPosition::Inside(0.85, 0.85),
2209            ..Theme::default()
2210        };
2211        let guide = GuideLegend::default();
2212        let suppressed = std::collections::HashSet::new();
2213        let legend_plans = std::collections::HashMap::new();
2214
2215        let rect = inside_legend_box(
2216            &scales,
2217            &theme,
2218            &plot_area,
2219            &canvas,
2220            &guide,
2221            &suppressed,
2222            &legend_plans,
2223        )
2224        .unwrap();
2225
2226        let legend_scales = legend_scales_for(&scales, &suppressed);
2227        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2228        let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2229        let size_hint =
2230            estimate_legend_box(&scales, &representatives, &theme, &guide, &legend_plans);
2231        let lift = inside_title_lift(&scales, &representatives, &theme, &guide);
2232        let (x, y, _) = legend_position(&theme, &plot_area, 0, size_hint, lift, &canvas);
2233
2234        // The visible box: the title's upper half above the origin (#377).
2235        assert_eq!(
2236            (rect.x, rect.y, rect.width, rect.height),
2237            (x, y - lift, size_hint.0, size_hint.1 + lift)
2238        );
2239    }
2240}