1use gpui::{
40 App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
41 prelude::FluentBuilder, px, relative,
42};
43use gpui_kit_semantics::{NodeSpec, Role, Semantic};
44use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, TypeScale};
45
46use crate::display::badge::{Badge, Tone};
47use crate::foundation::{Ident, StyledExt, text};
48use crate::strings::{ActiveStrings, StringKey};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Basis {
57 Measured,
59 Estimated,
61}
62
63impl Basis {
64 pub fn name(self) -> &'static str {
66 match self {
67 Self::Measured => "measured",
68 Self::Estimated => "estimated",
69 }
70 }
71
72 pub fn is_estimate(self) -> bool {
73 matches!(self, Self::Estimated)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq)]
79pub struct Quantity {
80 basis: Basis,
81 amount: f64,
82 text: SharedString,
83}
84
85impl Quantity {
86 pub fn measured(amount: f64, text: impl Into<SharedString>) -> Self {
88 Self {
89 basis: Basis::Measured,
90 amount,
91 text: text.into(),
92 }
93 }
94
95 pub fn estimated(amount: f64, text: impl Into<SharedString>) -> Self {
97 Self {
98 basis: Basis::Estimated,
99 amount,
100 text: text.into(),
101 }
102 }
103
104 pub fn basis(&self) -> Basis {
105 self.basis
106 }
107
108 pub fn amount(&self) -> f64 {
110 self.amount
111 }
112
113 pub fn text(&self) -> &SharedString {
115 &self.text
116 }
117
118 pub fn display(&self, cx: &App) -> SharedString {
121 cx.strings().format(
122 match self.basis {
123 Basis::Measured => StringKey::CostMeasured,
124 Basis::Estimated => StringKey::CostEstimated,
125 },
126 &[&self.text],
127 )
128 }
129}
130
131#[derive(Debug, Clone, PartialEq)]
133pub enum Reading {
134 Known(Quantity),
136 Unavailable { reason: Option<SharedString> },
139}
140
141impl Reading {
142 pub fn measured(amount: f64, text: impl Into<SharedString>) -> Self {
143 Self::Known(Quantity::measured(amount, text))
144 }
145
146 pub fn estimated(amount: f64, text: impl Into<SharedString>) -> Self {
147 Self::Known(Quantity::estimated(amount, text))
148 }
149
150 pub fn unavailable() -> Self {
151 Self::Unavailable { reason: None }
152 }
153
154 pub fn unavailable_because(reason: impl Into<SharedString>) -> Self {
156 Self::Unavailable {
157 reason: Some(reason.into()),
158 }
159 }
160
161 pub fn quantity(&self) -> Option<&Quantity> {
162 match self {
163 Self::Known(quantity) => Some(quantity),
164 Self::Unavailable { .. } => None,
165 }
166 }
167
168 pub fn name(&self) -> &'static str {
170 match self {
171 Self::Known(quantity) => quantity.basis().name(),
172 Self::Unavailable { .. } => "unavailable",
173 }
174 }
175
176 fn display(&self, cx: &App) -> SharedString {
177 match self {
178 Self::Known(quantity) => quantity.display(cx),
179 Self::Unavailable { reason } => reason
180 .clone()
181 .unwrap_or_else(|| cx.strings().text(StringKey::CostUnavailable)),
182 }
183 }
184
185 fn is_estimate(&self) -> bool {
186 self.quantity()
187 .is_some_and(|quantity| quantity.basis().is_estimate())
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Default)]
193pub enum Limit {
194 Known(Quantity),
196 #[default]
198 Unknown,
199}
200
201impl Limit {
202 pub fn measured(amount: f64, text: impl Into<SharedString>) -> Self {
203 Self::Known(Quantity::measured(amount, text))
204 }
205
206 pub fn estimated(amount: f64, text: impl Into<SharedString>) -> Self {
207 Self::Known(Quantity::estimated(amount, text))
208 }
209
210 pub fn quantity(&self) -> Option<&Quantity> {
211 match self {
212 Self::Known(quantity) => Some(quantity),
213 Self::Unknown => None,
214 }
215 }
216
217 pub fn is_known(&self) -> bool {
218 matches!(self, Self::Known(_))
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct LastVerified(SharedString);
230
231impl LastVerified {
232 pub fn at(when: impl Into<SharedString>) -> Self {
233 Self(when.into())
234 }
235
236 fn sentence(&self, cx: &App) -> SharedString {
237 cx.strings().format(StringKey::CostLastVerified, &[&self.0])
238 }
239}
240
241#[derive(Debug, Clone, PartialEq)]
243pub struct CostLine {
244 id: SharedString,
245 label: SharedString,
246 reading: Reading,
247 stale: Option<LastVerified>,
248}
249
250impl CostLine {
251 pub fn new(
252 id: impl Into<SharedString>,
253 label: impl Into<SharedString>,
254 reading: Reading,
255 ) -> Self {
256 Self {
257 id: id.into(),
258 label: label.into(),
259 reading,
260 stale: None,
261 }
262 }
263
264 pub fn stale(mut self, verified: LastVerified) -> Self {
267 self.stale = Some(verified);
268 self
269 }
270
271 pub fn id(&self) -> &SharedString {
272 &self.id
273 }
274
275 pub fn reading(&self) -> &Reading {
276 &self.reading
277 }
278}
279
280#[derive(Debug, Clone, IntoElement)]
282pub struct CostMeter {
283 ident: Ident,
284 label: Option<SharedString>,
285 lines: Vec<CostLine>,
286}
287
288impl CostMeter {
289 pub fn new(ident: impl Into<Ident>) -> Self {
290 Self {
291 ident: ident.into(),
292 label: None,
293 lines: Vec::new(),
294 }
295 }
296
297 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
298 self.label = Some(label.into());
299 self
300 }
301
302 pub fn line(mut self, line: CostLine) -> Self {
303 self.lines.push(line);
304 self
305 }
306
307 pub fn lines(mut self, lines: impl IntoIterator<Item = CostLine>) -> Self {
308 self.lines.extend(lines);
309 self
310 }
311}
312
313impl RenderOnce for CostMeter {
314 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
315 let theme = cx.theme().clone();
316 let rows: Vec<_> = self
317 .lines
318 .iter()
319 .map(|line| {
320 let ident = self.ident.child(line.id.as_ref());
321 let display = line.reading.display(cx);
322 let unavailable = line.reading.quantity().is_none();
323
324 div()
325 .column()
326 .w_full()
327 .gap_token(&theme, Space::Xs)
328 .child(
329 div()
330 .row()
331 .w_full()
332 .justify_between()
333 .gap_token(&theme, Space::Sm)
334 .child(
335 text(&theme, TypeScale::Label, line.label.clone())
336 .text_tone(&theme, TextTone::Muted),
337 )
338 .child(
339 div()
340 .row()
341 .gap_token(&theme, Space::Sm)
342 .child(
343 text(&theme, TypeScale::Label, display.clone()).text_tone(
344 &theme,
345 if unavailable {
346 TextTone::Faint
347 } else {
348 TextTone::Primary
349 },
350 ),
351 )
352 .when(line.reading.is_estimate(), |element| {
353 element.child(estimate_mark(&ident, cx))
354 }),
355 ),
356 )
357 .children(
358 line.stale
359 .as_ref()
360 .map(|verified| stale_line(&ident, &theme, verified.sentence(cx), cx)),
361 )
362 .semantic_in(
363 cx,
364 NodeSpec::new(ident.semantic_id(), Role::Status)
365 .text(line.label.clone())
366 .value(display)
367 .parent(self.ident.semantic_id()),
368 )
369 })
370 .collect();
371
372 div()
373 .column()
374 .w_full()
375 .gap_token(&theme, Space::Sm)
376 .p_token(&theme, Space::Md)
377 .radius(&theme, Radius::Card)
378 .frame(&theme, Surface::Panel, Elevation::Raised)
379 .when_some(self.label.clone(), |element, label| {
380 element.child(
381 text(&theme, TypeScale::Caption, label).text_tone(&theme, TextTone::Faint),
382 )
383 })
384 .children(rows)
385 .semantic_in(
386 cx,
387 NodeSpec::new(self.ident.semantic_id(), Role::Group)
388 .when_label(self.label)
389 .value(SharedString::from(self.lines.len().to_string())),
390 )
391 }
392}
393
394#[derive(Debug, Clone, IntoElement)]
399pub struct ContextGauge {
400 ident: Ident,
401 label: Option<SharedString>,
402 used: Reading,
403 limit: Limit,
404 stale: Option<LastVerified>,
405}
406
407impl ContextGauge {
408 pub fn new(ident: impl Into<Ident>, used: Reading) -> Self {
410 Self {
411 ident: ident.into(),
412 label: None,
413 used,
414 limit: Limit::Unknown,
415 stale: None,
416 }
417 }
418
419 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
420 self.label = Some(label.into());
421 self
422 }
423
424 pub fn limit(mut self, limit: Limit) -> Self {
425 self.limit = limit;
426 self
427 }
428
429 pub fn stale(mut self, verified: LastVerified) -> Self {
431 self.stale = Some(verified);
432 self
433 }
434
435 pub fn fraction(&self) -> Option<f32> {
440 let used = self.used.quantity()?;
441 let limit = self.limit.quantity()?;
442 (limit.amount() > 0.0).then(|| (used.amount() / limit.amount()).clamp(0.0, 1.0) as f32)
443 }
444}
445
446impl RenderOnce for ContextGauge {
447 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
448 let theme = cx.theme().clone();
449 let fraction = self.fraction();
450 let used_display = self.used.display(cx);
451 let unavailable = self.used.quantity().is_none();
452 let estimated = self.used.is_estimate()
453 || self
454 .limit
455 .quantity()
456 .is_some_and(|limit| limit.basis().is_estimate());
457
458 let reading = match self.limit.quantity() {
459 Some(limit) => cx.strings().format(
460 StringKey::CountOfTotal,
461 &[&used_display, &limit.display(cx)],
462 ),
463 None => used_display.clone(),
464 };
465
466 let spec = match fraction {
467 Some(fraction) => NodeSpec::new(self.ident.semantic_id(), Role::Progress)
468 .range(0.0, 1.0, fraction)
469 .value(reading.clone()),
470 None => NodeSpec::new(self.ident.semantic_id(), Role::Status)
473 .value(reading.clone())
474 .invalid(false),
475 };
476 let spec = match self.label.clone() {
477 Some(label) => spec.text(label),
478 None => spec,
479 };
480
481 let limit_note = (!self.limit.is_known()).then(|| {
482 let words = cx.strings().text(StringKey::ContextUnknownLimit);
483 text(&theme, TypeScale::Caption, words.clone())
484 .text_tone(&theme, TextTone::Faint)
485 .semantic_in(
486 cx,
487 NodeSpec::new(self.ident.child("limit").semantic_id(), Role::Text)
488 .text(words)
489 .value(SharedString::new_static("unknown-limit"))
490 .parent(self.ident.semantic_id()),
491 )
492 });
493
494 div()
495 .column()
496 .w_full()
497 .gap_token(&theme, Space::Xs)
498 .child(
499 div()
500 .row()
501 .w_full()
502 .justify_between()
503 .gap_token(&theme, Space::Sm)
504 .children(self.label.clone().map(|label| {
505 text(&theme, TypeScale::Label, label).text_tone(&theme, TextTone::Muted)
506 }))
507 .child(
508 div()
509 .row()
510 .gap_token(&theme, Space::Sm)
511 .child(text(&theme, TypeScale::Label, reading).text_tone(
512 &theme,
513 if unavailable {
514 TextTone::Faint
515 } else {
516 TextTone::Primary
517 },
518 ))
519 .when(estimated, |element| {
520 element.child(estimate_mark(&self.ident, cx))
521 }),
522 ),
523 )
524 .child(
525 div()
526 .relative()
527 .w_full()
528 .h(px(4.0))
529 .rounded_full()
530 .overflow_hidden()
531 .bg(theme.colors.hairline_strong)
532 .when_some(fraction, |element, fraction| {
536 element.child(
537 div()
538 .absolute()
539 .left_0()
540 .top_0()
541 .bottom_0()
542 .rounded_full()
543 .bg(theme.colors.accent)
544 .w(relative(fraction)),
545 )
546 }),
547 )
548 .children(limit_note)
549 .children(
550 self.stale
551 .as_ref()
552 .map(|verified| stale_line(&self.ident, &theme, verified.sentence(cx), cx)),
553 )
554 .semantic_in(cx, spec)
555 }
556}
557
558fn estimate_mark(ident: &Ident, cx: &App) -> Badge {
560 Badge::new(cx.strings().text(StringKey::CostEstimateMark))
561 .tone(Tone::Info)
562 .id(ident.child("estimate"))
563}
564
565fn stale_line(
566 ident: &Ident,
567 theme: &gpui_kit_theme::Theme,
568 sentence: SharedString,
569 cx: &App,
570) -> impl IntoElement {
571 text(theme, TypeScale::Caption, sentence.clone())
572 .text_color(theme.colors.warning)
573 .semantic_in(
574 cx,
575 NodeSpec::new(ident.child("stale").semantic_id(), Role::Status)
576 .text(sentence)
577 .value(SharedString::new_static("stale"))
578 .parent(ident.semantic_id()),
579 )
580}
581
582trait NodeSpecExt {
583 fn when_label(self, label: Option<SharedString>) -> Self;
584}
585
586impl NodeSpecExt for NodeSpec {
587 fn when_label(self, label: Option<SharedString>) -> Self {
588 match label {
589 Some(label) => self.text(label),
590 None => self,
591 }
592 }
593}