Files
Ely-GPUI-Components/src/charts/flow.rs
T
ZacharyZhang-NYandClaude Opus 5.5 94deeaed52 Port · Build: Zed's gpui at 1a28cff
gpui and gpui_platform now come from Zed's repo by git at 1a28cff,
the code gpui-pre 0.3.7 republishes, so the gallery can later build
for the browser. gpui takes stacker, as 0.2.2 always did, and
gpui_platform takes font-kit, without which macOS draws no text;
runtime_shaders moves to gpui_platform.

The API moved: focus, blur and Tab steps take cx, Corner is Anchor,
shaped lines paint with an alignment, defer_draw takes a content
mask, the inspector registers a factory, async updates return their
value, and wrapped lines are shared, not cloned. Updates through a
window context go through the window, so a closed window still ends
them as it did.

Where the new gpui behaves differently, Ely keeps its old intent.
Holds and reveals under a resting pointer survive typing through
gpui's input-modality-independent hover; previews the keyboard also
moves keep the default, and the calendar's leave ignores a key press.
Enter reaches a button inside the code editor, since gpui now arms a
key click on the press. The event logger skips a lone modifier. A
video and an image grid keep their shape in a flex column under
taffy 0.13, as a picture does.

AGENTS.md records the new stack and the rules this port changes; the
rest of the old gpui and taffy rules are re-tested in T44c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:30:17 -04:00

457 lines
16 KiB
Rust

