Skip to main content

gpui_kit_grid/state/
groups.rs

1//! Group captions on screen: a caption line of the frame, its texts from the
2//! catalog, what the `group_caption` hook knows, and where a press on a
3//! caption lands.
4//!
5//! A caption is the header of its rows (`table_head`, no stripes). Its
6//! chevron and text sit in a lane of their own over the zones, at the
7//! body's left edge, so they read at any horizontal scroll; the aggregates
8//! sit under their columns, in their zones, and scroll with them. The text
9//! ends at the first aggregate cell on screen that starts at least
10//! [`CAPTION_MIN_WIDTH`] past it; aggregates left of that are not painted
11//! on the caption.
12//!
13//! What changed shows on a caption, expanded or collapsed: an aggregate
14//! that changed in its cell, a new group or a new row count in the caption
15//! lane, from the projection's stamps through the change paint, as a data
16//! cell's change.
17
18use std::{fmt::Write as _, marker::PhantomData, ops::Range};
19
20use gpui_kit::{Bounds, FontWeight, Hsla, Pixels, Point, SharedString, Window, point, px, size};
21use rust_i18n::t;
22
23use super::{
24    CELL_PADDING, CellStyle, ColumnAlign, LiveGridDelegate, LiveGridState, ViewColumnIx,
25    a11y::{A11yTexts, PaintedRow, RowContent, RowOutline},
26    change::{ChangePainter as _, stamp_change},
27    disclosure::INDENT,
28    frame::{
29        CellText, CellTextContext, LEAD_ICON_LANE, RowPlanner, focus_outline, place_cell_text,
30    },
31    group_hash,
32    style::{AggregateLine, AggregateStyleContext},
33    tooltip::{CellTooltip, HoveredCell},
34};
35use crate::{
36    element::{IconItem, Region},
37    engine::{Aggregate, GroupInfo, GroupPath},
38    highlight::Stamp,
39    model::{
40        CellValue, ColumnIx, GridColumns as _, GridRow, OwnedValue, display_text, display_text_in,
41    },
42};
43
44/// The least room a caption's text keeps before the first aggregate cell
45/// painted on its line.
46pub(super) const CAPTION_MIN_WIDTH: Pixels = px(120.);
47
48/// The room on each side of a caption tag's text.
49const TAG_PADDING: Pixels = px(5.);
50/// The corner radius of a caption tag.
51const TAG_RADIUS: Pixels = px(3.);
52/// The gap between a caption tag and the caption's text.
53const TAG_GAP: Pixels = px(6.);
54/// A caption tag's height, as a share of the row height: 18 px in 26 px
55/// rows, 22 px in 32 px rows.
56const TAG_HEIGHT: f32 = 0.68;
57
58/// A tag before a group caption's text: a pill in the application's
59/// colours, such as a transit line's, whose text
60/// [`LiveGridDelegate::group_caption_tag`] writes.
61///
62/// [`LiveGridDelegate::group_caption_tag`]: super::LiveGridDelegate::group_caption_tag
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct CaptionTag {
65    background: Hsla,
66    foreground: Hsla,
67    font_weight: Option<FontWeight>,
68    min_width: Pixels,
69}
70
71impl CaptionTag {
72    /// A tag of `foreground` text on `background`, in the grid's font, as
73    /// wide as its text.
74    pub fn new(background: Hsla, foreground: Hsla) -> Self {
75        Self {
76            background,
77            foreground,
78            font_weight: None,
79            min_width: px(0.),
80        }
81    }
82
83    /// Sets the weight of the tag's text, such as `FontWeight::BOLD`.
84    pub fn with_font_weight(mut self, weight: FontWeight) -> Self {
85        self.font_weight = Some(weight);
86        self
87    }
88
89    /// Sets the least width of the tag: tags of short texts line up. The
90    /// text is centred in it.
91    pub fn with_min_width(mut self, width: Pixels) -> Self {
92        self.min_width = width;
93        self
94    }
95
96    /// The tag's colour.
97    pub fn background(&self) -> Hsla {
98        self.background
99    }
100
101    /// The colour of the tag's text.
102    pub fn foreground(&self) -> Hsla {
103        self.foreground
104    }
105
106    /// The weight of the tag's text; `None` is the grid's font.
107    pub fn font_weight(&self) -> Option<FontWeight> {
108        self.font_weight
109    }
110
111    /// The least width of the tag.
112    pub fn min_width(&self) -> Pixels {
113        self.min_width
114    }
115}
116
117/// The tooltip slot of a caption's text: no column has this index.
118const CAPTION_COLUMN: ColumnIx = ColumnIx::new(u16::MAX);
119
120/// The element id name of a caption text's tooltip div (`NamedInteger(this,
121/// group_hash)`).
122const CAPTION_TOOLTIP_ID: &str = "live-grid-group";
123
124/// The colours of a caption: the header's.
125#[derive(Clone, Copy, Debug)]
126pub(super) struct CaptionPalette {
127    pub(super) background: Hsla,
128    pub(super) foreground: Hsla,
129    pub(super) chevron: Hsla,
130}
131
132/// A part of a catalog template.
133#[derive(Clone, Debug, PartialEq, Eq)]
134enum Part {
135    Text(Range<usize>),
136    Title,
137    Value,
138    Count,
139    Name,
140}
141
142/// A catalog text with `%{…}` placeholders, parsed once: filling it writes
143/// into a buffer and allocates nothing more.
144#[derive(Clone, Debug, Default)]
145struct Template {
146    text: String,
147    parts: Vec<Part>,
148}
149
150impl Template {
151    fn parse(text: String) -> Self {
152        let mut parts = Vec::new();
153        let mut literal = 0;
154        let mut rest = 0;
155        while let Some(start) = text[rest..].find("%{").map(|at| rest + at) {
156            let Some(end) = text[start..].find('}').map(|at| start + at) else {
157                break;
158            };
159            let part = match &text[start + 2..end] {
160                "title" => Part::Title,
161                "value" => Part::Value,
162                "count" => Part::Count,
163                "name" => Part::Name,
164                // An unknown placeholder stays as text.
165                _ => {
166                    rest = end + 1;
167                    continue;
168                }
169            };
170            if start > literal {
171                parts.push(Part::Text(literal..start));
172            }
173            parts.push(part);
174            literal = end + 1;
175            rest = end + 1;
176        }
177        if literal < text.len() {
178            parts.push(Part::Text(literal..text.len()));
179        }
180        Self { text, parts }
181    }
182
183    /// Writes the template into `out`, each placeholder by `fill`.
184    fn write(&self, out: &mut String, mut fill: impl FnMut(&Part, &mut String)) {
185        for part in &self.parts {
186            match part {
187                Part::Text(range) => out.push_str(&self.text[range.clone()]),
188                part => fill(part, out),
189            }
190        }
191    }
192}
193
194/// The texts of captions, aggregates and expansion states, from this crate's
195/// catalog: read once per locale change, never per frame or cell.
196#[derive(Debug, Default)]
197pub(crate) struct CaptionTexts {
198    /// The locale the texts are of; empty before the first read.
199    locale: String,
200    caption: Template,
201    blank: SharedString,
202    yes: SharedString,
203    no: SharedString,
204    sum: SharedString,
205    avg: SharedString,
206    min: SharedString,
207    max: SharedString,
208    count: SharedString,
209    tooltip: Template,
210    a11y: A11yTexts,
211}
212
213impl CaptionTexts {
214    /// Reads the texts again if the locale changed since the last read.
215    pub(super) fn refresh(&mut self) {
216        let locale = rust_i18n::locale();
217        if *locale == *self.locale {
218            return;
219        }
220        let text = |key: &str| SharedString::from(t!(key, locale = &*locale).into_owned());
221        *self = Self {
222            locale: locale.to_string(),
223            caption: Template::parse(t!("live_grid.group.caption", locale = &*locale).into_owned()),
224            blank: text("live_grid.group.blank"),
225            yes: text("live_grid.group.true"),
226            no: text("live_grid.group.false"),
227            sum: text("live_grid.aggregate.sum"),
228            avg: text("live_grid.aggregate.avg"),
229            min: text("live_grid.aggregate.min"),
230            max: text("live_grid.aggregate.max"),
231            count: text("live_grid.aggregate.count"),
232            tooltip: Template::parse(
233                t!("live_grid.aggregate.tooltip", locale = &*locale).into_owned(),
234            ),
235            a11y: A11yTexts {
236                rising: text("live_grid.a11y.up"),
237                falling: text("live_grid.a11y.down"),
238                expanded: text("live_grid.a11y.expanded"),
239                collapsed: text("live_grid.a11y.collapsed"),
240                link: text("live_grid.a11y.link"),
241            },
242        };
243    }
244
245    /// The texts of the accessibility plan.
246    pub(super) fn a11y(&self) -> &A11yTexts {
247        &self.a11y
248    }
249
250    /// The text of a group's key value: as a cell shows it, with "(Blank)"
251    /// for an empty key and Yes/No for a boolean.
252    pub(super) fn value_text<'a>(
253        &'a self,
254        value: CellValue<'a>,
255        buffer: &'a mut String,
256    ) -> &'a str {
257        match value {
258            CellValue::Empty => &self.blank,
259            CellValue::Bool(true) => &self.yes,
260            CellValue::Bool(false) => &self.no,
261            value => match display_text(value, buffer) {
262                "" => &self.blank,
263                text => text,
264            },
265        }
266    }
267
268    /// The name of an aggregate: the catalog's for a built-in one,
269    /// `AggregateFn::name` for a custom one.
270    pub(super) fn aggregate_name<'a, R: GridRow>(&'a self, aggregate: &'a Aggregate<R>) -> &'a str {
271        match aggregate {
272            Aggregate::Sum => &self.sum,
273            Aggregate::Avg => &self.avg,
274            Aggregate::Min => &self.min,
275            Aggregate::Max => &self.max,
276            Aggregate::Count => &self.count,
277            Aggregate::Custom(function) => function.name(),
278        }
279    }
280
281    /// The tooltip of an aggregate cell: "Sum: 1234.00"; the value alone
282    /// for an aggregate without a name.
283    pub(super) fn aggregate_tooltip(&self, name: &str, value: &str) -> SharedString {
284        if name.is_empty() {
285            return SharedString::from(value.to_owned());
286        }
287        let mut out = String::new();
288        self.tooltip.write(&mut out, |part, out| match part {
289            Part::Name => out.push_str(name),
290            Part::Value => out.push_str(value),
291            _ => {}
292        });
293        out.into()
294    }
295}
296
297/// What [`LiveGridDelegate::group_caption`] knows about a visible caption.
298///
299/// [`LiveGridDelegate::group_caption`]: super::LiveGridDelegate::group_caption
300pub struct GroupCaptionContext<'a, R: GridRow> {
301    group: GroupInfo<'a>,
302    title: &'a str,
303    value_text: &'a str,
304    texts: &'a CaptionTexts,
305    row: PhantomData<fn() -> R>,
306}
307
308impl<'a, R: GridRow> GroupCaptionContext<'a, R> {
309    /// The group, in the projection on screen.
310    pub fn group(&self) -> GroupInfo<'a> {
311        self.group
312    }
313
314    /// The title of the group's level: its column's title, or the
315    /// `GroupKey` title.
316    pub fn title(&self) -> &'a str {
317        self.title
318    }
319
320    /// The group's key value.
321    pub fn value(&self) -> &'a OwnedValue {
322        self.group.value()
323    }
324
325    /// The text of the key value as the default caption writes it: as a
326    /// cell shows it, "(Blank)" for an empty key, Yes/No for a boolean.
327    pub fn value_text(&self) -> &'a str {
328        self.value_text
329    }
330
331    /// The group's live rows, its subgroups' included.
332    pub fn row_count(&self) -> usize {
333        self.group.row_count()
334    }
335
336    /// The group's aggregate of `column`, if the view aggregates it.
337    pub fn aggregate(&self, column: R::Column) -> Option<&'a OwnedValue> {
338        self.group.aggregates().get(column.ix())
339    }
340
341    /// Writes the default caption, the catalog's `live_grid.group.caption`:
342    /// "Instrument: AAPL (120)".
343    pub fn default_caption(&self, out: &mut String) {
344        let (title, value, count) = (self.title, self.value_text, self.row_count());
345        self.texts.caption.write(out, |part, out| match part {
346            Part::Title => out.push_str(title),
347            Part::Value => out.push_str(value),
348            Part::Count => {
349                let _ = write!(out, "{count}");
350            }
351            _ => {}
352        });
353    }
354}
355
356/// A caption line to plan.
357pub(super) struct PlannedCaption<'a> {
358    pub(super) group: GroupInfo<'a>,
359    pub(super) depth: u16,
360    /// The top edge in window coordinates.
361    pub(super) y: Pixels,
362    pub(super) display_ix: usize,
363    /// The title of the group's level.
364    pub(super) title: &'a str,
365    /// By the view's expansion: the chevron turns in the frame after the
366    /// press, before the lines follow.
367    pub(super) expanded: bool,
368    pub(super) focused: bool,
369    /// The pointer's x, if it is over this line and a tooltip may show.
370    pub(super) hover_x: Option<Pixels>,
371    /// The change stamps of the group's aggregates by `ColumnIx`, while any
372    /// is hot (`Projection::group_stamps`).
373    pub(super) stamps: Option<&'a [Stamp]>,
374    /// The caption's own stamp: a new group, a new row count
375    /// (`Projection::caption_stamp`).
376    pub(super) stamp: Option<Stamp>,
377}
378
379impl<D: LiveGridDelegate> RowPlanner<'_, D> {
380    /// Plans a caption line: the background across the zones and the row
381    /// line; in the caption lane the caption's change, the chevron, the
382    /// hook's text and the focus outline; in the zones the aggregate cells
383    /// right of the text with their changes. While the accessibility tree is
384    /// built, the caption row with its level and state, its text cell and
385    /// its aggregate cells.
386    pub(super) fn plan_caption(
387        &mut self,
388        caption: &PlannedCaption<'_>,
389        regions: &mut [Region; 3],
390        lane: &mut Region,
391        window: &mut Window,
392    ) {
393        let (y, row_height) = (caption.y, self.row_height);
394        let group = caption.group;
395        let key_hash = group_hash(group.path());
396        let aggregates = group.aggregates();
397        let palette = self.captions.palette;
398        let body_x = self.captions.body_x;
399        let row_right = self.captions.row_right;
400        for (slot, region) in regions.iter_mut().enumerate() {
401            let row_x = region.mask().origin.x;
402            let row_width = self.row_extents[slot];
403            region.quads.push((
404                Bounds::new(point(row_x, y), size(row_width, row_height)),
405                palette.background,
406            ));
407            region.lines.push((
408                Bounds::new(
409                    point(row_x, y + row_height - px(1.)),
410                    size(row_width, px(1.)),
411                ),
412                self.palette.row_border,
413            ));
414        }
415
416        // The chevron lane, then the text; the text stops at the first
417        // aggregate cell far enough right of it.
418        let lane_x = body_x + CELL_PADDING + INDENT * f32::from(caption.depth);
419        let text_x = lane_x + LEAD_ICON_LANE;
420        let threshold = text_x + CAPTION_MIN_WIDTH;
421        let visible_x = |slot: usize, x: Pixels| x.max(regions[slot].mask().origin.x);
422        let mut caption_right = row_right;
423        let mut end = None;
424        for (slot, range) in self.zone_ranges.iter().enumerate() {
425            for planned in &self.planned[range.clone()] {
426                let x = visible_x(slot, planned.x);
427                if aggregates.get(planned.column.ix()).is_some()
428                    && x >= threshold
429                    && x < caption_right
430                {
431                    caption_right = x;
432                    end = Some(planned.view);
433                }
434            }
435        }
436        // A new group or a new row count tints the lane from the caption's
437        // indent to its end, in the change paint.
438        if let Some(change) = caption
439            .stamp
440            .and_then(|stamp| stamp_change(stamp, None, &self.policy, self.tick))
441        {
442            self.any_hot = true;
443            let mut style = CellStyle::default();
444            self.change_paint
445                .paint(&change, &self.change_palette, &mut style);
446            let left = lane_x - CELL_PADDING;
447            if let Some(background) = style.background
448                && caption_right > left
449            {
450                lane.quads.push((
451                    Bounds::new(point(left, y), size(caption_right - left, row_height)),
452                    background,
453                ));
454            }
455        }
456        let chevron = self.captions.chevron_size;
457        lane.icons.push(IconItem {
458            bounds: Bounds::new(
459                point(
460                    lane_x + (LEAD_ICON_LANE - chevron) / 2.,
461                    y + (row_height - chevron) / 2.,
462                ),
463                size(chevron, chevron),
464            ),
465            path: self.captions.chevrons.path(caption.expanded),
466            color: palette.chevron,
467        });
468        self.icons += 1;
469
470        let captions = &mut self.captions;
471        captions.caption.clear();
472        captions.tag.clear();
473        let value_text = captions
474            .texts
475            .value_text(group.value().as_cell(), &mut captions.value);
476        let context = GroupCaptionContext {
477            group,
478            title: caption.title,
479            value_text,
480            texts: captions.texts,
481            row: PhantomData,
482        };
483        self.delegate
484            .group_caption(&context, &mut captions.caption, self.app);
485        let tag = self
486            .delegate
487            .group_caption_tag(&context, &mut captions.tag, self.app)
488            .filter(|_| !captions.tag.is_empty());
489        // The tag, a pill before the text, if it fits before the first
490        // aggregate.
491        let mut text_start = text_x;
492        if let Some(tag) = tag {
493            let width = self
494                .text_cache
495                .for_weight(tag.font_weight)
496                .layout(&captions.tag, window, &mut self.glyphs)
497                .width();
498            let pill_width = (width + TAG_PADDING * 2.).max(tag.min_width);
499            let pill_height = (row_height * TAG_HEIGHT).round();
500            if text_x + pill_width <= caption_right {
501                lane.pills.push((
502                    Bounds::new(
503                        point(text_x, y + (row_height - pill_height) / 2.),
504                        size(pill_width, pill_height),
505                    ),
506                    tag.background,
507                    TAG_RADIUS,
508                ));
509                place_cell_text(
510                    CellText {
511                        text: &captions.tag,
512                        x: text_x + (pill_width - width) / 2. - CELL_PADDING,
513                        // A pixel more: the text is never cut by rounding.
514                        width: width + CELL_PADDING * 2. + px(1.),
515                        y,
516                        right: false,
517                        lead: None,
518                        trend: None,
519                        weight: tag.font_weight,
520                        color: tag.foreground,
521                    },
522                    &mut CellTextContext {
523                        text_cache: &mut *self.text_cache,
524                        glyphs: &mut self.glyphs,
525                        arrows: &self.arrows,
526                        row_height,
527                    },
528                    lane,
529                    window,
530                );
531                text_start = text_x + pill_width + TAG_GAP;
532            }
533        }
534        let lane_width = (caption_right - body_x).max(px(0.));
535        let (truncated, _) = place_cell_text(
536            CellText {
537                text: &captions.caption,
538                x: text_start - CELL_PADDING,
539                width: (caption_right - text_start + CELL_PADDING).max(px(0.)),
540                y,
541                right: false,
542                lead: None,
543                trend: None,
544                weight: None,
545                color: palette.foreground,
546            },
547            &mut CellTextContext {
548                text_cache: &mut *self.text_cache,
549                glyphs: &mut self.glyphs,
550                arrows: &self.arrows,
551                row_height,
552            },
553            lane,
554            window,
555        );
556        let lane_bounds = Bounds::new(point(body_x, y), size(lane_width, row_height));
557        if let Some(a11y) = &mut self.a11y {
558            a11y.push_outlined_row(
559                PaintedRow {
560                    pinned: None,
561                    position: caption.display_ix,
562                    key_hash,
563                    y,
564                    height: row_height,
565                    selected: false,
566                },
567                RowOutline {
568                    content: RowContent::Caption,
569                    level: Some(caption.depth.saturating_add(1)),
570                    expanded: Some(caption.expanded),
571                },
572            );
573            let end = end.unwrap_or(ViewColumnIx::new(captions.visible_columns as u16));
574            if tag.is_some() {
575                // The tag reads first: "U1 4 under way".
576                let label = format!("{} {}", captions.tag, captions.caption);
577                a11y.push_caption(&label, end, lane_bounds);
578            } else {
579                a11y.push_caption(&captions.caption, end, lane_bounds);
580            }
581            if caption.focused && self.a11y_focus {
582                a11y.set_caption_focus(key_hash, None);
583            }
584        }
585        if let Some(hover_x) = caption.hover_x
586            && hover_x >= body_x
587            && hover_x < caption_right
588            && truncated
589        {
590            self.hovered = Some(HoveredCell {
591                key_hash,
592                column: CAPTION_COLUMN,
593                id: CAPTION_TOOLTIP_ID,
594                bounds: lane_bounds.intersect(&lane.mask()),
595                tooltip: Some(CellTooltip::text(SharedString::from(
596                    captions.caption.clone(),
597                ))),
598            });
599        }
600        if caption.focused && self.focus_visible {
601            let row = Bounds::new(
602                point(body_x, y),
603                size((row_right - body_x).max(px(0.)), row_height),
604            );
605            lane.outline
606                .extend(focus_outline(row).map(|bounds| (bounds, self.palette.ring)));
607        }
608
609        // The aggregate cells, as cells of their columns: change paint (from
610        // the projection's marks), then the delegate's style.
611        for (slot, region) in regions.iter_mut().enumerate() {
612            for column_slot in self.zone_ranges[slot].clone() {
613                let planned = &self.planned[column_slot];
614                let column = planned.column;
615                let Some(owned) = aggregates.get(column.ix()) else {
616                    continue;
617                };
618                let value = owned.as_cell();
619                let text = display_text_in(value, planned.number_format, &mut self.buffer);
620                let name = self
621                    .captions
622                    .aggregates
623                    .iter()
624                    .find(|(aggregated, _)| *aggregated == column)
625                    .map_or("", |(_, aggregate)| {
626                        self.captions.texts.aggregate_name(aggregate)
627                    });
628                if let Some(a11y) = &mut self.a11y {
629                    a11y.push_aggregate_cell(column_slot, text, name);
630                }
631                if planned.x.max(region.mask().origin.x) < threshold {
632                    continue;
633                }
634                // The change paint first, then the delegate's style, as a
635                // data cell's.
636                let change = caption
637                    .stamps
638                    .and_then(|stamps| stamps.get(column.ix().get()))
639                    .and_then(|stamp| {
640                        stamp_change(*stamp, Some(column.ix()), &self.policy, self.tick)
641                    });
642                let mut style = CellStyle::default();
643                if let Some(change) = &change {
644                    self.any_hot = true;
645                    self.change_paint
646                        .paint(change, &self.change_palette, &mut style);
647                }
648                self.delegate.aggregate_style(
649                    &AggregateStyleContext {
650                        line: AggregateLine::Caption(group),
651                        column,
652                        value: Some(value),
653                        change,
654                        focused: caption.focused,
655                    },
656                    &mut style,
657                    self.app,
658                );
659                // The arrow's direction in the cell's description.
660                if let (Some(a11y), Some(trend)) = (&mut self.a11y, style.trend) {
661                    a11y.set_trend(trend);
662                }
663                let cell_bounds = Bounds::new(point(planned.x, y), size(planned.width, row_height));
664                if let Some(background) = style.background {
665                    region.quads.push((cell_bounds, background));
666                }
667                let right = match planned.align {
668                    ColumnAlign::End => true,
669                    ColumnAlign::Start => false,
670                    ColumnAlign::Auto => value.is_number(),
671                };
672                let (_, icons) = place_cell_text(
673                    CellText {
674                        text,
675                        x: planned.x,
676                        width: planned.width,
677                        y,
678                        right,
679                        lead: None,
680                        trend: style.trend,
681                        weight: style.font_weight,
682                        color: style.foreground.unwrap_or(palette.foreground),
683                    },
684                    &mut CellTextContext {
685                        text_cache: &mut *self.text_cache,
686                        glyphs: &mut self.glyphs,
687                        arrows: &self.arrows,
688                        row_height,
689                    },
690                    region,
691                    window,
692                );
693                self.icons += icons;
694                self.cells += 1;
695                if let Some(hover_x) = caption.hover_x
696                    && (planned.x..planned.x + planned.width).contains(&hover_x)
697                    && hover_x >= region.mask().origin.x
698                {
699                    self.hovered = Some(HoveredCell {
700                        key_hash,
701                        column: column.ix(),
702                        id: column.id(),
703                        bounds: cell_bounds.intersect(&region.mask()),
704                        tooltip: Some(CellTooltip::text(
705                            self.captions.texts.aggregate_tooltip(name, text),
706                        )),
707                    });
708                }
709            }
710        }
711        self.captions.painted += 1;
712    }
713}
714
715impl<D: LiveGridDelegate> LiveGridState<D> {
716    /// The painted caption under a window position, and the column of the
717    /// aggregate cell painted under it, if any.
718    pub(super) fn caption_hit(
719        &self,
720        position: Point<Pixels>,
721    ) -> Option<(GroupPath, Option<ColumnIx>)> {
722        let path = self.painted.get(self.line_at(position)?)?.group()?.clone();
723        let threshold = self.caption_lane_x(path.depth()) + LEAD_ICON_LANE + CAPTION_MIN_WIDTH;
724        let column = self.column_at_position(position).filter(|ix| {
725            let has_aggregate = self
726                .projection
727                .groups()
728                .find(&path)
729                .is_some_and(|group| group.aggregates().get(*ix).is_some());
730            has_aggregate && self.cell_visible_x(*ix).is_some_and(|x| x >= threshold)
731        });
732        Some((path, column))
733    }
734
735    /// The window x where a visible column's cell shows: its left edge, or
736    /// its zone's for a cell partly under a frozen zone.
737    fn cell_visible_x(&self, column: ColumnIx) -> Option<Pixels> {
738        let geometry = self
739            .columns
740            .geometry(self.body_bounds.size.width, self.scroll_x);
741        let (zone, i) = self.columns.zone_position(column)?;
742        let frame = geometry.zone(zone);
743        let x = frame
744            .column_x(self.columns.zone_columns(zone), i)
745            .max(frame.x);
746        Some(self.body_bounds.origin.x + x)
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::{Part, Template};
753
754    #[test]
755    fn test_caption_template() {
756        let template = Template::parse("%{title}: %{value} (%{count})".to_owned());
757        assert_eq!(
758            template.parts,
759            [
760                Part::Title,
761                Part::Text(8..10),
762                Part::Value,
763                Part::Text(18..20),
764                Part::Count,
765                Part::Text(28..29),
766            ]
767        );
768        let mut out = String::new();
769        template.write(&mut out, |part, out| {
770            out.push_str(match part {
771                Part::Title => "Instrument",
772                Part::Value => "AAPL",
773                Part::Count => "120",
774                _ => "?",
775            })
776        });
777        assert_eq!(out, "Instrument: AAPL (120)");
778        // An unknown placeholder and an unclosed one stay as text.
779        let template = Template::parse("%{other} %{value} %{".to_owned());
780        let mut out = String::new();
781        template.write(&mut out, |_, out| out.push_str("v"));
782        assert_eq!(out, "%{other} v %{");
783    }
784}