1use 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
28pub const MAX_COMPARISON_ITEMS: usize = 12;
30
31pub const MAX_COMPARISON_FEATURES: usize = 128;
33
34const FEATURE_OVERDRAW_REM: f32 = 16.;
41
42#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
44pub enum ComparisonItemState {
45 #[default]
47 Default,
48 Highlighted,
50 Disabled,
52}
53
54#[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 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 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
76 self.description = Some(description.into());
77 self
78 }
79
80 pub fn state(mut self, state: ComparisonItemState) -> Self {
82 self.state = state;
83 self
84 }
85
86 pub fn id(&self) -> &str {
88 &self.id
89 }
90
91 pub fn label(&self) -> &str {
93 &self.label
94 }
95
96 pub fn description_text(&self) -> Option<&str> {
98 self.description.as_deref()
99 }
100
101 pub fn item_state(&self) -> ComparisonItemState {
103 self.state
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ComparisonValue {
110 item_id: SharedString,
111 display: SharedString,
112 included: Option<bool>,
113}
114
115impl ComparisonValue {
116 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 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 pub fn item_id(&self) -> &str {
136 &self.item_id
137 }
138
139 pub fn display(&self) -> &str {
141 &self.display
142 }
143
144 pub fn is_included(&self) -> Option<bool> {
146 self.included
147 }
148}
149
150#[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 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 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
172 self.description = Some(description.into());
173 self
174 }
175
176 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 pub fn id(&self) -> &str {
184 &self.id
185 }
186
187 pub fn label(&self) -> &str {
189 &self.label
190 }
191
192 pub fn description_text(&self) -> Option<&str> {
194 self.description.as_deref()
195 }
196
197 pub fn value(&self, item_id: &str) -> Option<&ComparisonValue> {
199 self.values.iter().find(|value| value.item_id == item_id)
200 }
201
202 pub fn values_snapshot(&self) -> &[ComparisonValue] {
204 &self.values
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ComparisonSnapshotError {
211 TooManyItems {
213 maximum: usize,
215 },
216 TooManyFeatures {
218 maximum: usize,
220 },
221 DuplicateItemId(SharedString),
223 DuplicateFeatureId(SharedString),
225 DuplicateValueItemId {
227 feature_id: SharedString,
229 item_id: SharedString,
231 },
232 UnknownItemId {
234 feature_id: SharedString,
236 item_id: SharedString,
238 },
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ComparisonSnapshot {
244 items: Arc<[ComparisonItem]>,
245 features: Arc<[ComparisonFeature]>,
246}
247
248impl ComparisonSnapshot {
249 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 pub fn items(&self) -> &[ComparisonItem] {
306 &self.items
307 }
308
309 pub fn features(&self) -> &[ComparisonFeature] {
311 &self.features
312 }
313
314 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#[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 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
355pub struct ComparisonTable {
370 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 resolved_layout: ResolvedLayoutKey,
388 _focus_subscriptions: Vec<gpui::Subscription>,
389 #[cfg(test)]
390 construction: ComparisonConstructionCounts,
391}
392
393impl ComparisonTable {
394 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 #[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 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 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 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 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 pub fn clear_selected_item(&mut self, cx: &mut Context<Self>) {
575 self.selected_item_id = None;
576 cx.notify();
577 }
578
579 pub fn selected_item_id(&self) -> Option<&str> {
581 self.selected_item_id.as_deref()
582 }
583
584 pub fn snapshot(&self) -> &Progressive<ComparisonSnapshot> {
586 &self.snapshot
587 }
588
589 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
717pub enum ComparisonTableEvent {
718 SelectionRequested {
720 id: SharedString,
722 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 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 .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 .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 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 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 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 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 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 .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 .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 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 .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 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 const MEASURED_VIEWPORT: (f32, f32) = (900., 600.);
1488
1489 const MAX_WINDOWED_ROWS: usize = 8;
1497
1498 const TIMED_DRAWS: usize = 40;
1501
1502 const WARMUP_DRAWS: usize = 5;
1505
1506 #[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 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 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 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 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 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 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 #[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 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 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 #[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 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}