1use 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
44pub(super) const CAPTION_MIN_WIDTH: Pixels = px(120.);
47
48const TAG_PADDING: Pixels = px(5.);
50const TAG_RADIUS: Pixels = px(3.);
52const TAG_GAP: Pixels = px(6.);
54const TAG_HEIGHT: f32 = 0.68;
57
58#[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 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 pub fn with_font_weight(mut self, weight: FontWeight) -> Self {
85 self.font_weight = Some(weight);
86 self
87 }
88
89 pub fn with_min_width(mut self, width: Pixels) -> Self {
92 self.min_width = width;
93 self
94 }
95
96 pub fn background(&self) -> Hsla {
98 self.background
99 }
100
101 pub fn foreground(&self) -> Hsla {
103 self.foreground
104 }
105
106 pub fn font_weight(&self) -> Option<FontWeight> {
108 self.font_weight
109 }
110
111 pub fn min_width(&self) -> Pixels {
113 self.min_width
114 }
115}
116
117const CAPTION_COLUMN: ColumnIx = ColumnIx::new(u16::MAX);
119
120const CAPTION_TOOLTIP_ID: &str = "live-grid-group";
123
124#[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#[derive(Clone, Debug, PartialEq, Eq)]
134enum Part {
135 Text(Range<usize>),
136 Title,
137 Value,
138 Count,
139 Name,
140}
141
142#[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 _ => {
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 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#[derive(Debug, Default)]
197pub(crate) struct CaptionTexts {
198 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 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 pub(super) fn a11y(&self) -> &A11yTexts {
247 &self.a11y
248 }
249
250 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 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 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
297pub 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 pub fn group(&self) -> GroupInfo<'a> {
311 self.group
312 }
313
314 pub fn title(&self) -> &'a str {
317 self.title
318 }
319
320 pub fn value(&self) -> &'a OwnedValue {
322 self.group.value()
323 }
324
325 pub fn value_text(&self) -> &'a str {
328 self.value_text
329 }
330
331 pub fn row_count(&self) -> usize {
333 self.group.row_count()
334 }
335
336 pub fn aggregate(&self, column: R::Column) -> Option<&'a OwnedValue> {
338 self.group.aggregates().get(column.ix())
339 }
340
341 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
356pub(super) struct PlannedCaption<'a> {
358 pub(super) group: GroupInfo<'a>,
359 pub(super) depth: u16,
360 pub(super) y: Pixels,
362 pub(super) display_ix: usize,
363 pub(super) title: &'a str,
365 pub(super) expanded: bool,
368 pub(super) focused: bool,
369 pub(super) hover_x: Option<Pixels>,
371 pub(super) stamps: Option<&'a [Stamp]>,
374 pub(super) stamp: Option<Stamp>,
377}
378
379impl<D: LiveGridDelegate> RowPlanner<'_, D> {
380 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 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 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 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 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 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 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 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 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(®ion.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 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 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 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}