use std::{ f32::consts::{FRAC_PI_2, TAU}, rc::Rc, }; use gpui::{ App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, Pixels, Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, fill, point, prelude::*, size, }; use super::{ axes::anchored, geometry::Rect, glide::Glide, paint::{at, finish, measure, place, ring, tint}, parts::ChartTooltip, pie::{bearing, toward, wedge}, plot::Format, scale::{compact, nice}, tiles::{Tiles, tracked}, }; use crate::{ motion, theme::{ActiveTheme, Density, Radius, TextSize}, typography::{format, tabular}, }; /// A measure against its target: its value, the target it aims for, and the bands from poor to good it falls in. #[derive(Clone, Debug, PartialEq)] pub struct Bullet { label: SharedString, value: f64, target: f64, bands: Vec, } impl Bullet { pub fn new(label: impl Into, value: f64, target: f64) -> Self { assert!( value.is_finite() && target.is_finite() && value >= 0.0 && target >= 0.0, "a bullet reads values of zero or more" ); Self { label: label.into(), value, target, bands: Vec::new(), } } /// Where each band ends, rising: poor, then fair, then good. pub fn bands(mut self, ends: impl IntoIterator) -> Self { self.bands = ends.into_iter().collect(); assert!( self.bands.windows(2).all(|pair| pair[0] < pair[1]) && self.bands.iter().all(|end| *end > 0.0), "bands end in rising order" ); self } /// The top of this bullet's scale: past its value, its target and its last band. fn top(&self) -> f64 { let most = self .bands .iter() .copied() .fold(self.value.max(self.target), f64::max); nice(0.0, most.max(f64::EPSILON), 4).0.1 } } /// Measures against their targets, a row each: a thin bar for the value over bands from poor to good, and a mark at the target. Hover a row to read it. #[derive(IntoElement)] pub struct BulletChart { base: Div, id: ElementId, bullets: Vec, format: Format, } impl BulletChart { pub fn new(id: impl Into) -> Self { Self { base: div(), id: id.into(), bullets: Vec::new(), format: Rc::new(compact), } } pub fn bullet(mut self, bullet: Bullet) -> Self { self.bullets.push(bullet); self } pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { self.format = Rc::new(format); self } } impl Styled for BulletChart { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for BulletChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let tiles: Entity = window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| Tiles::default()); let (bounds, hover) = ( tiles.read(cx).bounds, tiles.read(cx).pointed(self.bullets.len()), ); let theme = cx.theme(); let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); let (names, row, inset, count) = ( pixels(theme.label_width()), pixels(theme.table_row(Density::Comfortable)), pixels(sizes.inset), self.bullets.len(), ); let length = (f32::from(bounds.size.width) - names - inset).max(0.0); let format = self.format.clone(); let labels: Vec
= self .bullets .iter() .enumerate() .map(|(ix, bullet)| { let reading = format!("{} / {}", format(bullet.value), format(bullet.target)); div() .absolute() .left_0() .top(Pixels::from(row * ix as f32)) .w(Pixels::from(names)) .h(Pixels::from(row)) .flex() .flex_col() .justify_center() .pr_3() .child( div() .truncate() .text_size(theme.text_size(TextSize::Sm)) .text_color(colors.fg) .child(bullet.label.clone()), ) .child( tabular(div()) .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_muted) .child(reading), ) }) .collect(); let tooltip = hover.map(|ix| { let bullet = &self.bullets[ix]; let reach = if bullet.target > 0.0 { format::percent(bullet.value / bullet.target, 0, false) } else { "—".into() }; let card = ChartTooltip::new(bullet.label.clone()) .row(Some(tint(&colors, 0)), "Value", format(bullet.value)) .row(None, "Target", format(bullet.target)) .row(None, "Of target", reach); let x = names + length * (bullet.value / bullet.top()) as f32; anchored( (x, row * ix as f32 + row / 2.0), x > f32::from(bounds.size.width) * 0.6, card, ) }); let rows: Vec<(Vec, f32, f32)> = self .bullets .iter() .map(|bullet| { let share = |value: f64| (value / bullet.top()) as f32; ( bullet.bands.iter().map(|end| share(*end)).collect(), share(bullet.value), share(bullet.target), ) }) .collect(); let (stroke, corner, palette) = ( sizes.stroke.to_pixels(rem), theme.radius(Radius::Sm).to_pixels(rem), colors.clone(), ); let mut root = div() .debug_selector(|| "chart-root".into()) .relative() .h(theme.table_row(Density::Comfortable) * count as f32); root.style().refine(self.base.style()); tracked( root, (self.id.clone(), "bullets").into(), &tiles, move |(_, y)| { let at = (y / row).floor(); (at >= 0.0 && (at as usize) < count).then_some(at as usize) }, ) .child( canvas( |_, _, _| {}, move |bounds, _, window, _| { let origin = bounds.origin; for (ix, (bands, value, target)) in rows.iter().enumerate() { let top = row * ix as f32; let band = |share: f32| Rect { x: names, y: top + row * 0.2, w: length * share, h: row * 0.6, }; for (step, end) in bands.iter().enumerate().rev() { let strength = 0.04 + 0.04 * (bands.len() - 1 - step) as f32; window.paint_quad( fill(place(origin, band(*end)), palette.fg.opacity(strength)) .corner_radii(corner), ); } let bar = Rect { x: names, y: top + row * 0.38, w: length * value, h: row * 0.24, }; let ink = if hover.is_none_or(|on| on == ix) { tint(&palette, 0) } else { tint(&palette, 0).opacity(0.5) }; window.paint_quad(fill(place(origin, bar), ink).corner_radii(corner)); let mark = Bounds::new( at( origin, ( names + length * target - f32::from(stroke) / 2.0, top + row * 0.14, ), ), size(stroke, Pixels::from(row * 0.72)), ); window.paint_quad(fill(mark, palette.fg)); } }, ) .absolute() .inset_0(), ) .children(labels) .children(tooltip) .child(measure(tiles, |tiles| &mut tiles.bounds)) } } /// Goals as rings around one center, each filling clockwise toward its target; the center reads the goal under the pointer, or the first. #[derive(IntoElement)] pub struct ProgressChart { base: Div, id: ElementId, goals: Vec<(SharedString, f64, f64)>, format: Format, } impl ProgressChart { pub fn new(id: impl Into) -> Self { Self { base: div(), id: id.into(), goals: Vec::new(), format: Rc::new(compact), } } /// A goal: how far it has come of its target. pub fn goal(mut self, name: impl Into, value: f64, target: f64) -> Self { assert!( value.is_finite() && value >= 0.0 && target.is_finite() && target > 0.0, "a goal has a positive target and a value of zero or more" ); self.goals.push((name.into(), value, target)); self } pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { self.format = Rc::new(format); self } } impl Styled for ProgressChart { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for ProgressChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let rings: Entity = window.use_keyed_state((self.id.clone(), "rings"), cx, |_, _| Tiles::default()); let glide: Entity = window.use_keyed_state((self.id.clone(), "glide"), cx, |_, _| Glide::default()); let shares: Vec = self .goals .iter() .map(|(_, value, target)| value / target) .collect(); let slow = motion::duration(motion::SLOW, cx); let (glided, moving) = glide.update(cx, |glide, _| { glide.follow(std::slice::from_ref(&shares), slow) }); if moving { window.request_animation_frame(); } let glided = glided.into_iter().next().expect("one row of shares"); let (bounds, hover) = ( rings.read(cx).bounds, rings.read(cx).pointed(self.goals.len()), ); let theme = cx.theme(); let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); let side = f32::from(bounds.size.width).min(f32::from(bounds.size.height)); let count = self.goals.len(); let gap = f32::from(sizes.stroke.to_pixels(rem)) * 2.0; let thick = (side / 2.0 / count.max(1) as f32 - gap) .min(side * 0.09) .max(0.0); let radius = move |ix: usize| side / 2.0 - thick / 2.0 - (thick + gap) * ix as f32; let shown = hover.unwrap_or(0); let format = self.format.clone(); let middle = self.goals.get(shown).map(|(name, _, _)| { div() .absolute() .inset_0() .flex() .flex_col() .items_center() .justify_center() .child( tabular(div()) .text_size(theme.text_size(TextSize::Lg)) .text_color(colors.fg) .child(format::percent(shares[shown], 0, false)), ) .child( div() .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_muted) .child(name.clone()), ) }); let legend = self .goals .iter() .enumerate() .map(|(ix, (name, value, target))| { div() .flex() .items_center() .gap_2() .text_size(theme.text_size(TextSize::Sm)) .when(hover.is_some_and(|on| on != ix), |row| row.opacity(0.5)) .child( div() .size(theme.status_dot()) .rounded_full() .bg(tint(&colors, ix)), ) .child(div().text_color(colors.fg).child(name.clone())) .child(tabular(div()).text_color(colors.fg_muted).child(format!( "{} / {}", format(*value), format(*target) ))) }); let tooltip = hover.map(|ix| { let (name, value, target) = &self.goals[ix]; let card = ChartTooltip::new(name.clone()) .row( Some(tint(&colors, ix)), "Done", format::percent(value / target, 0, false), ) .row(None, "Of", format(*target)); anchored((side / 2.0 + radius(ix), side / 2.0), false, card) }); let palette = colors.clone(); let mut root = div().flex().items_center().gap_8(); root.style().refine(self.base.style()); let dial = tracked( div().relative().flex_none().size(sizes.height * 0.7), (self.id.clone(), "rings").into(), &rings, move |place| { let (_, distance) = bearing((side / 2.0, side / 2.0), place); (0..count).find(|ix| (distance - radius(*ix)).abs() <= thick / 2.0 + gap / 2.0) }, ); root.child( dial.child( canvas( |_, _, _| {}, move |bounds, _, window, _| { let center = bounds.origin + point(Pixels::from(side / 2.0), Pixels::from(side / 2.0)); for (ix, share) in glided.iter().enumerate() { let (middle, ink) = (radius(ix), tint(&palette, ix)); let ink = if hover.is_some_and(|on| on != ix) { ink.opacity(0.35) } else { ink }; let band = (middle - thick / 2.0, middle + thick / 2.0); if let Some(track) = wedge(center, band, (-FRAC_PI_2, TAU), 0.0) { finish(track, palette.fg.opacity(0.06), window); } let sweep = (*share as f32).clamp(0.0, 1.0) * TAU; if sweep <= 0.0 { continue; } if let Some(arc) = wedge(center, band, (-FRAC_PI_2, sweep), 0.0) { finish(arc, ink, window); } for angle in [-FRAC_PI_2, -FRAC_PI_2 + sweep] { ring( toward(center, middle, angle), Pixels::from(thick / 2.0), (ink, ink), Pixels::ZERO, window, ); } } }, ) .absolute() .inset_0(), ) .children(middle) .children(tooltip) .child(measure(rings, |rings| &mut rings.bounds)), ) .child(div().flex().flex_col().gap_2().children(legend)) } }