use std::rc::Rc;
use gpui::{
App, Bounds, Div, ElementId, Entity, HoverListenerMode, InteractiveElement, IntoElement,
MouseMoveEvent, ParentElement, PathBuilder, Pixels, Refineable, RenderOnce, SharedString,
StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, fill, prelude::*,
};
use super::{
axes::anchored,
geometry::Rect,
paint::{at, finish, measure, place, tint},
parts::ChartTooltip,
plot::Format,
scale::compact,
};
use crate::{
theme::{ActiveTheme, Radius, TextSize},
typography::tabular,
};
/// A flow chart's own state: its box and what the pointer is on.
#[derive(Default)]
struct Flows {
bounds: Bounds<Pixels>,
hover: Option<Hover>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Hover {
Node(usize),
Link(usize),
}
/// A link's ribbon: where it leaves its source and meets its target, the tops of both ends, and its thickness.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Ribbon {
pub from: (f32, f32),
pub to: (f32, f32),
pub thick: f32,
}
impl Ribbon {
/// The ribbon's top edge at a share of the way along: a curve level at both ends.
fn top(&self, share: f32) -> (f32, f32) {
let middle = (self.from.0 + self.to.0) / 2.0;
let (a, b, c, d) = (
(self.from.0, self.from.1),
(middle, self.from.1),
(middle, self.to.1),
(self.to.0, self.to.1),
);
let bez = |p: [f32; 4]| {
let t = share;
let u = 1.0 - t;
u * u * u * p[0] + 3.0 * u * u * t * p[1] + 3.0 * u * t * t * p[2] + t * t * t * p[3]
};
(bez([a.0, b.0, c.0, d.0]), bez([a.1, b.1, c.1, d.1]))
}
/// Whether a place lies on the ribbon.
fn holds(&self, (x, y): (f32, f32)) -> bool {
if x < self.from.0 || x > self.to.0 {
return false;
}
let (mut low, mut high) = (0.0f32, 1.0f32);
for _ in 0..24 {
let middle = (low + high) / 2.0;
if self.top(middle).0 < x {
low = middle
} else {
high = middle
}
}
let top = self.top(low).1;
y >= top && y <= top + self.thick
}
}
/// What flows into node `ix` and out of it.
fn flows_at(links: &[(usize, usize, f64)], ix: usize) -> (f64, f64) {
links
.iter()
.fold((0.0, 0.0), |(inward, outward), (from, to, value)| {
(
inward + if *to == ix { *value } else { 0.0 },
outward + if *from == ix { *value } else { 0.0 },
)
})
}
/// What passes through node `ix`: the more of what comes in and what goes out.
fn passing(links: &[(usize, usize, f64)], ix: usize) -> f64 {
let (inward, outward) = flows_at(links, ix);
inward.max(outward)
}
/// Lays out a flow: a column per step from the sources, each node as tall as what passes through it, each link a ribbon between its ends.
pub(crate) fn sankey(
count: usize,
links: &[(usize, usize, f64)],
frame: Rect,
(node, padding): (f32, f32),
) -> (Vec<Rect>, Vec<Ribbon>) {
let mut column = vec![0usize; count];
for _ in 0..count {
for (from, to, _) in links {
column[*to] = column[*to].max(column[*from] + 1);
}
}
assert!(
links
.iter()
.all(|(from, to, _)| column[*to] > column[*from]),
"a flow cannot loop back"
);
let through: Vec<f64> = (0..count).map(|ix| passing(links, ix)).collect();
let columns = column.iter().max().map_or(1, |last| last + 1);
let placed = &column;
let members = |c: usize| (0..count).filter(move |ix| placed[*ix] == c);
let scale = (0..columns)
.map(|c| {
let (total, gaps) = (
members(c).map(|ix| through[ix]).sum::<f64>(),
members(c).count().saturating_sub(1),
);
(frame.h - padding * gaps as f32).max(0.0) / total.max(f64::EPSILON) as f32
})
.fold(f32::MAX, f32::min);
let mut nodes = vec![Rect::default(); count];
for c in 0..columns {
let x = if columns > 1 {
frame.x + (frame.w - node) * c as f32 / (columns - 1) as f32
} else {
frame.x
};
let tall: f32 = members(c).map(|ix| through[ix] as f32 * scale).sum::<f32>()
+ padding * members(c).count().saturating_sub(1) as f32;
let mut y = frame.y + (frame.h - tall) / 2.0;
for ix in members(c) {
nodes[ix] = Rect {
x,
y,
w: node,
h: through[ix] as f32 * scale,
};
y += nodes[ix].h + padding;
}
}
let mut ribbons: Vec<Ribbon> = links
.iter()
.map(|link| Ribbon {
from: (0.0, 0.0),
to: (0.0, 0.0),
thick: link.2 as f32 * scale,
})
.collect();
let mut order: Vec<usize> = (0..links.len()).collect();
let mut stacked = vec![0.0f32; count];
order.sort_by(|a, b| nodes[links[*a].1].y.total_cmp(&nodes[links[*b].1].y));
for ix in &order {
let from = links[*ix].0;
ribbons[*ix].from = (nodes[from].x + node, nodes[from].y + stacked[from]);
stacked[from] += ribbons[*ix].thick;
}
stacked.fill(0.0);
order.sort_by(|a, b| nodes[links[*a].0].y.total_cmp(&nodes[links[*b].0].y));
for ix in &order {
let to = links[*ix].1;
ribbons[*ix].to = (nodes[to].x, nodes[to].y + stacked[to]);
stacked[to] += ribbons[*ix].thick;
}
(nodes, ribbons)
}
/// How things flow from where they start to where they end: nodes in columns, ribbons as thick as what passes. Hover a node or a ribbon to read it.
#[derive(IntoElement)]
pub struct SankeyChart {
base: Div,
id: ElementId,
nodes: Vec<SharedString>,
links: Vec<(usize, usize, f64)>,
format: Format,
}
impl SankeyChart {
pub fn new(
id: impl Into<ElementId>,
nodes: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let nodes = nodes.into_iter().map(Into::into).collect();
Self {
base: div(),
id: id.into(),
nodes,
links: Vec::new(),
format: Rc::new(compact),
}
}
/// A flow of `value` from node `from` to node `to`, by their places in the list.
pub fn link(mut self, from: usize, to: usize, value: f64) -> Self {
assert!(
from < self.nodes.len() && to < self.nodes.len() && from != to,
"a link joins two known nodes"
);
assert!(
value.is_finite() && value > 0.0,
"a link carries a positive value"
);
self.links.push((from, to, value));
self
}
pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
self.format = Rc::new(format);
self
}
}
impl Styled for SankeyChart {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for SankeyChart {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let flows: Entity<Flows> =
window.use_keyed_state((self.id.clone(), "flows"), cx, |_, _| Flows::default());
let (bounds, hover) = (flows.read(cx).bounds, flows.read(cx).hover);
let hover = hover.filter(|hover| match hover {
Hover::Node(ix) => *ix < self.nodes.len(),
Hover::Link(ix) => *ix < self.links.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 room = pixels(sizes.label) * 1.5;
let frame = Rect {
x: room,
y: pixels(sizes.inset),
w: (f32::from(bounds.size.width) - room * 2.0).max(0.0),
h: (f32::from(bounds.size.height) - pixels(sizes.inset) * 2.0).max(0.0),
};
let (nodes, ribbons) = if frame.w > 0.0 {
sankey(
self.nodes.len(),
&self.links,
frame,
(pixels(sizes.inset), pixels(sizes.inset) * 1.5),
)
} else {
(vec![Rect::default(); self.nodes.len()], Vec::new())
};
let lit = |link: usize| match hover {
Some(Hover::Link(ix)) => ix == link,
Some(Hover::Node(node)) => self.links[link].0 == node || self.links[link].1 == node,
None => true,
};
let strengths: Vec<f32> = (0..ribbons.len())
.map(|ix| {
if hover.is_none() {
0.28
} else if lit(ix) {
0.5
} else {
0.08
}
})
.collect();
let format = self.format.clone();
let text = |content: String, strong: bool| {
div()
.flex_none()
.whitespace_nowrap()
.text_size(theme.text_size(TextSize::Xs))
.text_color(if strong { colors.fg } else { colors.fg_muted })
.child(content)
};
let middle = frame.x + frame.w / 2.0;
let labels: Vec<Div> = nodes
.iter()
.zip(&self.nodes)
.enumerate()
.map(|(ix, (rect, name))| {
let right = rect.x < middle;
let x = if right {
rect.x - pixels(sizes.inset)
} else {
rect.x + rect.w + pixels(sizes.inset)
};
let label = div()
.flex()
.flex_col()
.when(right, |label| label.items_end())
.child(text(name.to_string(), hover == Some(Hover::Node(ix))))
.child(tabular(text(format(passing(&self.links, ix)), false)));
div()
.absolute()
.left(Pixels::from(x))
.top(Pixels::from(rect.y + rect.h / 2.0))
.w_0()
.h_0()
.flex()
.items_center()
.when(right, |anchor| anchor.justify_end())
.child(label)
})
.collect();
let tooltip = hover.map(|hover| match hover {
Hover::Link(ix) => {
let (from, to, value) = self.links[ix];
let place = ribbons[ix].top(0.5);
let card = ChartTooltip::new(format!("{} → {}", self.nodes[from], self.nodes[to]))
.row(Some(tint(&colors, from)), "Flow", format(value));
anchored(
(place.0, place.1 + ribbons[ix].thick / 2.0),
place.0 > f32::from(bounds.size.width) * 0.6,
card,
)
}
Hover::Node(ix) => {
let (inward, outward) = flows_at(&self.links, ix);
let rect = nodes[ix];
let card = ChartTooltip::new(self.nodes[ix].clone())
.row(None, "In", format(inward))
.row(None, "Out", format(outward));
anchored(
(rect.x + rect.w, rect.y + rect.h / 2.0),
rect.x > middle,
card,
)
}
});
let (painted, woven, corner) = (
nodes.clone(),
ribbons.clone(),
theme.radius(Radius::Sm).to_pixels(rem),
);
let sources: Vec<usize> = self.links.iter().map(|link| link.0).collect();
let palette = colors.clone();
let (hit_nodes, hit_ribbons) = (nodes, ribbons);
let (moved, left) = (flows.clone(), flows.clone());
let mut root = div()
.debug_selector(|| "chart-root".into())
.relative()
.h(sizes.height);
root.style().refine(self.base.style());
root.id((self.id.clone(), "sankey"))
.on_mouse_move(move |event: &MouseMoveEvent, _, cx| {
let offset = event.position - moved.read(cx).bounds.origin;
let place = (f32::from(offset.x), f32::from(offset.y));
let next = hit_nodes
.iter()
.position(|rect| rect.contains(place))
.map(Hover::Node)
.or_else(|| {
hit_ribbons
.iter()
.position(|ribbon| ribbon.holds(place))
.map(Hover::Link)
});
moved.update(cx, |flows, cx| {
if flows.hover != next {
flows.hover = next;
cx.notify();
}
})
})
.hover_listener_mode(HoverListenerMode::InputModalityIndependent)
.on_hover(move |inside, _, cx| {
if !*inside {
left.update(cx, |flows, cx| {
flows.hover = None;
cx.notify();
})
}
})
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let origin = bounds.origin;
for (ix, ribbon) in woven.iter().enumerate() {
let mut path = PathBuilder::fill();
path.move_to(at(origin, ribbon.from));
let middle = (ribbon.from.0 + ribbon.to.0) / 2.0;
path.cubic_bezier_to(
at(origin, ribbon.to),
at(origin, (middle, ribbon.from.1)),
at(origin, (middle, ribbon.to.1)),
);
path.line_to(at(origin, (ribbon.to.0, ribbon.to.1 + ribbon.thick)));
let low = |(x, y): (f32, f32)| (x, y + ribbon.thick);
path.cubic_bezier_to(
at(origin, low(ribbon.from)),
at(origin, low((middle, ribbon.to.1))),
at(origin, low((middle, ribbon.from.1))),
);
path.close();
finish(
path,
tint(&palette, sources[ix]).opacity(strengths[ix]),
window,
);
}
for (ix, rect) in painted.iter().enumerate() {
window.paint_quad(
fill(place(origin, *rect), tint(&palette, ix)).corner_radii(corner),
);
}
},
)
.absolute()
.inset_0(),
)
.children(labels)
.children(tooltip)
.child(measure(flows, |flows| &mut flows.bounds))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_flow_stacks_columns_and_links_meet_their_ends() {
let links = [(0, 2, 30.0), (1, 2, 10.0), (2, 3, 25.0), (2, 4, 15.0)];
let frame = Rect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 100.0,
};
let (nodes, ribbons) = sankey(5, &links, frame, (10.0, 20.0));
assert_eq!(
(nodes[0].x, nodes[2].x, nodes[3].x),
(0.0, 145.0, 290.0),
"three columns edge to edge"
);
assert_eq!(
(nodes[2].h, nodes[0].h),
(80.0, 60.0),
"the tightest column sets the scale for all"
);
assert_eq!(
ribbons[1].to.1,
nodes[2].y + ribbons[0].thick,
"the second inflow lands under the first"
);
assert!(ribbons[0].holds((ribbons[0].top(0.5).0, ribbons[0].top(0.5).1 + 1.0)));
assert!(!ribbons[0].holds((5.0, 50.0)), "left of the ribbon");
}
}