1use crate::ButtonLabelExt as _;
4use crate::control::ControlMetricsExt as _;
5use crate::decoration::{DecoratedExt as _, Decoration};
6use crate::handlers::Handler;
7use crate::surface::{card, description, eyebrow, title};
8use crate::theme::SemanticStyledExt as _;
9use gpui::{
10 App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
11 RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
12 Window, div, prelude::FluentBuilder as _, relative,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum RecommendationEvent {
18 Accepted {
20 id: SharedString,
22 },
23}
24use crate::motion::MotionTokens;
25use gpui_base::motion::{Transition, transition};
26use gpui_component::{
27 ActiveTheme as _, Sizable as _, StyledExt as _,
28 button::{Button, ButtonVariants as _},
29 h_flex, v_flex,
30};
31
32#[derive(IntoElement)]
46pub struct RecommendationCard {
47 id: SharedString,
48 style: StyleRefinement,
49 decoration: Decoration,
50 title: SharedString,
51 description: Option<SharedString>,
52 confidence: Option<f32>,
53 alternatives: Vec<SharedString>,
54 accept_label: SharedString,
55 on_event: Option<Handler<RecommendationEvent>>,
56}
57
58impl RecommendationCard {
59 pub fn decoration(mut self, decoration: Decoration) -> Self {
65 self.decoration = decoration;
66 self
67 }
68
69 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
71 Self {
72 id: id.into(),
73 style: StyleRefinement::default(),
74 decoration: Decoration::default(),
75 title: title.into(),
76 description: None,
77 confidence: None,
78 alternatives: Vec::new(),
79 accept_label: "Apply".into(),
80 on_event: None,
81 }
82 }
83
84 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
86 self.description = Some(description.into());
87 self
88 }
89
90 pub fn confidence(mut self, confidence: f32) -> Self {
93 self.confidence = Some(confidence.clamp(0.0, 1.0));
94 self
95 }
96
97 pub fn alternatives(
99 mut self,
100 alternatives: impl IntoIterator<Item = impl Into<SharedString>>,
101 ) -> Self {
102 self.alternatives = alternatives.into_iter().map(Into::into).collect();
103 self
104 }
105
106 pub fn on_event(
109 mut self,
110 handler: impl Fn(&RecommendationEvent, &mut Window, &mut App) + 'static,
111 ) -> Self {
112 self.on_event = Some(Box::new(handler));
113 self
114 }
115
116 pub fn accept_label(mut self, label: impl Into<SharedString>) -> Self {
118 self.accept_label = label.into();
119 self
120 }
121}
122
123impl Styled for RecommendationCard {
124 fn style(&mut self) -> &mut StyleRefinement {
125 &mut self.style
126 }
127}
128
129impl RenderOnce for RecommendationCard {
130 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
131 let tokens = cx.theme().semantic_tokens();
132 let mut decoration = std::mem::take(&mut self.decoration);
135 let event = RecommendationEvent::Accepted {
136 id: self.id.clone(),
137 };
138 let accessibility_label = self.title.clone();
139 let accessibility_description = self.description.clone();
140 card(self.id.clone(), cx)
141 .decoration_under(&mut decoration)
142 .role(Role::Group)
143 .aria_label(accessibility_label)
144 .when_some(accessibility_description, |this, description| {
145 this.aria_description(description)
146 })
147 .child(title(self.title, cx))
148 .when_some(self.description, |this, text| {
149 this.child(description(text, cx))
150 })
151 .when_some(self.confidence, |this, confidence| {
152 let meter_color = if confidence < 0.4 {
153 cx.theme().danger
154 } else if confidence < 0.7 {
155 cx.theme().warning
156 } else {
157 cx.theme().success
158 };
159 this.child(
160 h_flex()
161 .id(format!("{}-confidence", self.id))
162 .role(Role::Meter)
163 .aria_label("Confidence")
164 .aria_min_numeric_value(0.)
165 .aria_max_numeric_value(100.)
166 .aria_numeric_value((confidence * 100.0) as f64)
167 .items_center()
168 .gap(tokens.spacing.sm)
169 .child(
170 div()
171 .flex_1()
172 .h_1p5()
173 .rounded(tokens.radius.full)
174 .bg(cx.theme().muted)
175 .child(
176 div()
185 .h_full()
186 .w(relative(transition(
187 (
188 ElementId::from(SharedString::from(format!(
189 "{}-confidence-fill",
190 self.id
191 ))),
192 "value",
193 ),
194 confidence,
195 Transition::new(crate::motion::retarget_duration(
196 cx,
197 MotionTokens::read(cx).standard(),
198 ))
199 .ease(crate::motion::ease_in_out_quart),
200 window,
201 cx,
202 )))
203 .rounded(tokens.radius.full)
204 .bg(meter_color),
205 ),
206 )
207 .child(
208 div()
209 .flex_none()
210 .text_token(tokens.typography.xs)
211 .font_family(cx.theme().mono_font_family.clone())
212 .text_color(cx.theme().muted_foreground)
213 .child(format!("{:.0}%", confidence * 100.0)),
214 ),
215 )
216 })
217 .when(!self.alternatives.is_empty(), |this| {
218 this.child(
219 v_flex()
220 .id(format!("{}-alternatives", self.id))
221 .role(Role::List)
222 .aria_label("Also considered")
223 .gap(tokens.spacing.xs)
224 .child(eyebrow("Also considered", cx))
225 .children(self.alternatives.into_iter().enumerate().map(|(ix, alt)| {
226 let accessibility_label = alt.clone();
227 h_flex()
231 .id(format!("{}-alternative-{ix}", self.id))
232 .role(Role::ListItem)
233 .aria_label(accessibility_label)
234 .items_start()
235 .gap(tokens.spacing.sm)
236 .text_token(tokens.typography.sm)
237 .text_color(cx.theme().muted_foreground)
238 .child(crate::surface::leading_glyph_slot(
239 crate::sizing::slot_md(cx),
240 div()
241 .size_1()
242 .rounded(tokens.radius.full)
243 .bg(cx.theme().muted_foreground),
244 ))
245 .child(div().min_w_0().child(alt))
246 })),
247 )
248 })
249 .when_some(self.on_event, |this, handler| {
250 this.child(
251 h_flex().child(
252 Button::new(format!("{}-accept", self.id))
253 .primary()
254 .small()
255 .control_metrics(cx)
256 .accessibility_id(format!("{}-accept", self.id))
257 .text_label(self.accept_label)
258 .on_click(move |_: &ClickEvent, window, cx| {
259 handler(&event, window, cx)
260 }),
261 ),
262 )
263 })
264 .decoration_over(&mut decoration)
265 .refine_style(&self.style)
266 }
267}