Skip to main content

gpui_ai/
insight.rs

1//! Paged analytical insight cards with metrics and a time series.
2
3use crate::control::composed_button;
4use crate::handlers::SharedHandler;
5use crate::motion::{MotionTokens, swap_progress};
6use crate::theme::SemanticStyledExt as _;
7use gpui::{
8    App, ClickEvent, Div, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
9    RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
10    Styled, Window, div, prelude::FluentBuilder as _,
11};
12use gpui_base::Button;
13use gpui_base::motion::{Transition, transition};
14use gpui_component::{
15    ActiveTheme as _, StyledExt as _, chart::LineChart, h_flex, text::TextView, v_flex,
16};
17
18/// The direction communicated by an insight metric.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum InsightTrend {
21    /// The metric is increasing.
22    Up,
23    /// The metric is decreasing.
24    Down,
25    /// The metric is unchanged.
26    Flat,
27}
28
29impl InsightTrend {
30    fn accessibility_label(self) -> &'static str {
31        match self {
32            Self::Up => "increasing",
33            Self::Down => "decreasing",
34            Self::Flat => "steady",
35        }
36    }
37
38    fn visible_label(self) -> &'static str {
39        match self {
40            Self::Up => "↑ Increasing",
41            Self::Down => "↓ Decreasing",
42            Self::Flat => "→ Steady",
43        }
44    }
45}
46
47/// A labeled value displayed in an [`InsightCard`].
48#[derive(Debug, Clone)]
49pub struct InsightMetric {
50    id: SharedString,
51    label: SharedString,
52    value: SharedString,
53    change: Option<SharedString>,
54    trend: InsightTrend,
55}
56
57impl InsightMetric {
58    /// Creates a metric with stable identity, a label, and a formatted value.
59    pub fn new(
60        id: impl Into<SharedString>,
61        label: impl Into<SharedString>,
62        value: impl Into<SharedString>,
63    ) -> Self {
64        Self {
65            id: id.into(),
66            label: label.into(),
67            value: value.into(),
68            change: None,
69            trend: InsightTrend::Flat,
70        }
71    }
72
73    /// Adds a textual change and its semantic direction.
74    pub fn change(mut self, change: impl Into<SharedString>, trend: InsightTrend) -> Self {
75        self.change = Some(change.into());
76        self.trend = trend;
77        self
78    }
79
80    fn accessibility_name(&self) -> SharedString {
81        match &self.change {
82            Some(change) => format!(
83                "{}, {}, {}, {}",
84                self.label,
85                self.value,
86                change,
87                self.trend.accessibility_label()
88            )
89            .into(),
90            None => format!("{}, {}", self.label, self.value).into(),
91        }
92    }
93}
94
95/// One labeled sample in an insight chart series.
96#[derive(Debug, Clone)]
97pub struct InsightPoint {
98    label: SharedString,
99    value: f64,
100}
101
102impl InsightPoint {
103    /// Creates a chart point from its display label and numeric value.
104    pub fn new(label: impl Into<SharedString>, value: f64) -> Self {
105        Self {
106            label: label.into(),
107            value,
108        }
109    }
110}
111
112/// An interaction emitted by [`InsightCard`].
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum InsightEvent {
115    /// Requests the page before the currently displayed insight.
116    Previous {
117        /// Stable insight identifier.
118        id: SharedString,
119    },
120    /// Requests the page after the currently displayed insight.
121    Next {
122        /// Stable insight identifier.
123        id: SharedString,
124    },
125    /// Requests a suggested follow-up prompt.
126    FollowUp {
127        /// Stable insight identifier.
128        id: SharedString,
129        /// Prompt selected by the user.
130        prompt: SharedString,
131    },
132}
133
134#[derive(Clone, Copy)]
135enum InsightControlStyle {
136    Paging,
137    FollowUp,
138}
139
140struct InsightControl {
141    card_id: SharedString,
142    local_id: &'static str,
143    label: SharedString,
144    disabled: bool,
145    event: InsightEvent,
146    style: InsightControlStyle,
147}
148
149impl InsightControl {
150    fn styled_button(self, handler: Option<SharedHandler<InsightEvent>>, cx: &mut App) -> Button {
151        let tokens = cx.theme().semantic_tokens();
152        let (background, foreground, border, hover, active) = match self.style {
153            InsightControlStyle::Paging => (
154                cx.theme().transparent,
155                cx.theme().foreground,
156                cx.theme().transparent,
157                cx.theme().button_hover,
158                cx.theme().button_active,
159            ),
160            InsightControlStyle::FollowUp => (
161                cx.theme().button_primary,
162                cx.theme().button_primary_foreground,
163                cx.theme().primary,
164                cx.theme().button_primary_hover,
165                cx.theme().button_primary_active,
166            ),
167        };
168        let disabled = self.disabled;
169        let label = self.label.clone();
170        composed_button((ElementId::from(self.card_id), self.local_id), self.label)
171            .disabled(disabled)
172            .flex()
173            .items_center()
174            .justify_center()
175            .px(tokens.spacing.sm)
176            .py(tokens.spacing.xxs)
177            .border_1()
178            .border_color(border)
179            .rounded(tokens.radius.sm)
180            .bg(background)
181            .text_token(tokens.typography.xs)
182            .text_color(foreground)
183            .when(!disabled, |button| {
184                button
185                    .hover(|style| style.bg(hover))
186                    .active(|style| style.bg(active))
187            })
188            .focus(|style| style.border_color(cx.theme().ring))
189            .focus_visible(|style| style.border_color(cx.theme().ring))
190            .styles(|styles| {
191                styles.disabled(|style| {
192                    style
193                        .bg(cx.theme().muted)
194                        .text_color(cx.theme().muted_foreground)
195                        .border_color(cx.theme().border)
196                })
197            })
198            .child(div().child(label))
199            .when_some(handler, |button, handler| {
200                let event = self.event;
201                button.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
202            })
203    }
204}
205
206/// A caller-controlled analytical card with metrics, a chart, and paging.
207///
208/// The application owns the active page and data. Events preserve the stable
209/// card identity so callers can update the right domain object after reorder.
210///
211/// # Example
212///
213/// ```
214/// # use gpui_ai::prelude::*;
215/// InsightCard::new("demand", "Demand changed")
216///     .body("Mint Chip softened while Vanilla strengthened.")
217///     .page(1, 3)
218///     .metrics([InsightMetric::new("mint", "Mint Chip", "$2,377.66")])
219///     .series("Weekly demand", [InsightPoint::new("Mon", 18.0)])
220///     .chart_summary("Weekly demand rose from 18 to 24 orders.")
221///     .follow_up("Rebalance flavors")
222///     .on_event(|event, _, _| { /* update application-owned state */ });
223/// ```
224#[derive(IntoElement)]
225pub struct InsightCard {
226    id: SharedString,
227    style: StyleRefinement,
228    title: SharedString,
229    body: Option<SharedString>,
230    current_page: usize,
231    total_pages: usize,
232    metrics: Vec<InsightMetric>,
233    series_name: SharedString,
234    points: Vec<InsightPoint>,
235    chart_summary: Option<SharedString>,
236    follow_up: Option<SharedString>,
237    on_event: Option<SharedHandler<InsightEvent>>,
238}
239
240impl InsightCard {
241    /// Creates an insight card with stable identity and a headline.
242    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
243        Self {
244            id: id.into(),
245            style: StyleRefinement::default(),
246            title: title.into(),
247            body: None,
248            current_page: 1,
249            total_pages: 1,
250            metrics: Vec::new(),
251            series_name: "Series".into(),
252            points: Vec::new(),
253            chart_summary: None,
254            follow_up: None,
255            on_event: None,
256        }
257    }
258
259    /// Sets selectable explanatory Markdown.
260    pub fn body(mut self, body: impl Into<SharedString>) -> Self {
261        self.body = Some(body.into());
262        self
263    }
264
265    /// Sets the one-based page position, clamped to the available page count.
266    pub fn page(mut self, current: usize, total: usize) -> Self {
267        self.total_pages = total.max(1);
268        self.current_page = current.clamp(1, self.total_pages);
269        self
270    }
271
272    /// Replaces the summary metrics displayed by the card.
273    pub fn metrics(mut self, metrics: impl IntoIterator<Item = InsightMetric>) -> Self {
274        self.metrics = metrics.into_iter().collect();
275        self
276    }
277
278    /// Sets the chart series label and points.
279    pub fn series(
280        mut self,
281        name: impl Into<SharedString>,
282        points: impl IntoIterator<Item = InsightPoint>,
283    ) -> Self {
284        self.series_name = name.into();
285        self.points = points.into_iter().collect();
286        self
287    }
288
289    /// Sets the nonvisual summary that explains the chart's meaning.
290    pub fn chart_summary(mut self, summary: impl Into<SharedString>) -> Self {
291        self.chart_summary = Some(summary.into());
292        self
293    }
294
295    /// Adds a suggested follow-up prompt and its action button.
296    pub fn follow_up(mut self, prompt: impl Into<SharedString>) -> Self {
297        self.follow_up = Some(prompt.into());
298        self
299    }
300
301    /// Handles all paging and follow-up interactions from this card.
302    pub fn on_event(
303        mut self,
304        handler: impl Fn(&InsightEvent, &mut Window, &mut App) + 'static,
305    ) -> Self {
306        self.on_event = Some(std::rc::Rc::new(handler));
307        self
308    }
309
310    fn page_label(&self) -> SharedString {
311        format!("{} of {}", self.current_page, self.total_pages).into()
312    }
313
314    fn previous_event(&self) -> InsightEvent {
315        InsightEvent::Previous {
316            id: self.id.clone(),
317        }
318    }
319
320    fn next_event(&self) -> InsightEvent {
321        InsightEvent::Next {
322            id: self.id.clone(),
323        }
324    }
325
326    fn resolved_chart_summary(&self) -> Option<SharedString> {
327        self.chart_summary.clone().or_else(|| {
328            if self.points.is_empty() {
329                return None;
330            }
331
332            let values = self
333                .points
334                .iter()
335                .map(|point| format!("{} {}", point.label, point.value))
336                .collect::<Vec<_>>()
337                .join(", ");
338            Some(format!("{}: {values}.", self.series_name).into())
339        })
340    }
341
342    fn previous_control(&self) -> InsightControl {
343        InsightControl {
344            card_id: self.id.clone(),
345            local_id: "previous",
346            label: "Previous".into(),
347            disabled: self.current_page == 1 || self.on_event.is_none(),
348            event: self.previous_event(),
349            style: InsightControlStyle::Paging,
350        }
351    }
352
353    fn previous_button(&self, cx: &mut App) -> Button {
354        self.previous_control()
355            .styled_button(self.on_event.clone(), cx)
356    }
357
358    fn next_control(&self) -> InsightControl {
359        InsightControl {
360            card_id: self.id.clone(),
361            local_id: "next",
362            label: "Next".into(),
363            disabled: self.current_page == self.total_pages || self.on_event.is_none(),
364            event: self.next_event(),
365            style: InsightControlStyle::Paging,
366        }
367    }
368
369    fn next_button(&self, cx: &mut App) -> Button {
370        self.next_control().styled_button(self.on_event.clone(), cx)
371    }
372
373    fn follow_up_control(&self) -> Option<InsightControl> {
374        self.follow_up.clone().map(|prompt| InsightControl {
375            card_id: self.id.clone(),
376            local_id: "follow-up",
377            label: prompt.clone(),
378            disabled: self.on_event.is_none(),
379            event: InsightEvent::FollowUp {
380                id: self.id.clone(),
381                prompt,
382            },
383            style: InsightControlStyle::FollowUp,
384        })
385    }
386
387    fn follow_up_button(&self, cx: &mut App) -> Option<Button> {
388        self.follow_up_control()
389            .map(|control| control.styled_button(self.on_event.clone(), cx))
390    }
391}
392
393fn metrics_group(card_id: &SharedString, cx: &mut App) -> Stateful<Div> {
394    let tokens = cx.theme().semantic_tokens();
395    h_flex()
396        .id(format!("{card_id}-metrics"))
397        .role(Role::List)
398        .aria_label("Insight metrics")
399        .flex_wrap()
400        .gap(tokens.spacing.sm)
401}
402
403fn metric_item(card_id: &SharedString, metric: InsightMetric, cx: &mut App) -> Stateful<Div> {
404    let tokens = cx.theme().semantic_tokens();
405    let accessibility_name = metric.accessibility_name();
406    let trend = metric.trend;
407    let trend_color = match trend {
408        InsightTrend::Up => cx.theme().success,
409        InsightTrend::Down => cx.theme().danger,
410        InsightTrend::Flat => cx.theme().muted_foreground,
411    };
412
413    v_flex()
414        .id((
415            ElementId::from((ElementId::from(card_id.clone()), "metric")),
416            metric.id.clone(),
417        ))
418        .role(Role::ListItem)
419        .aria_label(accessibility_name)
420        .flex_1()
421        .gap(tokens.spacing.xxs)
422        .p(tokens.spacing.sm)
423        .bg(cx.theme().muted)
424        .rounded(tokens.radius.sm)
425        .child(crate::surface::hint(metric.label, cx))
426        .child(
427            div()
428                .text_token(tokens.typography.md)
429                .text_color(cx.theme().foreground)
430                .child(metric.value),
431        )
432        .when_some(metric.change, |this, change| {
433            this.child(
434                h_flex()
435                    .gap(tokens.spacing.xs)
436                    .text_token(tokens.typography.xs)
437                    .text_color(trend_color)
438                    .child(change)
439                    .child(trend.visible_label()),
440            )
441        })
442}
443
444fn chart_group(
445    card_id: &SharedString,
446    label: SharedString,
447    summary: SharedString,
448    page: usize,
449    points: Vec<InsightPoint>,
450    window: &mut Window,
451    cx: &mut App,
452) -> Stateful<Div> {
453    let tokens = cx.theme().semantic_tokens();
454    // Drawn points retarget from their previous controlled values at the
455    // standard tempo, keyed by page and label: a value updated in place
456    // morphs, while a page swap presents fresh keys and joins at rest —
457    // two different weeks never cross-morph just because both start on
458    // Monday. Semantics read the controlled values; only drawing follows.
459    let standard = MotionTokens::read(cx).standard();
460    let points = points
461        .into_iter()
462        .map(|point| {
463            let drawn = transition(
464                ElementId::Name(SharedString::from(format!(
465                    "{card_id}-pt-{page}-{}",
466                    point.label
467                ))),
468                point.value as f32,
469                Transition::new(crate::motion::retarget_duration(cx, standard))
470                    .ease(crate::motion::ease_in_out_quart),
471                window,
472                cx,
473            );
474            InsightPoint {
475                label: point.label,
476                value: drawn as f64,
477            }
478        })
479        .collect::<Vec<_>>();
480    v_flex()
481        .id((ElementId::from(card_id.clone()), "chart-group"))
482        .role(Role::Group)
483        .aria_label(label.clone())
484        .aria_description(summary)
485        .h(tokens.spacing.xxl + tokens.spacing.xxl + tokens.spacing.xxl + tokens.spacing.xxl)
486        .child(
487            LineChart::new(points)
488                .x(|point| point.label.clone())
489                .y(|point| point.value)
490                .natural()
491                .grid(false)
492                .x_axis(false)
493                .stroke(cx.theme().chart_2)
494                .name(label)
495                .id((ElementId::from(card_id.clone()), "chart")),
496        )
497}
498
499impl Styled for InsightCard {
500    fn style(&mut self) -> &mut StyleRefinement {
501        &mut self.style
502    }
503}
504
505impl RenderOnce for InsightCard {
506    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
507        let tokens = cx.theme().semantic_tokens();
508        // Page travel: the content enters with a quick fade and a few
509        // pixels from the side the navigation came from, keyed by a
510        // generation so revisiting a page replays its entrance while
511        // re-renders replay nothing. Controls stay outside the travel.
512        let page_memory = window.use_keyed_state(
513            (ElementId::from(self.id.clone()), "page-memory"),
514            cx,
515            |_, _| (self.current_page, 0u64, true),
516        );
517        let (previous_page, mut page_generation, mut entering_forward) = *page_memory.read(cx);
518        if previous_page != self.current_page {
519            page_generation = page_generation.wrapping_add(1);
520            entering_forward = self.current_page > previous_page;
521            let stamp = (self.current_page, page_generation, entering_forward);
522            page_memory.update(cx, |memory, _| *memory = stamp);
523        }
524        // Generation zero is the mount: the first page is presented, not
525        // entered.
526        let page_entrance = if page_generation == 0 {
527            1.0
528        } else {
529            swap_progress(
530                ElementId::Name(SharedString::from(format!(
531                    "{}-page-enter-{page_generation}",
532                    self.id
533                ))),
534                window,
535                cx,
536            )
537        };
538        let page_travel = tokens.spacing.xxs * (1.0 - page_entrance) * crate::motion::travel(cx);
539        let page_offset = if entering_forward {
540            page_travel
541        } else {
542            -page_travel
543        };
544        let page_label = self.page_label();
545        let page_text: SharedString = format!("Insights · {page_label}").into();
546        let resolved_chart_summary = self.resolved_chart_summary();
547        let accessibility_description: SharedString = match &resolved_chart_summary {
548            Some(summary) => format!("{page_text}. {summary}").into(),
549            None => page_text.clone(),
550        };
551        let paging_buttons =
552            (self.total_pages > 1).then(|| (self.previous_button(cx), self.next_button(cx)));
553        let follow_up_button = self.follow_up_button(cx);
554        let chart_label = self.series_name.clone();
555        let card_id = self.id.clone();
556        let chart = if self.points.is_empty() {
557            None
558        } else {
559            resolved_chart_summary.map(|summary| {
560                chart_group(
561                    &card_id,
562                    chart_label,
563                    summary,
564                    self.current_page,
565                    self.points,
566                    window,
567                    cx,
568                )
569            })
570        };
571
572        v_flex()
573            .id(self.id.clone())
574            .role(Role::Group)
575            .aria_label(self.title.clone())
576            .aria_description(accessibility_description)
577            .gap(tokens.spacing.md)
578            .p(tokens.spacing.lg)
579            .bg(cx.theme().background)
580            .border_1()
581            .border_color(cx.theme().border)
582            .rounded(tokens.radius.md)
583            .child(
584                v_flex()
585                    .gap(tokens.spacing.md)
586                    .relative()
587                    .left(page_offset)
588                    .opacity(page_entrance)
589                    .child(
590                        v_flex()
591                            .gap(tokens.spacing.xs)
592                            .child(crate::surface::hint(page_text, cx))
593                            .child(
594                                div()
595                                    .text_token(tokens.typography.lg)
596                                    .text_color(cx.theme().foreground)
597                                    .child(self.title),
598                            ),
599                    )
600                    .when_some(self.body, |this, body| {
601                        this.child(
602                            TextView::markdown((ElementId::from(card_id.clone()), "body"), body)
603                                .selectable(true),
604                        )
605                    })
606                    .when(!self.metrics.is_empty(), |this| {
607                        this.child(
608                            metrics_group(&card_id, cx).children(
609                                self.metrics
610                                    .into_iter()
611                                    .map(|metric| metric_item(&card_id, metric, cx)),
612                            ),
613                        )
614                    })
615                    .when_some(chart, |this, chart| this.child(chart)),
616            )
617            .when(self.total_pages > 1 || self.follow_up.is_some(), |this| {
618                this.child(
619                    h_flex()
620                        .flex_wrap()
621                        .items_center()
622                        .gap(tokens.spacing.sm)
623                        .when_some(paging_buttons, |this, (previous, next)| {
624                            this.child(
625                                div().flex_1().child(
626                                    h_flex().gap(tokens.spacing.xs).child(previous).child(next),
627                                ),
628                            )
629                        })
630                        .when_some(follow_up_button, |this, button| this.child(button)),
631                )
632            })
633            .refine_style(&self.style)
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::{
640        InsightCard, InsightEvent, InsightMetric, InsightPoint, InsightTrend, chart_group,
641        metric_item, metrics_group,
642    };
643
644    mod paging_motion {
645        use super::super::{InsightCard, InsightMetric};
646        use gpui::{
647            Context, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext,
648            VisualTestContext, Window, div, px,
649        };
650
651        struct PagedProbe {
652            page: usize,
653        }
654
655        impl Render for PagedProbe {
656            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
657                div().w(px(420.)).h(px(360.)).child(
658                    InsightCard::new("probe-insight", "Weekly demand")
659                        .page(self.page, 3)
660                        .metrics([InsightMetric::new("mint", "Mint Chip", "$2,377.66")]),
661                )
662            }
663        }
664
665        fn draw(cx: &mut VisualTestContext) {
666            cx.update(|window, cx| window.draw(cx).clear(cx));
667        }
668
669        #[gpui::test]
670        fn a_mounted_page_is_presented_and_navigation_enters(cx: &mut TestAppContext) {
671            cx.update(crate::init);
672            let (probe, cx) = cx.add_window_view(|_, _| PagedProbe { page: 1 });
673            let cx: &mut VisualTestContext = cx;
674            draw(cx);
675            crate::motion::take_reveal_frame_requests();
676            draw(cx);
677            assert_eq!(
678                crate::motion::take_reveal_frame_requests(),
679                0,
680                "the first page is presented, not entered"
681            );
682
683            probe.update(cx, |probe, cx| {
684                probe.page = 2;
685                cx.notify();
686            });
687            draw(cx);
688            assert!(
689                crate::motion::take_reveal_frame_requests() > 0,
690                "navigating must enter the new page"
691            );
692
693            cx.executor()
694                .advance_clock(std::time::Duration::from_secs(2));
695            draw(cx);
696            crate::motion::take_reveal_frame_requests();
697            probe.update(cx, |probe, cx| {
698                probe.page = 1;
699                cx.notify();
700            });
701            draw(cx);
702            assert!(
703                crate::motion::take_reveal_frame_requests() > 0,
704                "returning to a page replays its entrance"
705            );
706        }
707    }
708    use gpui::{
709        Element as _, IntoElement as _, Render, RenderOnce as _, Role, TestAppContext, Window,
710        accesskit, canvas,
711    };
712    use std::sync::{Arc, Mutex};
713
714    #[derive(Clone, Copy)]
715    enum ChildProbeKind {
716        Metrics,
717        Metric,
718        Chart,
719        Previous,
720        Next,
721        FollowUp,
722    }
723
724    struct CapturedChild {
725        role: Option<Role>,
726        node: accesskit::Node,
727    }
728
729    struct ChildProbe {
730        kind: ChildProbeKind,
731        captured: Arc<Mutex<Option<CapturedChild>>>,
732    }
733
734    impl Render for ChildProbe {
735        fn render(
736            &mut self,
737            _: &mut Window,
738            _: &mut gpui::Context<Self>,
739        ) -> impl gpui::IntoElement {
740            let kind = self.kind;
741            let captured = self.captured.clone();
742            canvas(
743                move |_, window, cx| {
744                    let card_id: gpui::SharedString = "insight".into();
745                    let mut card = Some(
746                        InsightCard::new(card_id.clone(), "Demand changed")
747                            .page(1, 3)
748                            .follow_up("Should I rebalance flavors?")
749                            .on_event(|_, _, _| {}),
750                    );
751                    macro_rules! capture_element {
752                        ($element:expr) => {{
753                            let element = $element.into_element();
754                            let role = element.a11y_role();
755                            let mut node = accesskit::Node::new(role.unwrap_or(Role::Unknown));
756                            element.write_a11y_info(&mut node);
757                            CapturedChild { role, node }
758                        }};
759                    }
760                    let child = match kind {
761                        ChildProbeKind::Metrics => {
762                            capture_element!(metrics_group(&card_id, cx))
763                        }
764                        ChildProbeKind::Metric => capture_element!(metric_item(
765                            &card_id,
766                            InsightMetric::new("mint", "Mint Chip", "$2,377.66")
767                                .change("down 4.41%", InsightTrend::Down),
768                            cx,
769                        )),
770                        ChildProbeKind::Chart => capture_element!(chart_group(
771                            &card_id,
772                            "Trend snapshot".into(),
773                            "Trend snapshot: Week 1 18, Week 2 24.".into(),
774                            1,
775                            vec![
776                                InsightPoint::new("Week 1", 18.0),
777                                InsightPoint::new("Week 2", 24.0),
778                            ],
779                            window,
780                            cx,
781                        )),
782                        ChildProbeKind::Previous => capture_element!(
783                            card.take()
784                                .expect("probe card should be available")
785                                .previous_button(cx)
786                                .render(window, cx)
787                        ),
788                        ChildProbeKind::Next => capture_element!(
789                            card.take()
790                                .expect("probe card should be available")
791                                .next_button(cx)
792                                .render(window, cx)
793                        ),
794                        ChildProbeKind::FollowUp => capture_element!(
795                            card.take()
796                                .expect("probe card should be available")
797                                .follow_up_button(cx)
798                                .expect("probe card should have a follow-up")
799                                .render(window, cx)
800                        ),
801                    };
802                    *captured.lock().expect("capture mutex should be available") = Some(child);
803                },
804                |_, _, _, _| {},
805            )
806        }
807    }
808
809    fn capture_child(kind: ChildProbeKind, cx: &mut TestAppContext) -> CapturedChild {
810        cx.update(crate::init);
811        let captured = Arc::new(Mutex::new(None));
812        let result = captured.clone();
813        let (_, cx) = cx.add_window_view(move |_, _| ChildProbe { kind, captured });
814        cx.update(|window, cx| window.draw(cx).clear(cx));
815        result
816            .lock()
817            .expect("capture mutex should be available")
818            .take()
819            .expect("semantic child should be captured")
820    }
821
822    #[test]
823    fn page_position_is_one_based_and_clamped() {
824        assert_eq!(
825            InsightCard::new("insight", "Demand changed")
826                .page(0, 0)
827                .page_label(),
828            "1 of 1"
829        );
830        assert_eq!(
831            InsightCard::new("insight", "Demand changed")
832                .page(7, 3)
833                .page_label(),
834            "3 of 3"
835        );
836        assert_eq!(
837            InsightCard::new("insight", "Demand changed")
838                .page(2, 3)
839                .page_label(),
840            "2 of 3"
841        );
842    }
843
844    #[test]
845    fn metric_accessibility_name_never_relies_on_color() {
846        let metric = InsightMetric::new("mint", "Mint Chip", "$2,377.66")
847            .change("down 4.41%", InsightTrend::Down);
848        assert_eq!(
849            metric.accessibility_name(),
850            "Mint Chip, $2,377.66, down 4.41%, decreasing"
851        );
852    }
853
854    #[test]
855    fn events_preserve_the_stable_insight_identity() {
856        let card = InsightCard::new("insight-17", "Demand changed").follow_up("Rebalance flavors");
857        assert_eq!(
858            card.previous_event(),
859            InsightEvent::Previous {
860                id: "insight-17".into()
861            }
862        );
863        assert_eq!(
864            card.next_event(),
865            InsightEvent::Next {
866                id: "insight-17".into()
867            }
868        );
869        assert_eq!(
870            card.follow_up_control().map(|control| control.event),
871            Some(InsightEvent::FollowUp {
872                id: "insight-17".into(),
873                prompt: "Rebalance flavors".into(),
874            })
875        );
876    }
877
878    #[test]
879    fn chart_summary_falls_back_to_series_and_labeled_values() {
880        let card = InsightCard::new("insight", "Demand changed").series(
881            "Trend snapshot",
882            [
883                InsightPoint::new("Week 1", 18.0),
884                InsightPoint::new("Week 2", 24.0),
885            ],
886        );
887
888        assert_eq!(
889            card.resolved_chart_summary(),
890            Some("Trend snapshot: Week 1 18, Week 2 24.".into())
891        );
892        assert_eq!(
893            card.chart_summary("Demand grew overall")
894                .resolved_chart_summary(),
895            Some("Demand grew overall".into())
896        );
897    }
898
899    #[gpui::test]
900    fn metric_and_chart_children_expose_direct_semantics(cx: &mut TestAppContext) {
901        let metrics = capture_child(ChildProbeKind::Metrics, cx);
902        assert_eq!(metrics.role, Some(Role::List));
903        assert_eq!(metrics.node.label(), Some("Insight metrics"));
904
905        let metric = capture_child(ChildProbeKind::Metric, cx);
906        assert_eq!(metric.role, Some(Role::ListItem));
907        assert_eq!(
908            metric.node.label(),
909            Some("Mint Chip, $2,377.66, down 4.41%, decreasing")
910        );
911
912        let chart = capture_child(ChildProbeKind::Chart, cx);
913        assert_eq!(chart.role, Some(Role::Group));
914        assert_eq!(chart.node.label(), Some("Trend snapshot"));
915        assert_eq!(
916            chart.node.description(),
917            Some("Trend snapshot: Week 1 18, Week 2 24.")
918        );
919    }
920
921    #[gpui::test]
922    fn insight_controls_expose_click_actions_and_disabled_boundaries(cx: &mut TestAppContext) {
923        let previous = capture_child(ChildProbeKind::Previous, cx);
924        let next = capture_child(ChildProbeKind::Next, cx);
925        let follow_up = capture_child(ChildProbeKind::FollowUp, cx);
926        assert_eq!(previous.role, Some(Role::Button));
927        assert_eq!(previous.node.label(), Some("Previous"));
928        assert!(!previous.node.supports_action(accesskit::Action::Click));
929
930        assert_eq!(next.role, Some(Role::Button));
931        assert_eq!(next.node.label(), Some("Next"));
932        assert!(next.node.supports_action(accesskit::Action::Click));
933
934        assert_eq!(follow_up.role, Some(Role::Button));
935        assert_eq!(follow_up.node.label(), Some("Should I rebalance flavors?"));
936        assert!(follow_up.node.supports_action(accesskit::Action::Click));
937    }
938}