Skip to main content

gpui_kit_grid/state/
summary.rs

1//! Totals and group footers on screen: where the totals row is shown, how a
2//! summary line is planned, what a press on one targets, and what autofit
3//! measures of them.
4//!
5//! A summary line shows aggregates of the projection on screen: the totals
6//! row those of the whole view (`Projection::totals`), a group footer its
7//! group's. Both read as the band's footer (`table_foot`, no stripes); each
8//! aggregate sits under its column, in its zone, and scrolls sideways with
9//! it. The totals row is the outermost line of its pinned band, in the
10//! semibold weight by default, with the label "Total" in the first visible
11//! column when that column has no aggregate. A footer follows its group's
12//! lines in the data rows, in the grid's font, without a label: its group's
13//! caption above names it. Neither takes the focus or the selection.
14
15use gpui_kit::{
16    App, Bounds, Context, FontWeight, Hsla, Pixels, Point, SharedString, Window, point, px, size,
17};
18use rust_i18n::t;
19
20use super::{
21    CellChange, CellStyle, ColumnAlign, ColumnOf, KeyOf, LiveGridDelegate, LiveGridState,
22    ViewColumnIx,
23    a11y::{PaintedRow, RowOutline},
24    change::ChangePainter as _,
25    columns::column_of,
26    frame::{CellText, CellTextContext, RowPlanner, place_cell_text},
27    menu::GridTarget,
28    pinned::BodyLine,
29    style::{AggregateLine, AggregateStyleContext},
30    tooltip::{CellTooltip, HoveredCell},
31};
32use crate::{
33    element::Region,
34    engine::{AggregateRow, GroupPath},
35    highlight::Stamp,
36    model::{ColumnIx, GridColumns as _, NumberFormat, OwnedValue, display_text_in},
37    store::PinnedEdge,
38    text::{GlyphArena, TextCaches},
39};
40
41/// The weight of the totals row unless `aggregate_style` sets another.
42/// Footers keep the grid's font: a subtotal reads quieter than the total.
43pub(super) const TOTALS_WEIGHT: FontWeight = FontWeight::SEMIBOLD;
44
45/// The label of the totals row, from this crate's catalog once per locale
46/// change, never per frame.
47#[derive(Debug, Default)]
48pub(crate) struct SummaryTexts {
49    /// The locale the texts are of; empty before the first read.
50    locale: String,
51    total: SharedString,
52}
53
54impl SummaryTexts {
55    /// Reads the texts again if the locale changed since the last read.
56    pub(super) fn refresh(&mut self) {
57        let locale = rust_i18n::locale();
58        if *locale == *self.locale {
59            return;
60        }
61        self.total = t!("live_grid.totals.label", locale = &*locale)
62            .into_owned()
63            .into();
64        self.locale = locale.to_string();
65    }
66
67    /// "Total": the label of the totals row and its accessible name.
68    pub(super) fn total(&self) -> &str {
69        &self.total
70    }
71}
72
73impl<D: LiveGridDelegate> LiveGridState<D> {
74    /// Shows the totals row at `edge`: the aggregates of every row the view
75    /// passes (rows of collapsed groups included), as the outermost row of
76    /// that pinned band. Without aggregates (`ViewSpec::with_aggregates`)
77    /// the row shows only its label.
78    pub fn with_totals(mut self, edge: PinnedEdge) -> Self {
79        self.totals = Some(edge);
80        self
81    }
82
83    /// Shows the totals row at `edge`, or hides it (`None`). Takes effect
84    /// with the next frame; no event, no rebuild.
85    pub fn set_totals(&mut self, edge: Option<PinnedEdge>, cx: &mut Context<Self>) {
86        if self.totals == edge {
87            return;
88        }
89        self.totals = edge;
90        // The data rows' area changed.
91        self.clamp_scroll();
92        cx.notify();
93    }
94
95    /// Where the totals row is shown, if it is.
96    pub fn totals(&self) -> Option<PinnedEdge> {
97        self.totals
98    }
99
100    /// The target of a press at a window position on a summary line: a
101    /// cell of the totals row or of a painted group footer, in the column
102    /// under the press (left of the columns the first visible one, right of
103    /// them the last).
104    pub(super) fn summary_target(
105        &self,
106        position: Point<Pixels>,
107    ) -> Option<GridTarget<KeyOf<D>, ColumnOf<D>>> {
108        let column = || {
109            self.column_at_position(position)
110                .or_else(|| {
111                    if position.x < self.body_bounds.origin.x {
112                        self.columns.visible().next()
113                    } else {
114                        self.columns.visible().last()
115                    }
116                })
117                .map(column_of)
118        };
119        match self.body_line_at(position)? {
120            BodyLine::Totals(edge) => Some(GridTarget::Totals {
121                edge,
122                column: column()?,
123            }),
124            BodyLine::Data(line) => Some(GridTarget::GroupFooter {
125                group: self.painted.get(line)?.footer()?.clone(),
126                column: column()?,
127            }),
128            BodyLine::Pinned(..) => None,
129        }
130    }
131
132    /// Returns true if the shown projection has the footer of the group
133    /// at `path` on a line: the group is there, expanded, under expanded
134    /// ancestors, and `GroupFooter` gives it a footer.
135    pub(super) fn is_footer_shown(&self, path: &GroupPath) -> bool {
136        let projection = &self.projection;
137        projection.groups().find(path).is_some_and(|group| {
138            group.has_footer() && group.is_expanded() && projection.group_line(group.id()).is_some()
139        })
140    }
141}
142
143/// A summary line to plan: the totals row or a group footer.
144pub(super) struct PlannedSummary<'a> {
145    pub(super) line: AggregateLine<'a>,
146    pub(super) aggregates: AggregateRow<'a>,
147    /// The change stamps of the aggregates, by `ColumnIx`.
148    pub(super) stamps: Option<&'a [Stamp]>,
149    /// The top edge in window coordinates.
150    pub(super) y: Pixels,
151    /// The line's background across the zones; the totals row has its
152    /// band's.
153    pub(super) background: Option<Hsla>,
154    /// The weight before the delegate's style.
155    pub(super) weight: Option<FontWeight>,
156    /// The label in the first visible column, if that column has no
157    /// aggregate; also the row's accessible name.
158    pub(super) label: Option<&'a str>,
159    pub(super) hovered_column: Option<ColumnIx>,
160    /// The line's accessibility row.
161    pub(super) row: PaintedRow,
162    pub(super) outline: RowOutline,
163}
164
165impl<D: LiveGridDelegate> RowPlanner<'_, D> {
166    /// Plans a summary line across the three zone regions of its band:
167    /// the background (a footer's), then every cell, as a data cell is:
168    /// change paint from its stamp, the weight, `aggregate_style`, the
169    /// aggregate's text or the label, a tooltip for the hovered cell; then
170    /// the row line. While the accessibility tree is built, the row with
171    /// its name, the label cell and the aggregate cells; empty cells are
172    /// left out, as on a caption.
173    pub(super) fn plan_summary(
174        &mut self,
175        summary: &PlannedSummary<'_>,
176        regions: &mut [Region; 3],
177        window: &mut Window,
178    ) {
179        let (y, row_height, palette) = (summary.y, self.row_height, self.palette);
180        let key_hash = summary.row.key_hash;
181        if let Some(a11y) = &mut self.a11y {
182            a11y.push_outlined_row(summary.row, summary.outline);
183            if let Some(label) = summary.label {
184                a11y.set_row_label(label);
185            }
186        }
187        for (slot, region) in regions.iter_mut().enumerate() {
188            let row_x = region.mask().origin.x;
189            let row_width = self.row_extents[slot];
190            if let Some(background) = summary.background {
191                region.quads.push((
192                    Bounds::new(point(row_x, y), size(row_width, row_height)),
193                    background,
194                ));
195            }
196            for column_slot in self.zone_ranges[slot].clone() {
197                let planned = &self.planned[column_slot];
198                let column = planned.column;
199                let (x, width) = (planned.x, planned.width);
200                let owned = summary.aggregates.get(column.ix());
201                let value = owned.map(OwnedValue::as_cell);
202                let label = summary
203                    .label
204                    .filter(|_| owned.is_none() && planned.view == ViewColumnIx::new(0));
205                let change = owned
206                    .and(summary.stamps)
207                    .and_then(|stamps| stamps.get(column.ix().get()))
208                    .and_then(|stamp| {
209                        self.policy
210                            .intensity(*stamp, self.tick)
211                            .map(|intensity| CellChange {
212                                intensity,
213                                change: stamp.change(),
214                                direction: stamp.direction(),
215                                column_ix: Some(column.ix()),
216                            })
217                    });
218                self.any_hot |= change.is_some();
219
220                let mut style = CellStyle::default();
221                if let Some(weight) = summary.weight {
222                    style.set_font_weight(weight);
223                }
224                if let Some(change) = &change {
225                    self.change_paint
226                        .paint(change, &self.change_palette, &mut style);
227                }
228                self.delegate.aggregate_style(
229                    &AggregateStyleContext {
230                        line: summary.line,
231                        column,
232                        value,
233                        change,
234                        focused: false,
235                    },
236                    &mut style,
237                    self.app,
238                );
239                let cell_bounds = Bounds::new(point(x, y), size(width, row_height));
240                if let Some(background) = style.background {
241                    region.quads.push((cell_bounds, background));
242                }
243                let right = match planned.align {
244                    ColumnAlign::End => true,
245                    ColumnAlign::Start => false,
246                    ColumnAlign::Auto => value.is_some_and(|value| value.is_number()),
247                };
248                let text = match (value, label) {
249                    (Some(value), _) => {
250                        display_text_in(value, planned.number_format, &mut self.buffer)
251                    }
252                    (None, Some(label)) => label,
253                    (None, None) => "",
254                };
255                let (truncated, icons) = place_cell_text(
256                    CellText {
257                        text,
258                        x,
259                        width,
260                        y,
261                        right,
262                        lead: None,
263                        trend: style.trend,
264                        weight: style.font_weight,
265                        color: style.foreground.unwrap_or(palette.pinned_foreground),
266                    },
267                    &mut CellTextContext {
268                        text_cache: &mut *self.text_cache,
269                        glyphs: &mut self.glyphs,
270                        arrows: &self.arrows,
271                        row_height,
272                    },
273                    region,
274                    window,
275                );
276                self.icons += icons;
277                self.cells += 1;
278                // The aggregate's name: the catalog's, or a custom one's.
279                let name = owned.map(|_| {
280                    self.captions
281                        .aggregates
282                        .iter()
283                        .find(|(aggregated, _)| *aggregated == column)
284                        .map_or("", |(_, aggregate)| {
285                            self.captions.texts.aggregate_name(aggregate)
286                        })
287                });
288                if let Some(a11y) = &mut self.a11y {
289                    match name {
290                        Some(name) => {
291                            a11y.push_aggregate_cell(column_slot, text, name);
292                            if let Some(trend) = style.trend {
293                                a11y.set_trend(trend);
294                            }
295                        }
296                        None if label.is_some() => a11y.push_cell(column_slot, text, false),
297                        None => {}
298                    }
299                }
300                if summary.hovered_column == Some(column.ix()) {
301                    // An aggregate always tells its name and full value;
302                    // the label only when truncated. The delegate's
303                    // `tooltip` needs a row.
304                    let tooltip = match name {
305                        Some(name) => Some(CellTooltip::text(
306                            self.captions.texts.aggregate_tooltip(name, text),
307                        )),
308                        None if truncated => {
309                            Some(CellTooltip::text(SharedString::from(text.to_owned())))
310                        }
311                        None => None,
312                    };
313                    self.hovered = Some(HoveredCell {
314                        key_hash,
315                        column: column.ix(),
316                        id: column.id(),
317                        bounds: cell_bounds.intersect(&region.mask()),
318                        tooltip,
319                    });
320                }
321            }
322            region.lines.push((
323                Bounds::new(
324                    point(row_x, y + row_height - px(1.)),
325                    size(row_width, px(1.)),
326                ),
327                palette.row_border,
328            ));
329        }
330    }
331}
332
333/// A summary line of the frame autofit measures: a group caption or footer
334/// on screen, or the totals row.
335pub(super) struct SummaryLine<'a> {
336    pub(super) line: AggregateLine<'a>,
337    pub(super) aggregates: AggregateRow<'a>,
338    /// The weight the frame paints it in before the delegate's style.
339    pub(super) weight: Option<FontWeight>,
340    /// The totals row's label and the column it shows in.
341    pub(super) label: Option<(&'a str, ColumnIx)>,
342}
343
344/// What measuring summary texts for autofit needs.
345pub(super) struct SummaryMeasure<'a, D: LiveGridDelegate> {
346    pub(super) delegate: &'a D,
347    pub(super) app: &'a App,
348    pub(super) text_cache: &'a mut TextCaches,
349    pub(super) buffer: &'a mut String,
350    pub(super) arena: &'a mut GlyphArena,
351    /// How the measured column shows its numbers.
352    pub(super) format: NumberFormat,
353}
354
355impl<D: LiveGridDelegate> SummaryMeasure<'_, D> {
356    /// The widest text of `column` on `lines`: its aggregates and the
357    /// totals label, each in the weight the frame paints it in (the line's,
358    /// then `aggregate_style`'s). Zero without them. Once per autofit, never
359    /// per frame.
360    pub(super) fn width(
361        &mut self,
362        lines: &[SummaryLine<'_>],
363        column: ColumnOf<D>,
364        window: &Window,
365    ) -> Pixels {
366        let mut widest = Pixels::ZERO;
367        for line in lines {
368            let owned = line.aggregates.get(column.ix());
369            let label = line
370                .label
371                .filter(|(_, label_column)| owned.is_none() && *label_column == column.ix())
372                .map(|(label, _)| label);
373            if owned.is_none() && label.is_none() {
374                continue;
375            }
376            let value = owned.map(OwnedValue::as_cell);
377            let mut style = CellStyle::default();
378            if let Some(weight) = line.weight {
379                style.set_font_weight(weight);
380            }
381            self.delegate.aggregate_style(
382                &AggregateStyleContext {
383                    line: line.line,
384                    column,
385                    value,
386                    change: None,
387                    focused: false,
388                },
389                &mut style,
390                self.app,
391            );
392            let text = match (value, label) {
393                (Some(value), _) => display_text_in(value, self.format, self.buffer),
394                (None, Some(label)) => label,
395                (None, None) => continue,
396            };
397            if text.is_empty() {
398                continue;
399            }
400            self.arena.clear();
401            let width = self
402                .text_cache
403                .for_weight(style.font_weight)
404                .layout(text, window, self.arena)
405                .width();
406            widest = widest.max(width);
407        }
408        widest
409    }
410}