Skip to main content

gpui_ai/
comparison_table.rs

1//! Bounded, feature-oriented comparison table values and presentation.
2
3use gpui_base::StyledExt as _;
4use std::{collections::HashSet, sync::Arc};
5
6use gpui::{
7    AnyElement, App, Axis, Context, ElementId, EventEmitter, FocusHandle, Focusable, Hsla,
8    InteractiveElement as _, IntoElement, ListAlignment, ListOffset, ListState, ParentElement as _,
9    Pixels, Render, Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _,
10    Window, div, list, prelude::FluentBuilder as _,
11};
12use gpui_base::InteractiveElementExt as _;
13use gpui_component::{
14    ActiveTheme as _, Icon, IconName, h_flex, scroll::ScrollableMask, text::TextView,
15};
16
17use crate::scrolling::PolicyScrollbarExt as _;
18use crate::{
19    control::outlined_control_with_wrapping_label,
20    motion::{MotionTokens, VisibleAnimationExt as _},
21    records_table::escape_markdown_text,
22    resolved_layout::ResolvedLayoutKey,
23    scrolling::list_scroll_mask,
24    stream::{ProgressState, Progressive},
25    theme::SemanticStyledExt as _,
26};
27
28/// Maximum number of side-by-side items accepted by a comparison snapshot.
29pub const MAX_COMPARISON_ITEMS: usize = 12;
30
31/// Maximum number of feature rows accepted by a comparison snapshot.
32pub const MAX_COMPARISON_FEATURES: usize = 128;
33
34/// Distance past the viewport, in rem, where feature rows stay measured.
35///
36/// Rem rather than pixels: a row's height is wrapped text laid out against
37/// the window's type scale, so the overdraw that buys one row of slack at a
38/// small rem buys none at a large one. Sixteen rem is the transcript's
39/// overdraw at a default type scale.
40const FEATURE_OVERDRAW_REM: f32 = 16.;
41
42/// Presentation state attached to one comparison item.
43#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
44pub enum ComparisonItemState {
45    /// An ordinary comparison item.
46    #[default]
47    Default,
48    /// An item the application wants to call out as recommended or notable.
49    Highlighted,
50    /// An item that cannot currently be selected or activated.
51    Disabled,
52}
53
54/// One consumer-owned item displayed as a comparison column.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ComparisonItem {
57    id: SharedString,
58    label: SharedString,
59    description: Option<SharedString>,
60    state: ComparisonItemState,
61}
62
63impl ComparisonItem {
64    /// Creates an ordinary item with stable identity and a visible label.
65    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
66        Self {
67            id: id.into(),
68            label: label.into(),
69            description: None,
70            state: ComparisonItemState::Default,
71        }
72    }
73
74    /// Adds readable supporting copy for the item header.
75    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
76        self.description = Some(description.into());
77        self
78    }
79
80    /// Sets the consumer-owned item presentation state.
81    pub fn state(mut self, state: ComparisonItemState) -> Self {
82        self.state = state;
83        self
84    }
85
86    /// Returns the stable application ID.
87    pub fn id(&self) -> &str {
88        &self.id
89    }
90
91    /// Returns the visible label.
92    pub fn label(&self) -> &str {
93        &self.label
94    }
95
96    /// Returns optional readable supporting copy.
97    pub fn description_text(&self) -> Option<&str> {
98        self.description.as_deref()
99    }
100
101    /// Returns the current consumer-owned presentation state.
102    pub fn item_state(&self) -> ComparisonItemState {
103        self.state
104    }
105}
106
107/// One readable value at the intersection of a feature and comparison item.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ComparisonValue {
110    item_id: SharedString,
111    display: SharedString,
112    included: Option<bool>,
113}
114
115impl ComparisonValue {
116    /// Creates a free-form readable value for one stable item ID.
117    pub fn new(item_id: impl Into<SharedString>, display: impl Into<SharedString>) -> Self {
118        Self {
119            item_id: item_id.into(),
120            display: display.into(),
121            included: None,
122        }
123    }
124
125    /// Creates an explicit included/not-included value without relying on an icon or color.
126    pub fn included(item_id: impl Into<SharedString>, included: bool) -> Self {
127        Self {
128            item_id: item_id.into(),
129            display: if included { "Included" } else { "Not included" }.into(),
130            included: Some(included),
131        }
132    }
133
134    /// Returns the stable item ID this value belongs to.
135    pub fn item_id(&self) -> &str {
136        &self.item_id
137    }
138
139    /// Returns the readable displayed value.
140    pub fn display(&self) -> &str {
141        &self.display
142    }
143
144    /// Returns explicit support state when this is an included/not-included value.
145    pub fn is_included(&self) -> Option<bool> {
146        self.included
147    }
148}
149
150/// One feature row with values keyed by stable comparison item ID.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ComparisonFeature {
153    id: SharedString,
154    label: SharedString,
155    description: Option<SharedString>,
156    values: Arc<[ComparisonValue]>,
157}
158
159impl ComparisonFeature {
160    /// Creates an empty feature row with stable identity and a visible label.
161    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
162        Self {
163            id: id.into(),
164            label: label.into(),
165            description: None,
166            values: Arc::from([]),
167        }
168    }
169
170    /// Adds readable supporting copy for the feature label.
171    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
172        self.description = Some(description.into());
173        self
174    }
175
176    /// Replaces the immutable item-value snapshot.
177    pub fn values(mut self, values: impl IntoIterator<Item = ComparisonValue>) -> Self {
178        self.values = values.into_iter().collect::<Vec<_>>().into();
179        self
180    }
181
182    /// Returns the stable application ID.
183    pub fn id(&self) -> &str {
184        &self.id
185    }
186
187    /// Returns the visible label.
188    pub fn label(&self) -> &str {
189        &self.label
190    }
191
192    /// Returns optional readable supporting copy.
193    pub fn description_text(&self) -> Option<&str> {
194        self.description.as_deref()
195    }
196
197    /// Returns the value for a stable item ID.
198    pub fn value(&self, item_id: &str) -> Option<&ComparisonValue> {
199        self.values.iter().find(|value| value.item_id == item_id)
200    }
201
202    /// Returns the immutable values in consumer order.
203    pub fn values_snapshot(&self) -> &[ComparisonValue] {
204        &self.values
205    }
206}
207
208/// Structural validation failure for a bounded comparison snapshot.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ComparisonSnapshotError {
211    /// More side-by-side items were supplied than the bounded layout supports.
212    TooManyItems {
213        /// The largest accepted item count.
214        maximum: usize,
215    },
216    /// More feature rows were supplied than the bounded layout supports.
217    TooManyFeatures {
218        /// The largest accepted feature count.
219        maximum: usize,
220    },
221    /// Two items reused one stable ID.
222    DuplicateItemId(SharedString),
223    /// Two features reused one stable ID.
224    DuplicateFeatureId(SharedString),
225    /// A feature supplied more than one value for the same stable item ID.
226    DuplicateValueItemId {
227        /// Stable feature ID containing the duplicate.
228        feature_id: SharedString,
229        /// Reused stable item ID.
230        item_id: SharedString,
231    },
232    /// A feature value referenced an item absent from the snapshot.
233    UnknownItemId {
234        /// Stable feature ID containing the value.
235        feature_id: SharedString,
236        /// Unknown stable item ID.
237        item_id: SharedString,
238    },
239}
240
241/// Validated immutable data for one intentionally bounded comparison surface.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ComparisonSnapshot {
244    items: Arc<[ComparisonItem]>,
245    features: Arc<[ComparisonFeature]>,
246}
247
248impl ComparisonSnapshot {
249    /// Validates and creates a bounded comparison snapshot.
250    pub fn try_new(
251        items: impl IntoIterator<Item = ComparisonItem>,
252        features: impl IntoIterator<Item = ComparisonFeature>,
253    ) -> Result<Self, ComparisonSnapshotError> {
254        let items = items.into_iter().collect::<Vec<_>>();
255        if items.len() > MAX_COMPARISON_ITEMS {
256            return Err(ComparisonSnapshotError::TooManyItems {
257                maximum: MAX_COMPARISON_ITEMS,
258            });
259        }
260        let features = features.into_iter().collect::<Vec<_>>();
261        if features.len() > MAX_COMPARISON_FEATURES {
262            return Err(ComparisonSnapshotError::TooManyFeatures {
263                maximum: MAX_COMPARISON_FEATURES,
264            });
265        }
266
267        let mut item_ids = HashSet::new();
268        for item in &items {
269            if !item_ids.insert(item.id.clone()) {
270                return Err(ComparisonSnapshotError::DuplicateItemId(item.id.clone()));
271            }
272        }
273
274        let mut feature_ids = HashSet::new();
275        for feature in &features {
276            if !feature_ids.insert(feature.id.clone()) {
277                return Err(ComparisonSnapshotError::DuplicateFeatureId(
278                    feature.id.clone(),
279                ));
280            }
281            let mut value_item_ids = HashSet::new();
282            for value in feature.values.iter() {
283                if !value_item_ids.insert(value.item_id.clone()) {
284                    return Err(ComparisonSnapshotError::DuplicateValueItemId {
285                        feature_id: feature.id.clone(),
286                        item_id: value.item_id.clone(),
287                    });
288                }
289                if !item_ids.contains(&value.item_id) {
290                    return Err(ComparisonSnapshotError::UnknownItemId {
291                        feature_id: feature.id.clone(),
292                        item_id: value.item_id.clone(),
293                    });
294                }
295            }
296        }
297
298        Ok(Self {
299            items: items.into(),
300            features: features.into(),
301        })
302    }
303
304    /// Returns the immutable items in consumer order.
305    pub fn items(&self) -> &[ComparisonItem] {
306        &self.items
307    }
308
309    /// Returns the immutable features in consumer order.
310    pub fn features(&self) -> &[ComparisonFeature] {
311        &self.features
312    }
313
314    /// Finds a feature by stable application ID.
315    pub fn feature(&self, feature_id: &str) -> Option<&ComparisonFeature> {
316        self.features
317            .iter()
318            .find(|feature| feature.id == feature_id)
319    }
320}
321
322/// Element construction counts for one draw of the virtualized render.
323///
324/// This surface is bounded at [`MAX_COMPARISON_FEATURES`] rows by
325/// [`MAX_COMPARISON_ITEMS`] columns, but the feature list is virtualized: a
326/// draw constructs only the rows the viewport shows, so the counts must track
327/// that window — never the whole bound, and never a running total across
328/// redraws. The eager render this replaced built all 1,536 cells of the
329/// maximum shape every draw. Counting is test-only: production builds carry
330/// neither the field nor the branch.
331#[cfg(test)]
332#[derive(Default)]
333struct ComparisonConstructionCounts {
334    feature_rows: std::cell::Cell<usize>,
335    cells: std::cell::Cell<usize>,
336}
337
338#[cfg(test)]
339impl ComparisonConstructionCounts {
340    /// Opens a draw. Counts describe one draw, never a running total.
341    fn start_draw(&self) {
342        self.feature_rows.set(0);
343        self.cells.set(0);
344    }
345
346    fn count_feature_row(&self) {
347        self.feature_rows.set(self.feature_rows.get() + 1);
348    }
349
350    fn count_cell(&self) {
351        self.cells.set(self.cells.get() + 1);
352    }
353}
354
355/// A controlled, intentionally bounded feature-comparison surface.
356///
357/// The application owns snapshot progress, highlighted/disabled item state,
358/// and selected item identity. The entity retains only focus, measured row
359/// heights, and overflow presentation state.
360///
361/// Feature rows are virtualized through `gpui::ListState`: the bound is 128
362/// rows by 12 columns, and rendering all of it eagerly cost far more than a
363/// frame budget because every mounted row is laid out whether or not it is on
364/// screen. `ListState` rather than `gpui_base::v_virtual_list` because rows
365/// wrap to variable heights and the latter needs exact heights up front.
366/// Column headers sit outside the list, so they no longer scroll away
367/// vertically; both they and the rows stay on one horizontally scrolled
368/// canvas, which is what keeps a column aligned with its cells.
369pub struct ComparisonTable {
370    /// Styles the caller put on this component, applied to its own frame.
371    ///
372    /// Last, so a caller outranks the component's defaults - the same rule the
373    /// builder components follow. A wrapper `div` cannot stand in for this:
374    /// a background, a border, or an ink set on a wrapper paints around the
375    /// component rather than on it.
376    style: gpui::StyleRefinement,
377    id: SharedString,
378    label: SharedString,
379    snapshot: Progressive<ComparisonSnapshot>,
380    selected_item_id: Option<SharedString>,
381    focused_item_id: Option<SharedString>,
382    focus_handle: FocusHandle,
383    focus_engaged: bool,
384    horizontal_scroll: ScrollHandle,
385    feature_list: ListState,
386    /// Rem size the cached row heights were measured against.
387    resolved_layout: ResolvedLayoutKey,
388    _focus_subscriptions: Vec<gpui::Subscription>,
389    #[cfg(test)]
390    construction: ComparisonConstructionCounts,
391}
392
393impl ComparisonTable {
394    /// Creates an empty controlled comparison table with stable identity.
395    pub fn new(
396        id: impl Into<SharedString>,
397        label: impl Into<SharedString>,
398        window: &mut Window,
399        cx: &mut Context<Self>,
400    ) -> Self {
401        let empty = ComparisonSnapshot {
402            items: Arc::from([]),
403            features: Arc::from([]),
404        };
405        let focus_handle = cx.focus_handle();
406        let focus_subscriptions = vec![
407            cx.on_focus(&focus_handle, window, |this, _, cx| {
408                this.focus_engaged = true;
409                this.reveal_focused_item(cx);
410            }),
411            cx.on_blur(&focus_handle, window, |this, _, _| {
412                this.focus_engaged = false;
413            }),
414        ];
415        Self {
416            style: gpui::StyleRefinement::default(),
417            id: id.into(),
418            label: label.into(),
419            snapshot: Progressive::pending(empty),
420            selected_item_id: None,
421            focused_item_id: None,
422            focus_handle,
423            focus_engaged: false,
424            horizontal_scroll: ScrollHandle::new(),
425            feature_list: ListState::new(
426                0,
427                ListAlignment::Top,
428                window.rem_size() * FEATURE_OVERDRAW_REM,
429            ),
430            resolved_layout: ResolvedLayoutKey::default(),
431            _focus_subscriptions: focus_subscriptions,
432            #[cfg(test)]
433            construction: ComparisonConstructionCounts::default(),
434        }
435    }
436
437    /// Returns the `(feature rows, value cells)` the most recent draw built.
438    #[cfg(test)]
439    fn construction_counts(&self) -> (usize, usize) {
440        (
441            self.construction.feature_rows.get(),
442            self.construction.cells.get(),
443        )
444    }
445
446    /// Replaces the controlled progressive comparison snapshot.
447    pub fn set_snapshot(
448        &mut self,
449        snapshot: Progressive<ComparisonSnapshot>,
450        _: &mut Window,
451        cx: &mut Context<Self>,
452    ) {
453        let previous_feature_ids = self
454            .snapshot
455            .content()
456            .features
457            .iter()
458            .map(|feature| feature.id.clone())
459            .collect::<Vec<_>>();
460        self.snapshot = snapshot;
461        self.reconcile_feature_list(&previous_feature_ids);
462        if self.selected_item_id.as_ref().is_some_and(|selected| {
463            !self
464                .snapshot
465                .content()
466                .items
467                .iter()
468                .any(|item| item.id == *selected && item.state != ComparisonItemState::Disabled)
469        }) {
470            self.selected_item_id = None;
471        }
472        if self.focused_item_id.as_ref().is_none_or(|focused| {
473            !self
474                .snapshot
475                .content()
476                .items
477                .iter()
478                .any(|item| item.id == *focused && item.state != ComparisonItemState::Disabled)
479        }) {
480            self.focused_item_id = self
481                .snapshot
482                .content()
483                .items
484                .iter()
485                .find(|item| item.state != ComparisonItemState::Disabled)
486                .map(|item| item.id.clone());
487        }
488        if self.focus_engaged {
489            self.reveal_focused_item(cx);
490        }
491        cx.notify();
492    }
493
494    /// Points the virtual list at the new feature sequence.
495    ///
496    /// A snapshot whose feature identities are unchanged is a content update —
497    /// a streaming comparison filling in values — so it keeps the reader's
498    /// scroll position and only invalidates the heights it may have changed.
499    /// Any other snapshot is a different document, and starts at the top.
500    fn reconcile_feature_list(&mut self, previous_feature_ids: &[SharedString]) {
501        let features = &self.snapshot.content().features;
502        let same_sequence = previous_feature_ids.len() == features.len()
503            && previous_feature_ids
504                .iter()
505                .zip(features.iter())
506                .all(|(previous, feature)| *previous == feature.id);
507        if same_sequence {
508            if !features.is_empty() {
509                self.feature_list.remeasure_items(0..features.len());
510            }
511        } else {
512            self.feature_list.reset(features.len());
513        }
514    }
515
516    /// Re-measures feature rows after the window's rem size changed.
517    ///
518    /// Row heights cache wrapped text laid out at the previous rem, and no
519    /// snapshot reports a zoom, so nothing else invalidates them. The anchor
520    /// policy is this surface's own: the feature that was first on screen
521    /// stays first, because a comparison is read from wherever the reader
522    /// left it rather than from either end.
523    fn resolve_layout(&mut self, rem_size: Pixels, cx: &mut Context<Self>) {
524        if !self.resolved_layout.observe(rem_size) {
525            return;
526        }
527        let offset = self.feature_list.logical_scroll_top();
528        let anchor = self
529            .snapshot
530            .content()
531            .features
532            .get(offset.item_ix)
533            .map(|feature| (feature.id.clone(), offset.offset_in_item));
534
535        self.feature_list.remeasure();
536        if let Some((anchor_id, offset_in_item)) = anchor
537            && let Some(item_ix) = self
538                .snapshot
539                .content()
540                .features
541                .iter()
542                .position(|feature| feature.id == anchor_id)
543        {
544            self.feature_list.scroll_to(ListOffset {
545                item_ix,
546                offset_in_item,
547            });
548        }
549        cx.notify();
550    }
551
552    /// Replaces the controlled selected item when the stable item is enabled.
553    pub fn set_selected_item(
554        &mut self,
555        item_id: impl Into<SharedString>,
556        _: &mut Window,
557        cx: &mut Context<Self>,
558    ) {
559        let item_id = item_id.into();
560        if !self
561            .snapshot
562            .content()
563            .items
564            .iter()
565            .any(|item| item.id == item_id && item.state != ComparisonItemState::Disabled)
566        {
567            return;
568        }
569        self.selected_item_id = Some(item_id);
570        cx.notify();
571    }
572
573    /// Clears the current controlled item selection without changing focus.
574    pub fn clear_selected_item(&mut self, cx: &mut Context<Self>) {
575        self.selected_item_id = None;
576        cx.notify();
577    }
578
579    /// Returns the current controlled selected stable item ID.
580    pub fn selected_item_id(&self) -> Option<&str> {
581        self.selected_item_id.as_deref()
582    }
583
584    /// Returns the current progressive comparison snapshot.
585    pub fn snapshot(&self) -> &Progressive<ComparisonSnapshot> {
586        &self.snapshot
587    }
588
589    /// Moves keyboard focus to an enabled stable item and reveals its column.
590    pub fn focus_item(&mut self, item_id: &str, window: &mut Window, cx: &mut Context<Self>) {
591        let Some(index) = self
592            .snapshot
593            .content()
594            .items
595            .iter()
596            .position(|item| item.id == item_id && item.state != ComparisonItemState::Disabled)
597        else {
598            return;
599        };
600        self.focused_item_id = Some(self.snapshot.content().items[index].id.clone());
601        self.focus_engaged = true;
602        self.reveal_item(index, cx);
603        self.focus_handle.focus(window, cx);
604        cx.notify();
605    }
606
607    fn request_selection(&self, item_id: SharedString, cx: &mut Context<Self>) {
608        if self
609            .snapshot
610            .content()
611            .items
612            .iter()
613            .any(|item| item.id == item_id && item.state != ComparisonItemState::Disabled)
614        {
615            cx.emit(ComparisonTableEvent::SelectionRequested {
616                id: self.id.clone(),
617                item_id,
618            });
619        }
620    }
621
622    fn move_item_focus(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
623        let enabled = self
624            .snapshot
625            .content()
626            .items
627            .iter()
628            .enumerate()
629            .filter(|(_, item)| item.state != ComparisonItemState::Disabled)
630            .map(|(index, _)| index)
631            .collect::<Vec<_>>();
632        if enabled.is_empty() {
633            return;
634        }
635        let current = self
636            .focused_item_id
637            .as_ref()
638            .and_then(|focused| {
639                self.snapshot
640                    .content()
641                    .items
642                    .iter()
643                    .position(|item| item.id == *focused)
644            })
645            .and_then(|index| enabled.iter().position(|enabled| *enabled == index))
646            .unwrap_or_default();
647        let next = (current as isize + delta).rem_euclid(enabled.len() as isize) as usize;
648        let index = enabled[next];
649        self.focused_item_id = Some(self.snapshot.content().items[index].id.clone());
650        self.focus_engaged = true;
651        self.reveal_item(index, cx);
652        self.focus_handle.focus(window, cx);
653        cx.notify();
654    }
655
656    fn reveal_item(&self, index: usize, cx: &App) {
657        let (feature_width, item_width, _) = comparison_layout(cx);
658        let current = self.horizontal_scroll.offset();
659        self.horizontal_scroll.set_offset(gpui::point(
660            -(feature_width + item_width * index as f32),
661            current.y,
662        ));
663    }
664
665    fn reveal_focused_item(&self, cx: &App) {
666        let Some(index) =
667            self.focused_item_id.as_ref().and_then(|focused| {
668                self.snapshot.content().items.iter().position(|item| {
669                    item.id == *focused && item.state != ComparisonItemState::Disabled
670                })
671            })
672        else {
673            return;
674        };
675        self.reveal_item(index, cx);
676    }
677
678    /// Scrolls the stable feature row into the vertical viewport.
679    pub fn scroll_to_feature(&mut self, feature_id: &str, cx: &mut Context<Self>) {
680        let Some(index) = self
681            .snapshot
682            .content()
683            .features
684            .iter()
685            .position(|feature| feature.id == feature_id)
686        else {
687            return;
688        };
689        // The list indexes features directly: column headers are no longer
690        // items in the scrolled sequence, so nothing is added to the index.
691        //
692        // A row already whole on screen must not move — revealing a feature
693        // the reader is looking at should be a no-op. Anywhere else the row
694        // is anchored to the top of the viewport, which is exact whether or
695        // not the rows in between have ever been measured. Reaching for a
696        // pixel goal instead (`scroll_to_reveal_item`) reads heights the
697        // virtual list has not measured yet and lands short of a distant row.
698        // An unmeasured row reports no bounds, and that is a scroll.
699        let viewport = self.feature_list.viewport_bounds();
700        if self
701            .feature_list
702            .bounds_for_item(index)
703            .is_some_and(|row| row.top() >= viewport.top() && row.bottom() <= viewport.bottom())
704        {
705            return;
706        }
707        self.feature_list.scroll_to(ListOffset {
708            item_ix: index,
709            offset_in_item: Pixels::ZERO,
710        });
711        cx.notify();
712    }
713}
714
715/// Typed application intent emitted by a comparison table.
716#[derive(Debug, Clone, PartialEq, Eq)]
717pub enum ComparisonTableEvent {
718    /// An enabled item was chosen through pointer, keyboard, or accessibility activation.
719    SelectionRequested {
720        /// Stable comparison table ID.
721        id: SharedString,
722        /// Stable comparison item ID.
723        item_id: SharedString,
724    },
725}
726
727impl EventEmitter<ComparisonTableEvent> for ComparisonTable {}
728
729impl Focusable for ComparisonTable {
730    fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
731        self.focus_handle.clone()
732    }
733}
734
735fn comparison_table_frame(
736    table_id: &str,
737    label: impl Into<SharedString>,
738) -> gpui::Stateful<gpui::Div> {
739    let identity: SharedString = format!("comparison-table-root:{table_id}").into();
740    div()
741        .id(identity.clone())
742        .debug_selector(move || identity.to_string())
743        .role(Role::Table)
744        .aria_label(label.into())
745}
746
747fn comparison_layout(cx: &App) -> (gpui::Pixels, gpui::Pixels, gpui::Pixels) {
748    let spacing = cx.theme().semantic_tokens().spacing;
749    (
750        spacing.xxl * 6. + spacing.xl,
751        spacing.xxl * 5. + spacing.lg,
752        spacing.xxl * 2. + spacing.sm,
753    )
754}
755
756fn comparison_item_header_frame(
757    table_id: &str,
758    item: &ComparisonItem,
759    selected: bool,
760) -> gpui::Stateful<gpui::Div> {
761    let debug_id: SharedString = format!("comparison-item-header:{table_id}:{}", item.id).into();
762    let state_label = match item.state {
763        ComparisonItemState::Default => None,
764        ComparisonItemState::Highlighted => Some("Recommended"),
765        ComparisonItemState::Disabled => Some("Unavailable"),
766    };
767    let accessible_label: SharedString = state_label
768        .map(|state| format!("{}; {state}", item.label))
769        .unwrap_or_else(|| item.label.to_string())
770        .into();
771    div()
772        .id(debug_id.clone())
773        .debug_selector(move || debug_id.to_string())
774        .role(Role::ColumnHeader)
775        .aria_label(accessible_label)
776        .aria_selected(selected)
777        .when_some(item.description.clone(), |header, description| {
778            header.aria_description(description)
779        })
780}
781
782fn comparison_item_control(
783    table_id: &str,
784    item: &ComparisonItem,
785    window: &mut Window,
786    cx: &mut App,
787) -> gpui_base::Button {
788    let debug_id: SharedString = format!("comparison-item-control:{table_id}:{}", item.id).into();
789    outlined_control_with_wrapping_label(
790        debug_id.clone(),
791        format!("Select {}", item.label),
792        item.label.clone(),
793        window,
794        cx,
795    )
796    .debug_selector(move || debug_id.to_string())
797    .disabled(item.state == ComparisonItemState::Disabled)
798    .when_some(item.description.clone(), |button, description| {
799        button.aria_description(description)
800    })
801}
802
803fn comparison_status_frame(
804    table_id: &str,
805    role: Role,
806    label: SharedString,
807) -> gpui::Stateful<gpui::Div> {
808    let identity = format!("comparison-table-status:{table_id}");
809    div()
810        .id(identity.clone())
811        .debug_selector(move || identity.clone())
812        .role(role)
813        .aria_label(label)
814}
815fn comparison_feature_row_frame(
816    table_id: &str,
817    feature: &ComparisonFeature,
818) -> gpui::Stateful<gpui::Div> {
819    let identity = format!("comparison-feature:{table_id}:{}", feature.id);
820    div()
821        .id(identity.clone())
822        .debug_selector(move || identity.clone())
823        .role(Role::Row)
824        .aria_label(feature.label.clone())
825        .when_some(feature.description.clone(), |row, description| {
826            row.aria_description(description)
827        })
828}
829
830fn comparison_feature_header_frame(
831    table_id: &str,
832    feature: &ComparisonFeature,
833) -> gpui::Stateful<gpui::Div> {
834    div()
835        .id(format!(
836            "comparison-feature-label:{table_id}:{}",
837            feature.id
838        ))
839        .role(Role::RowHeader)
840        .aria_label(feature.label.clone())
841        .when_some(feature.description.clone(), |header, description| {
842            header.aria_description(description)
843        })
844}
845
846fn comparison_cell_frame(
847    table_id: &str,
848    feature_id: &str,
849    item: &ComparisonItem,
850    display: SharedString,
851) -> gpui::Stateful<gpui::Div> {
852    let identity = format!("comparison-cell:{table_id}:{feature_id}:{}", item.id);
853    div()
854        .id(identity.clone())
855        .debug_selector(move || identity.clone())
856        .role(Role::Cell)
857        .aria_label(format!("{}: {display}", item.label))
858        .aria_value(display)
859}
860
861impl ComparisonTable {
862    /// Builds one feature row on demand for the virtual list.
863    ///
864    /// Only rows inside the viewport and its overdraw reach this, which is
865    /// what keeps a frame's cost proportional to what is on screen rather
866    /// than to the whole bounded snapshot. Every cell still carries the same
867    /// selectable Markdown and the same semantics an eagerly built row did.
868    fn render_feature_row(
869        &mut self,
870        index: usize,
871        _: &mut Window,
872        cx: &mut Context<Self>,
873    ) -> AnyElement {
874        let snapshot = self.snapshot.content();
875        let (Some(feature), items) = (
876            snapshot.features.get(index).cloned(),
877            snapshot.items.clone(),
878        ) else {
879            return div().hidden().into_any_element();
880        };
881        #[cfg(test)]
882        self.construction.count_feature_row();
883        let tokens = cx.theme().semantic_tokens();
884        let (feature_width, item_width, _) = comparison_layout(cx);
885
886        comparison_feature_row_frame(&self.id, &feature)
887            .flex()
888            // A virtualized row is measured on its own, so it has to be told
889            // it spans the grid; without that its columns have no width to
890            // divide and each falls back to its floor, leaving the body
891            // narrower than the header above it.
892            .w_full()
893            .border_t_1()
894            .border_color(cx.theme().border)
895            .child(
896                comparison_feature_header_frame(&self.id, &feature)
897                    .w(feature_width)
898                    .flex_none()
899                    .p(tokens.spacing.sm)
900                    .child(
901                        TextView::markdown(
902                            format!("comparison-feature-copy:{}:{}", self.id, feature.id),
903                            escape_markdown_text(&feature.label),
904                        )
905                        .selectable(true),
906                    )
907                    .when_some(feature.description.clone(), |header, description| {
908                        header.child(
909                            TextView::markdown(
910                                format!(
911                                    "comparison-feature-description:{}:{}",
912                                    self.id, feature.id
913                                ),
914                                escape_markdown_text(&description),
915                            )
916                            .selectable(true),
917                        )
918                    }),
919            )
920            .children(items.iter().map(|item| {
921                #[cfg(test)]
922                self.construction.count_cell();
923                let display: SharedString = feature
924                    .value(&item.id)
925                    .map(|value| value.display.clone())
926                    .unwrap_or_else(|| "Not specified".into());
927                comparison_cell_frame(&self.id, &feature.id, item, display.clone())
928                    // Fluid, and for the reason the header gives: a cell holds
929                    // the column its header names, so the two divide the width
930                    // by the same rule or they stop lining up.
931                    .flex_1()
932                    .min_w(item_width)
933                    .p(tokens.spacing.sm)
934                    .border_l_1()
935                    .border_color(cx.theme().border)
936                    .when(item.state == ComparisonItemState::Highlighted, |cell| {
937                        cell.bg(cx.theme().accent)
938                    })
939                    .child(
940                        TextView::markdown(
941                            format!(
942                                "comparison-cell-copy:{}:{}:{}",
943                                self.id, feature.id, item.id
944                            ),
945                            escape_markdown_text(&display),
946                        )
947                        .selectable(true),
948                    )
949            }))
950            .into_any_element()
951    }
952}
953
954impl gpui::Styled for ComparisonTable {
955    fn style(&mut self) -> &mut gpui::StyleRefinement {
956        &mut self.style
957    }
958}
959
960impl Render for ComparisonTable {
961    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
962        #[cfg(test)]
963        self.construction.start_draw();
964        // The rem is a resolved-layout input, so a change to it invalidates
965        // every measured row height. Reading it here costs nothing; the
966        // reaction is deferred so that render itself neither mutates nor
967        // notifies.
968        let rem_size = window.rem_size();
969        if !self.resolved_layout.matches(rem_size) {
970            cx.defer_in(window, move |table, _, cx| {
971                table.resolve_layout(rem_size, cx);
972            });
973        }
974        let tokens = cx.theme().semantic_tokens();
975        let owner = cx.weak_entity();
976        let navigation_owner = owner.clone();
977        let items = self.snapshot.content().items.clone();
978        let features = self.snapshot.content().features.clone();
979        let (feature_width, item_width, header_height) = comparison_layout(cx);
980        let status = match self.snapshot.state() {
981            ProgressState::Pending => Some((Role::ProgressIndicator, "Loading comparison".into())),
982            ProgressState::Running => Some((Role::ProgressIndicator, "Updating comparison".into())),
983            ProgressState::Failed(reason) => Some((Role::Alert, reason.clone())),
984            ProgressState::Complete if items.is_empty() || features.is_empty() => {
985                Some((Role::Status, "No comparison data".into()))
986            }
987            ProgressState::Complete => None,
988        };
989
990        comparison_table_frame(&self.id, self.label.clone())
991            .size_full()
992            .min_h_0()
993            .relative()
994            .overflow_hidden()
995            .border_1()
996            .border_color(cx.theme().border)
997            .rounded(tokens.radius.lg)
998            .child(
999                div()
1000                    .id(format!("comparison-table-scroll:{}", self.id))
1001                    .size_full()
1002                    .min_h_0()
1003                    .flex()
1004                    .flex_col()
1005                    .overflow_x_scroll()
1006                    .lock_scroll_axis()
1007                    .track_scroll(&self.horizontal_scroll)
1008                    .policy_horizontal_scrollbar(&self.horizontal_scroll, cx)
1009                    .when_some(status, |surface, (role, label): (Role, SharedString)| {
1010                        // Status states carry the same semantic color language as
1011                        // Task Rows: info for in-flight work, danger for failures,
1012                        // muted for empty. A spinner accompanies loading so the
1013                        // state is visible, not just readable.
1014                        fn status_visuals(role: Role, cx: &App) -> (Hsla, IconName) {
1015                            match role {
1016                                Role::ProgressIndicator => {
1017                                    (cx.theme().info, IconName::LoaderCircle)
1018                                }
1019                                Role::Alert => (cx.theme().danger, IconName::CircleX),
1020                                _ => (cx.theme().muted_foreground, IconName::Dash),
1021                            }
1022                        }
1023                        let spinner = role == Role::ProgressIndicator;
1024                        // A settled empty comparison takes the shared
1025                        // empty-state anatomy; in-flight and failed states
1026                        // keep the inline status row, and loading adds a
1027                        // skeleton in the coming table's shape below it.
1028                        if role == Role::Status {
1029                            return surface.child(
1030                                comparison_status_frame(&self.id, role, label.clone())
1031                                    .p(tokens.spacing.md)
1032                                    .child(crate::surface::empty_state(
1033                                        IconName::Inbox,
1034                                        label,
1035                                        None,
1036                                        cx,
1037                                    )),
1038                            );
1039                        }
1040                        let skeleton_columns = items.len().max(3);
1041                        let (color, text) = (status_visuals(role, cx).0, label.clone());
1042                        surface.child(
1043                            comparison_status_frame(&self.id, role, label.clone())
1044                                .p(tokens.spacing.md)
1045                                .flex()
1046                                .flex_col()
1047                                .gap(tokens.spacing.md)
1048                                .child(
1049                                    h_flex()
1050                                        .gap(tokens.spacing.sm)
1051                                        .items_center()
1052                                        .when(spinner, |row| {
1053                                            let spin =
1054                                                crate::motion::MotionTokens::effective_preference(
1055                                                    cx,
1056                                                ) != crate::motion::MotionPreference::Snap;
1057                                            // The animated element must be the direct
1058                                            // child; wrap the rotating icon in a
1059                                            // fixed-size slot so layout stays stable.
1060                                            let icon = status_visuals(role, cx).1;
1061                                            row.child(
1062                                                div().size_4().child(
1063                                                    Icon::new(icon)
1064                                                        .text_color(color)
1065                                                        .with_visible_animation(
1066                                                            "comparison-status-spinner",
1067                                                            // Frame demand: active only for a
1068                                                            // ProgressIndicator status. Settled
1069                                                            // statuses take the branch below and
1070                                                            // render a still icon. Reduced motion
1071                                                            // holds delta at 0 — an unrotated icon.
1072                                                            MotionTokens::read(cx)
1073                                                                .status_spinner()
1074                                                                .looping(),
1075                                                            move |this, delta| {
1076                                                                let delta =
1077                                                                    if spin { delta } else { 0.0 };
1078                                                                this.rotate(gpui::percentage(delta))
1079                                                            },
1080                                                        )
1081                                                        .into_any_element(),
1082                                                ),
1083                                            )
1084                                        })
1085                                        .when(!spinner, |row| {
1086                                            let icon = status_visuals(role, cx).1;
1087                                            row.child(Icon::new(icon).size_4().text_color(color))
1088                                        })
1089                                        .child(
1090                                            div()
1091                                                .text_token(tokens.typography.sm)
1092                                                .text_color(color)
1093                                                .child(text),
1094                                        ),
1095                                )
1096                                .when(spinner, |frame| {
1097                                    frame.child(div().w_full().child(
1098                                        crate::surface::skeleton_rows(
1099                                            ElementId::from((
1100                                                ElementId::from(self.id.clone()),
1101                                                "loading-skeleton",
1102                                            )),
1103                                            3,
1104                                            skeleton_columns,
1105                                            cx,
1106                                        ),
1107                                    ))
1108                                }),
1109                        )
1110                    })
1111                    .child(
1112                        div()
1113                            .relative()
1114                            .w_full()
1115                            .flex_1()
1116                            .min_h_0()
1117                            // The grid's intrinsic width is feature column + N item
1118                            // columns. Only the outer viewport owns horizontal
1119                            // movement, keeping keyboard reveal, scrollbar drag,
1120                            // headers, and cells on one canvas.
1121                            .min_w(feature_width + item_width * items.len() as f32)
1122                            .child(
1123                                div()
1124                                    .id(format!("comparison-table-rows:{}", self.id))
1125                                    .size_full()
1126                                    .min_h_0()
1127                                    .flex()
1128                                    .flex_col()
1129                                    .role(Role::RowGroup)
1130                                    .child(
1131                                        div()
1132                                            .id(format!("comparison-table-header-row:{}", self.id))
1133                                            .flex()
1134                                            // Column headers sit outside the scrolled
1135                                            // list, so they stay put while features
1136                                            // scroll under them, and their controls
1137                                            // stay reachable from the keyboard.
1138                                            .flex_none()
1139                                            .role(Role::Row)
1140                                            .child(
1141                                                div()
1142                                                    .id(format!(
1143                                                        "comparison-feature-header:{}",
1144                                                        self.id
1145                                                    ))
1146                                                    .w(feature_width)
1147                                                    .flex_none()
1148                                                    .flex()
1149                                                    .flex_col()
1150                                                    .gap(tokens.spacing.xxs)
1151                                                    .p(tokens.spacing.sm)
1152                                                    .role(Role::ColumnHeader)
1153                                                    .aria_label("Feature"),
1154                                            )
1155                                            .children(items.iter().map(|item| {
1156                                                let item_id = item.id.clone();
1157                                                let handler_owner = owner.clone();
1158                                                let selected = self.selected_item_id.as_ref()
1159                                                    == Some(&item.id);
1160                                                // The selection a table mounts
1161                                                // with is placed settled; a
1162                                                // column the user selects fades
1163                                                // its marker in once at the
1164                                                // quick tempo.
1165                                                let acknowledged =
1166                                                    crate::motion::acknowledged_state(
1167                                                        ElementId::Name(
1168                                                            format!(
1169                                                                "comparison-selected:{}:{}",
1170                                                                self.id, item.id
1171                                                            )
1172                                                            .into(),
1173                                                        ),
1174                                                        selected as u64,
1175                                                        window,
1176                                                        cx,
1177                                                    );
1178                                                let focused =
1179                                                    self.focused_item_id.as_ref() == Some(&item.id);
1180                                                let state_label = match item.state {
1181                                                    ComparisonItemState::Default => None,
1182                                                    ComparisonItemState::Highlighted => {
1183                                                        Some("Recommended")
1184                                                    }
1185                                                    ComparisonItemState::Disabled => {
1186                                                        Some("Unavailable")
1187                                                    }
1188                                                };
1189                                                comparison_item_header_frame(
1190                                                    &self.id, item, selected,
1191                                                )
1192                                                // Fluid: the columns divide
1193                                                // whatever width the table is
1194                                                // given, rather than each
1195                                                // taking a fixed slice and
1196                                                // leaving the remainder as a
1197                                                // band of empty table beside
1198                                                // them. The fixed width stays
1199                                                // on as the floor, so a narrow
1200                                                // viewport scrolls sideways
1201                                                // instead of squeezing the
1202                                                // columns past reading.
1203                                                .flex_1()
1204                                                .min_w(item_width)
1205                                                .min_h(header_height)
1206                                                .p(tokens.spacing.sm)
1207                                                .border_l_1()
1208                                                .border_color(cx.theme().border)
1209                                                .when(
1210                                                    item.state == ComparisonItemState::Highlighted,
1211                                                    |header| header.bg(cx.theme().accent),
1212                                                )
1213                                                .when(selected, |header| {
1214                                                    // Selection is the shared
1215                                                    // fill grammar, not a ring:
1216                                                    // an inset rounded wash on
1217                                                    // an absolute overlay, so
1218                                                    // the marker cannot reflow
1219                                                    // the header it sits under.
1220                                                    // Painted first, it stays
1221                                                    // beneath the content.
1222                                                    header.child(
1223                                                        div()
1224                                                            .absolute()
1225                                                            .inset(tokens.spacing.xxs)
1226                                                            .rounded(crate::surface::nested_radius(
1227                                                                tokens.radius.lg,
1228                                                                tokens.spacing.xxs,
1229                                                                tokens.radius.sm,
1230                                                            ))
1231                                                            .bg(cx
1232                                                                .theme()
1233                                                                .list_active
1234                                                                .opacity(acknowledged)),
1235                                                    )
1236                                                })
1237                                                .child(
1238                                                    comparison_item_control(
1239                                                        &self.id, item, window, cx,
1240                                                    )
1241                                                    .w_full()
1242                                                    .tab_stop(focused)
1243                                                    .when(focused, |button| {
1244                                                        button.track_focus(&self.focus_handle)
1245                                                    })
1246                                                    .on_click(move |_, window, cx| {
1247                                                        let _ = handler_owner.update(
1248                                                            cx,
1249                                                            |table, cx| {
1250                                                                table.focus_item(
1251                                                                    &item_id, window, cx,
1252                                                                );
1253                                                                table.request_selection(
1254                                                                    item_id.clone(),
1255                                                                    cx,
1256                                                                );
1257                                                            },
1258                                                        );
1259                                                    }),
1260                                                )
1261                                                .when_some(
1262                                                    item.description.clone(),
1263                                                    |header, description| {
1264                                                        header.child(
1265                                            TextView::markdown(
1266                                                format!(
1267                                                    "comparison-item-description:{}:{}",
1268                                                    self.id, item.id
1269                                                ),
1270                                                escape_markdown_text(&description),
1271                                            )
1272                                            .selectable(true),
1273                                        )
1274                                                    },
1275                                                )
1276                                                .when_some(state_label, |header, state| {
1277                                                    header.child(
1278                                                        crate::surface::hint(state, cx)
1279                                                            .mt(tokens.spacing.xs),
1280                                                    )
1281                                                })
1282                                                .child(
1283                                                    div()
1284                                                        .mt(tokens.spacing.xs)
1285                                                        .text_token(tokens.typography.xs)
1286                                                        .opacity(if selected {
1287                                                            acknowledged
1288                                                        } else {
1289                                                            0.0
1290                                                        })
1291                                                        .child("Selected"),
1292                                                )
1293                                            }))
1294                                            .on_key_down(move |event, window, cx| {
1295                                                let delta = match event.keystroke.key.as_str() {
1296                                                    "left" => -1,
1297                                                    "right" => 1,
1298                                                    _ => return,
1299                                                };
1300                                                let _ = navigation_owner.update(cx, |table, cx| {
1301                                                    table.move_item_focus(delta, window, cx);
1302                                                });
1303                                                cx.stop_propagation();
1304                                            }),
1305                                    )
1306                                    .child(
1307                                        div()
1308                                            .id(format!("comparison-table-features:{}", self.id))
1309                                            .relative()
1310                                            .flex_1()
1311                                            .min_h_0()
1312                                            .overflow_hidden()
1313                                            .policy_vertical_scrollbar(&self.feature_list, cx)
1314                                            .child(
1315                                                list(
1316                                                    self.feature_list.clone(),
1317                                                    cx.processor(Self::render_feature_row),
1318                                                )
1319                                                .size_full(),
1320                                            ),
1321                                    ),
1322                            )
1323                            .child(list_scroll_mask(&self.feature_list)),
1324                    ),
1325            )
1326            .child(
1327                ScrollableMask::new(Axis::Horizontal, &self.horizontal_scroll)
1328                    .id((ElementId::from(self.id.clone()), "horizontal-scroll-mask")),
1329            )
1330            .refine_style(&self.style)
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use gpui::{
1338        Element as _, Entity, RenderOnce as _, TestAppContext, VisualTestContext, accesskit,
1339        canvas, px, size,
1340    };
1341    use std::{
1342        sync::{Arc, Mutex},
1343        time::{Duration, Instant},
1344    };
1345
1346    type CapturedNodes = Arc<Mutex<Option<[(Option<Role>, accesskit::Node); 9]>>>;
1347
1348    struct SemanticsProbe {
1349        captured: CapturedNodes,
1350    }
1351
1352    impl Render for SemanticsProbe {
1353        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1354            let captured = self.captured.clone();
1355            canvas(
1356                move |_, window, cx| {
1357                    let root = comparison_table_frame("plans", "Plan comparison").into_element();
1358                    let business = ComparisonItem::new("business", "Business")
1359                        .description("Best for growing teams")
1360                        .state(ComparisonItemState::Highlighted);
1361                    let header =
1362                        comparison_item_header_frame("plans", &business, true).into_element();
1363                    let enabled_control = comparison_item_control("plans", &business, window, cx)
1364                        .on_click(|_, _, _| {})
1365                        .render(window, cx)
1366                        .into_element();
1367                    let control = comparison_item_control(
1368                        "plans",
1369                        &ComparisonItem::new("legacy", "Legacy")
1370                            .state(ComparisonItemState::Disabled),
1371                        window,
1372                        cx,
1373                    )
1374                    .on_click(|_, _, _| {})
1375                    .render(window, cx)
1376                    .into_element();
1377                    let disabled_header = comparison_item_header_frame(
1378                        "plans",
1379                        &ComparisonItem::new("legacy", "Legacy")
1380                            .state(ComparisonItemState::Disabled),
1381                        false,
1382                    )
1383                    .into_element();
1384                    let feature = ComparisonFeature::new("support", "Priority support")
1385                        .description("Response within four hours");
1386                    let row = comparison_feature_row_frame("plans", &feature).into_element();
1387                    let row_header =
1388                        comparison_feature_header_frame("plans", &feature).into_element();
1389                    let cell =
1390                        comparison_cell_frame("plans", "support", &business, "Included".into())
1391                            .into_element();
1392                    let status = comparison_status_frame(
1393                        "plans",
1394                        Role::ProgressIndicator,
1395                        "Loading comparison".into(),
1396                    )
1397                    .into_element();
1398                    let mut root_node = accesskit::Node::new(Role::Unknown);
1399                    root.write_a11y_info(&mut root_node);
1400                    let mut header_node = accesskit::Node::new(Role::Unknown);
1401                    header.write_a11y_info(&mut header_node);
1402                    let mut control_node = accesskit::Node::new(Role::Unknown);
1403                    control.write_a11y_info(&mut control_node);
1404                    let mut disabled_header_node = accesskit::Node::new(Role::Unknown);
1405                    disabled_header.write_a11y_info(&mut disabled_header_node);
1406                    let mut enabled_control_node = accesskit::Node::new(Role::Unknown);
1407                    enabled_control.write_a11y_info(&mut enabled_control_node);
1408                    let mut row_node = accesskit::Node::new(Role::Unknown);
1409                    row.write_a11y_info(&mut row_node);
1410                    let mut row_header_node = accesskit::Node::new(Role::Unknown);
1411                    row_header.write_a11y_info(&mut row_header_node);
1412                    let mut cell_node = accesskit::Node::new(Role::Unknown);
1413                    cell.write_a11y_info(&mut cell_node);
1414                    let mut status_node = accesskit::Node::new(Role::Unknown);
1415                    status.write_a11y_info(&mut status_node);
1416                    *captured.lock().expect("capture mutex should be available") = Some([
1417                        (root.a11y_role(), root_node),
1418                        (header.a11y_role(), header_node),
1419                        (control.a11y_role(), control_node),
1420                        (disabled_header.a11y_role(), disabled_header_node),
1421                        (enabled_control.a11y_role(), enabled_control_node),
1422                        (row.a11y_role(), row_node),
1423                        (row_header.a11y_role(), row_header_node),
1424                        (cell.a11y_role(), cell_node),
1425                        (status.a11y_role(), status_node),
1426                    ]);
1427                },
1428                |_, _, _, _| {},
1429            )
1430        }
1431    }
1432
1433    #[gpui::test]
1434    fn comparison_semantics_name_table_selected_highlight_and_disabled_action(
1435        cx: &mut TestAppContext,
1436    ) {
1437        cx.update(crate::init);
1438        let captured = Arc::new(Mutex::new(None));
1439        let result = captured.clone();
1440        let (_, cx) = cx.add_window_view(move |_, _| SemanticsProbe { captured });
1441        cx.update(|window, cx| window.draw(cx).clear(cx));
1442        let [
1443            root,
1444            header,
1445            disabled,
1446            disabled_header,
1447            enabled,
1448            row,
1449            row_header,
1450            cell,
1451            status,
1452        ] = result
1453            .lock()
1454            .expect("capture mutex should be available")
1455            .take()
1456            .expect("semantic nodes should be captured");
1457
1458        assert_eq!(root.0, Some(Role::Table));
1459        assert_eq!(root.1.label(), Some("Plan comparison"));
1460        assert_eq!(header.0, Some(Role::ColumnHeader));
1461        assert_eq!(header.1.label(), Some("Business; Recommended"));
1462        assert_eq!(header.1.description(), Some("Best for growing teams"));
1463        assert_eq!(header.1.is_selected(), Some(true));
1464        assert_eq!(disabled.0, Some(Role::Button));
1465        assert!(!disabled.1.supports_action(accesskit::Action::Click));
1466        assert_eq!(disabled_header.1.label(), Some("Legacy; Unavailable"));
1467        assert_eq!(enabled.0, Some(Role::Button));
1468        assert_eq!(enabled.1.label(), Some("Select Business"));
1469        assert_eq!(enabled.1.description(), Some("Best for growing teams"));
1470        assert!(enabled.1.supports_action(accesskit::Action::Click));
1471        assert_eq!(row.0, Some(Role::Row));
1472        assert_eq!(row.1.label(), Some("Priority support"));
1473        assert_eq!(row.1.description(), Some("Response within four hours"));
1474        assert_eq!(row_header.0, Some(Role::RowHeader));
1475        assert_eq!(row_header.1.label(), Some("Priority support"));
1476        assert_eq!(cell.0, Some(Role::Cell));
1477        assert_eq!(cell.1.label(), Some("Business: Included"));
1478        assert_eq!(cell.1.value(), Some("Included"));
1479        assert_eq!(status.0, Some(Role::ProgressIndicator));
1480        assert_eq!(status.1.label(), Some("Loading comparison"));
1481    }
1482
1483    /// Viewport used by every construction measurement below. A fixed size
1484    /// keeps the numbers comparable between runs; it is smaller than the
1485    /// bounded grid on both axes, so the measurement covers the scrolled case
1486    /// the surface actually ships in.
1487    const MEASURED_VIEWPORT: (f32, f32) = (900., 600.);
1488
1489    /// Largest feature-row window a draw at [`MEASURED_VIEWPORT`] may build.
1490    ///
1491    /// The viewport is 600 px, a described row measures around 173 px, and
1492    /// the list keeps [`FEATURE_OVERDRAW_REM`] measured past the fold, so the
1493    /// window runs to about five rows. Eight leaves room for measurement to
1494    /// move without coming anywhere near [`MAX_COMPARISON_FEATURES`], which
1495    /// is the number this guard exists to keep a frame away from.
1496    const MAX_WINDOWED_ROWS: usize = 8;
1497
1498    /// Timed draws per shape: enough for a stable median and a p95 that is a
1499    /// real sample rather than the single worst draw.
1500    const TIMED_DRAWS: usize = 40;
1501
1502    /// Draws discarded before timing. The first draws pay one-time font, glyph,
1503    /// and text-layout caching that no steady-state frame repeats.
1504    const WARMUP_DRAWS: usize = 5;
1505
1506    /// Worst-case content for a bounded shape: every item and every feature
1507    /// carries a description, so each row builds the most selectable Markdown
1508    /// views the contract allows, and every cell has a value to render.
1509    /// Item columns divide the table's width, and a cell sits under its own
1510    /// header.
1511    ///
1512    /// The columns were fixed slices: three plans in a table with room for
1513    /// five left a band of empty table beside them, which reads as a table
1514    /// that failed to draw rather than one with room to spare. They divide
1515    /// what they are given now, and the same rule has to reach the rows -
1516    /// they are virtualized and measured apart from the header, so getting
1517    /// this wrong shows up as a body narrower than the header above it.
1518    #[gpui::test]
1519    fn item_columns_fill_the_table_and_cells_line_up_with_their_header(cx: &mut TestAppContext) {
1520        let (table, cx) = measured_table(cx);
1521        draw_shape(&table, cx, 3, 3);
1522        redraw(&table, cx);
1523
1524        let frame = cx
1525            .debug_bounds("comparison-table-root:measured")
1526            .expect("the table should render");
1527        let last = cx
1528            .debug_bounds("comparison-item-header:measured:item-2")
1529            .expect("the last column header should render");
1530        let cell = cx
1531            .debug_bounds("comparison-cell:measured:feature-0:item-2")
1532            .expect("the last column's first cell should render");
1533
1534        // The frame's own border is the only thing that may stand between the
1535        // last column and the edge of the table.
1536        assert_eq!(
1537            frame.right() - last.right(),
1538            px(1.),
1539            "the columns leave a band of empty table: header ends {:?} short of the frame",
1540            frame.right() - last.right()
1541        );
1542        assert!(
1543            last.size.width > comparison_layout_item_width(cx),
1544            "a column that fills is wider than the floor it may not go below: {:?}",
1545            last.size.width
1546        );
1547        assert_eq!(
1548            (cell.left(), cell.right()),
1549            (last.left(), last.right()),
1550            "a cell must sit under the header of its own column: cell {cell:?}, header {last:?}"
1551        );
1552    }
1553
1554    #[gpui::test]
1555    fn selecting_a_column_keeps_the_header_height_and_acknowledges_once(cx: &mut TestAppContext) {
1556        let (table, cx) = measured_table(cx);
1557        draw_shape(&table, cx, 4, 3);
1558        cx.executor()
1559            .advance_clock(std::time::Duration::from_secs(2));
1560        redraw(&table, cx);
1561        let header = |cx: &mut VisualTestContext| {
1562            cx.debug_bounds("comparison-item-header:measured:item-1")
1563                .expect("the header should render")
1564        };
1565        let unselected = header(cx);
1566
1567        crate::motion::take_reveal_frame_requests();
1568        cx.update(|window, cx| {
1569            table.update(cx, |table, cx| {
1570                table.set_selected_item("item-1", window, cx);
1571            });
1572        });
1573        redraw(&table, cx);
1574        assert!(
1575            crate::motion::take_reveal_frame_requests() > 0,
1576            "selecting a column must acknowledge the new marker"
1577        );
1578        cx.executor()
1579            .advance_clock(std::time::Duration::from_secs(2));
1580        redraw(&table, cx);
1581        let selected = header(cx);
1582        assert_eq!(
1583            selected.size.height, unselected.size.height,
1584            "the Selected label lives in a reserved slot; selection must not reflow the header"
1585        );
1586    }
1587
1588    fn bounded_snapshot(features: usize, items: usize) -> ComparisonSnapshot {
1589        let item_ids = (0..items)
1590            .map(|index| SharedString::from(format!("item-{index}")))
1591            .collect::<Vec<_>>();
1592        ComparisonSnapshot::try_new(
1593            item_ids.iter().enumerate().map(|(index, id)| {
1594                ComparisonItem::new(id.clone(), format!("Plan {index}"))
1595                    .description("Readable supporting copy for one comparison column")
1596            }),
1597            (0..features).map(|feature| {
1598                ComparisonFeature::new(format!("feature-{feature}"), format!("Feature {feature}"))
1599                    .description(
1600                        "Selectable supporting detail that makes every feature row variable height",
1601                    )
1602                    .values(
1603                        item_ids
1604                            .iter()
1605                            .map(|id| ComparisonValue::new(id.clone(), format!("Value {feature}"))),
1606                    )
1607            }),
1608        )
1609        .expect("the fixture should stay inside the bounded contract")
1610    }
1611
1612    /// Applies one bounded shape, draws it, and returns what that draw built.
1613    fn draw_shape(
1614        table: &Entity<ComparisonTable>,
1615        cx: &mut VisualTestContext,
1616        features: usize,
1617        items: usize,
1618    ) -> (usize, usize) {
1619        cx.update(|window, cx| {
1620            table.update(cx, |table, cx| {
1621                table.set_snapshot(
1622                    Progressive::complete(bounded_snapshot(features, items)),
1623                    window,
1624                    cx,
1625                );
1626            });
1627            window.draw(cx).clear(cx);
1628        });
1629        table.read_with(cx, |table, _| table.construction_counts())
1630    }
1631
1632    /// Redraws without touching the snapshot — the frame a selection, scroll,
1633    /// or theme change produces — and returns what that draw built.
1634    fn redraw(table: &Entity<ComparisonTable>, cx: &mut VisualTestContext) -> (usize, usize) {
1635        cx.update(|window, cx| {
1636            table.update(cx, |_, cx| cx.notify());
1637            window.draw(cx).clear(cx);
1638        });
1639        table.read_with(cx, |table, _| table.construction_counts())
1640    }
1641
1642    /// Redraws once and returns the wall-clock cost of the whole draw, layout
1643    /// and paint included, alongside what it built.
1644    fn timed_redraw(
1645        table: &Entity<ComparisonTable>,
1646        cx: &mut VisualTestContext,
1647    ) -> (Duration, (usize, usize)) {
1648        let elapsed = cx.update(|window, cx| {
1649            table.update(cx, |_, cx| cx.notify());
1650            let started = Instant::now();
1651            window.draw(cx).clear(cx);
1652            started.elapsed()
1653        });
1654        (
1655            elapsed,
1656            table.read_with(cx, |table, _| table.construction_counts()),
1657        )
1658    }
1659
1660    /// Nearest-rank percentile over ascending samples.
1661    fn percentile(ascending: &[Duration], percent: usize) -> Duration {
1662        let rank = (ascending.len() * percent).div_ceil(100).max(1);
1663        ascending[rank - 1]
1664    }
1665
1666    /// A table in a window sized to [`MEASURED_VIEWPORT`], ready to be drawn.
1667    /// The width one item column may not go below, for the fluid-column test.
1668    fn comparison_layout_item_width(cx: &mut VisualTestContext) -> gpui::Pixels {
1669        cx.update(|_, cx| comparison_layout(cx).1)
1670    }
1671
1672    fn measured_table(
1673        cx: &mut TestAppContext,
1674    ) -> (Entity<ComparisonTable>, &mut VisualTestContext) {
1675        cx.update(crate::init);
1676        let (table, cx) = cx.add_window_view(|window, cx| {
1677            ComparisonTable::new("measured", "Measured comparison", window, cx)
1678        });
1679        let cx: &mut VisualTestContext = cx;
1680        cx.simulate_resize(size(px(MEASURED_VIEWPORT.0), px(MEASURED_VIEWPORT.1)));
1681        (table, cx)
1682    }
1683
1684    /// A bound the render draws in full is not a bound on frame cost. Every
1685    /// mounted row is laid out whether or not it is on screen, so before
1686    /// virtualization a maximum-shape draw laid out 128 rows to show three
1687    /// and a half of them.
1688    ///
1689    /// This is the guard on the window: a draw builds the rows the viewport
1690    /// can show plus overdraw, one cell per item in each, and neither the
1691    /// snapshot's length nor the number of frames drawn widens it.
1692    #[gpui::test]
1693    fn a_draw_builds_the_viewport_window_rather_than_the_whole_snapshot(cx: &mut TestAppContext) {
1694        let (table, cx) = measured_table(cx);
1695
1696        let (maximum_rows, maximum_cells) =
1697            draw_shape(&table, cx, MAX_COMPARISON_FEATURES, MAX_COMPARISON_ITEMS);
1698        assert!(
1699            (1..=MAX_WINDOWED_ROWS).contains(&maximum_rows),
1700            "the maximum snapshot must build a window, not its whole self: \
1701             {maximum_rows} rows"
1702        );
1703        assert_eq!(
1704            maximum_cells,
1705            maximum_rows * MAX_COMPARISON_ITEMS,
1706            "every constructed row must build one cell per item"
1707        );
1708
1709        // Frames after the first must not creep upward: the window is the
1710        // viewport's size, and redrawing does not widen it or accumulate.
1711        for _ in 0..3 {
1712            let (rows, cells) = redraw(&table, cx);
1713            assert!(
1714                (1..=maximum_rows).contains(&rows),
1715                "a redraw must not widen the window: {rows} rows"
1716            );
1717            assert_eq!(cells, rows * MAX_COMPARISON_ITEMS);
1718        }
1719
1720        let (typical_rows, typical_cells) = draw_shape(&table, cx, 24, 6);
1721        assert_eq!(
1722            typical_cells,
1723            typical_rows * 6,
1724            "a typical snapshot must also build one cell per item"
1725        );
1726        // The point of the whole change: rows of the same height cost the
1727        // same per frame whether the snapshot holds 24 of them or 128.
1728        assert_eq!(
1729            typical_rows, maximum_rows,
1730            "the window must follow the viewport, not the snapshot's length"
1731        );
1732
1733        assert_eq!(
1734            draw_shape(&table, cx, 0, 0),
1735            (0, 0),
1736            "an empty snapshot must build no rows at all"
1737        );
1738    }
1739
1740    /// Draw cost at the bounded maximum, printed rather than asserted:
1741    /// wall-clock is machine- and load-dependent, so a threshold here would
1742    /// gate on noise. The release budget belongs to the release-profile
1743    /// harness. What this test does enforce is that every timed draw built
1744    /// only the visible window, so the numbers describe the work they claim.
1745    ///
1746    /// Ignored by default because it exists for its printout, not an
1747    /// assertion. Run it deliberately, in the profile you mean to measure:
1748    ///
1749    /// ```text
1750    /// cargo test --release -p gpui-ai --lib comparison -- --ignored --nocapture
1751    /// ```
1752    ///
1753    /// The record that decided this surface's shape, both on desktop Windows
1754    /// at a 900x600 viewport, 40 draws after warmup:
1755    ///
1756    /// ```text
1757    /// eager (removed), debug profile, whole bound built every draw:
1758    /// 128 x 12 -> 1536 cells | p50 1463..1523 ms | p95 1555..1817 ms
1759    /// construction was ~18 ms of a ~1140 ms draw; the rest was layout and
1760    /// paint of a 22,144 px grid with ~3.5 rows on screen (~29x overdraw).
1761    ///
1762    /// virtualized (current), release profile, viewport window only:
1763    /// 128 x 12 ->  3 rows / 36 cells | p50 2.420 ms | p95 2.525 ms
1764    ///  24 x  6 ->  3 rows / 18 cells | p50 1.400 ms | p95 1.478 ms
1765    /// ```
1766    ///
1767    /// The eager debug row is not comparable to the release row as a
1768    /// profile; it is kept because the ~29x overdraw it documents is
1769    /// profile-independent and is the reason the list is virtualized.
1770    #[gpui::test]
1771    #[ignore = "measurement, not a gate: exists for its printout"]
1772    fn the_bounded_maximum_draw_cost_is_measured_against_the_frame_budget(cx: &mut TestAppContext) {
1773        let (table, cx) = measured_table(cx);
1774        // The profile comes from the invocation, so the header must not name
1775        // one: this same test prints debug or release numbers.
1776        let mut report = format!(
1777            "\ncomparison draw cost — profile per invocation, \
1778             {}x{} viewport, {TIMED_DRAWS} draws after {WARMUP_DRAWS} warmup\n",
1779            MEASURED_VIEWPORT.0, MEASURED_VIEWPORT.1
1780        );
1781
1782        for (features, items) in [(MAX_COMPARISON_FEATURES, MAX_COMPARISON_ITEMS), (24, 6)] {
1783            draw_shape(&table, cx, features, items);
1784            for _ in 0..WARMUP_DRAWS {
1785                redraw(&table, cx);
1786            }
1787
1788            let mut samples = Vec::with_capacity(TIMED_DRAWS);
1789            let mut built = (0, 0);
1790            for _ in 0..TIMED_DRAWS {
1791                let (elapsed, counts) = timed_redraw(&table, cx);
1792                assert!(
1793                    counts.0 <= MAX_WINDOWED_ROWS && counts.1 == counts.0 * items,
1794                    "a timed draw must build a window of whole rows: {counts:?}"
1795                );
1796                built = counts;
1797                samples.push(elapsed);
1798            }
1799            samples.sort();
1800
1801            let milliseconds = |sample: Duration| sample.as_secs_f64() * 1000.;
1802            report.push_str(&format!(
1803                "  {features:>3} features x {items:>2} items -> {:>4} rows, {:>5} cells built | \
1804                 p50 {:>8.3} ms | p95 {:>8.3} ms\n",
1805                built.0,
1806                built.1,
1807                milliseconds(percentile(&samples, 50)),
1808                milliseconds(percentile(&samples, 95)),
1809            ));
1810        }
1811
1812        eprintln!("{report}");
1813    }
1814}