1use crate::control::composed_button;
4use crate::decoration::{DecoratedExt as _, Decoration};
5use crate::handlers::SharedHandler;
6use crate::surface::CardFrameExt as _;
7use crate::theme::SemanticStyledExt as _;
8use gpui::{
9 App, ClickEvent, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
10 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
11 prelude::FluentBuilder as _,
12};
13use gpui_component::{
14 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, v_flex,
15};
16use std::rc::Rc;
17
18#[derive(IntoElement)]
30pub struct ContextCard {
31 id: SharedString,
32 style: StyleRefinement,
33 decoration: Decoration,
34 source: SharedString,
35 snippet: Option<SharedString>,
36 relevance: Option<f32>,
37 on_event: Option<SharedHandler<ContextCardEvent>>,
38}
39
40impl ContextCard {
41 pub fn decoration(mut self, decoration: Decoration) -> Self {
47 self.decoration = decoration;
48 self
49 }
50
51 pub fn new(id: impl Into<SharedString>, source: impl Into<SharedString>) -> Self {
54 Self {
55 id: id.into(),
56 style: StyleRefinement::default(),
57 decoration: Decoration::default(),
58 source: source.into(),
59 snippet: None,
60 relevance: None,
61 on_event: None,
62 }
63 }
64
65 pub fn snippet(mut self, snippet: impl Into<SharedString>) -> Self {
67 self.snippet = Some(snippet.into());
68 self
69 }
70
71 pub fn relevance(mut self, relevance: f32) -> Self {
74 self.relevance = Some(relevance.clamp(0.0, 1.0));
75 self
76 }
77
78 pub fn on_event(
80 mut self,
81 handler: impl Fn(&ContextCardEvent, &mut Window, &mut App) + 'static,
82 ) -> Self {
83 self.on_event = Some(Rc::new(handler));
84 self
85 }
86
87 fn accessibility_label(&self) -> SharedString {
88 match self.relevance {
89 Some(relevance) => {
90 format!("{}, {:.0}% relevance", self.source, relevance * 100.0).into()
91 }
92 None => self.source.clone(),
93 }
94 }
95}
96
97impl Styled for ContextCard {
98 fn style(&mut self) -> &mut StyleRefinement {
99 &mut self.style
100 }
101}
102
103impl RenderOnce for ContextCard {
104 fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
105 let tokens = cx.theme().semantic_tokens();
106 let mut decoration = std::mem::take(&mut self.decoration);
109 let clickable = self.on_event.is_some();
110 let event = ContextCardEvent::Opened {
111 id: self.id.clone(),
112 };
113 let accessibility_label = self.accessibility_label();
114 let accessibility_description = self.snippet.clone();
115
116 let content = v_flex()
119 .gap(tokens.spacing.sm)
120 .child(
121 h_flex()
122 .items_center()
123 .gap(tokens.spacing.sm)
124 .child(
125 Icon::new(IconName::File)
126 .xsmall()
127 .text_color(cx.theme().muted_foreground),
128 )
129 .child(
130 crate::surface::hint(self.source, cx)
131 .flex_1()
132 .truncate()
133 .font_family(cx.theme().mono_font_family.clone()),
134 )
135 .when_some(self.relevance, |this, relevance| {
136 this.child(
137 div()
138 .flex_none()
139 .text_token(tokens.typography.xs)
140 .text_color(cx.theme().muted_foreground)
141 .child(format!("{:.0}%", relevance * 100.0)),
142 )
143 })
144 .when(clickable, |this| {
145 this.child(
146 Icon::new(IconName::ExternalLink)
147 .xsmall()
148 .text_color(cx.theme().muted_foreground),
149 )
150 }),
151 )
152 .when_some(self.snippet, |this, snippet| {
153 this.child(
154 div()
155 .text_token(tokens.typography.sm)
156 .text_color(cx.theme().foreground)
157 .line_clamp(3)
158 .child(snippet),
159 )
160 });
161
162 if let Some(handler) = self.on_event {
168 composed_button(self.id.clone(), accessibility_label)
169 .w_full()
170 .flex()
171 .flex_col()
172 .items_start()
173 .justify_start()
174 .p(tokens.spacing.lg)
175 .card_frame(cx)
176 .decoration_under(&mut decoration)
177 .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
178 .active(|style| style.bg(cx.theme().accent))
179 .focus_visible(|style| style.border_color(cx.theme().ring))
180 .when_some(accessibility_description, |this, description| {
181 this.aria_description(description)
182 })
183 .child(content.w_full())
184 .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
185 .decoration_over(&mut decoration)
186 .refine_style(&self.style)
187 .into_any_element()
188 } else {
189 content
190 .id(self.id)
191 .role(Role::Group)
192 .aria_label(accessibility_label)
193 .when_some(accessibility_description, |this, description| {
194 this.aria_description(description)
195 })
196 .w_full()
197 .p(tokens.spacing.lg)
198 .card_frame(cx)
199 .decoration_under(&mut decoration)
200 .decoration_over(&mut decoration)
201 .refine_style(&self.style)
202 .into_any_element()
203 }
204 }
205}
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum ContextCardEvent {
209 Opened {
211 id: SharedString,
213 },
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn accessibility_name_includes_source_and_relevance() {
222 assert_eq!(
223 ContextCard::new("ctx", "pricing.md")
224 .relevance(0.923)
225 .accessibility_label(),
226 "pricing.md, 92% relevance"
227 );
228 assert_eq!(
229 ContextCard::new("ctx", "pricing.md").accessibility_label(),
230 "pricing.md"
231 );
232 }
233}