Files
Ely-GPUI-Components/src/charts/targets.rs
T
ZacharyZhang-NYandClaude Opus 5.5 e482bac865 Capture · Roots: the roots round 2 missed
A sweep that read every render found 28 roots still on w_full,
reached by 64 components: the field button under Select and every
picker, the checked and mention fields, the slider track,
NumberInput, Steps, the state views, the agent previews, and every
chart and finance frame. All drop it. LivePreview's size_full becomes
h_full, since its card no longer fills.

Every host of those roots was listed and read, 231 in all. Five
tests that mounted a picker as the window root sit in a size_full
box, the index manager gives its method select a flex_1 box, and the
notification center and the gallery's state frames stack their state
views in a column so they stretch.

Each dropped root carries a debug selector, and six chapter tests
mount 43 components in a padded card whose column is measured by
content, through layout::tests::narrow_width: each root must take the
full 240px. With w_full back on the roots, all six fail.

AGENTS.md names the roots that keep w_full and why: picture frames
that keep a ratio, and rows that hand a text field its share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:34:01 -04:00

454 lines
16 KiB
Rust

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<f64>,
}
impl Bullet {
pub fn new(label: impl Into<SharedString>, 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<Item = f64>) -> 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<Bullet>,
format: Format,
}
impl BulletChart {
pub fn new(id: impl Into<ElementId>) -> 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<Tiles> =
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<Div> = 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, 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<ElementId>) -> 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<SharedString>, 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<Tiles> =
window.use_keyed_state((self.id.clone(), "rings"), cx, |_, _| Tiles::default());
let glide: Entity<Glide> =
window.use_keyed_state((self.id.clone(), "glide"), cx, |_, _| Glide::default());
let shares: Vec<f64> = 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))
}
}