Files
Ely-GPUI-Components/src/charts/schedule.rs
T

416 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use gpui::{
App, Bounds, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
PathBuilder, Pixels, Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window,
canvas, div, fill, size,
};
use jiff::{
Span,
civil::{Date, Weekday},
};
use super::{
axes::anchored,
geometry::Rect,
paint::{at, finish, measure, place, tint},
parts::ChartTooltip,
tiles::{Tiles, tracked},
};
use crate::{
theme::{ActiveTheme, Density, Radius, TextSize},
typography::format,
};
/// A task on a schedule: its name, first and last day, how much is done, and the tasks it waits for.
#[derive(Clone, Debug, PartialEq)]
pub struct Task {
name: SharedString,
start: Date,
end: Date,
done: f32,
after: Vec<usize>,
}
impl Task {
/// A task from its first day through its last.
pub fn new(name: impl Into<SharedString>, start: Date, end: Date) -> Self {
assert!(end >= start, "a task ends on or after its first day");
Self {
name: name.into(),
start,
end,
done: 0.0,
after: Vec::new(),
}
}
/// How much is done, zero to one.
pub fn done(mut self, share: f32) -> Self {
assert!((0.0..=1.0).contains(&share), "done is a share, zero to one");
self.done = share;
self
}
/// Waits for the task at `ix`; an arrow runs from its end.
pub fn after(mut self, ix: usize) -> Self {
self.after.push(ix);
self
}
}
/// Whole days from `from` to `to`.
pub(super) fn days(from: Date, to: Date) -> i32 {
to.since(from)
.expect("dates within jiff's range")
.get_days()
}
/// Days to mark along a span: each month's first day when the span is long, each Monday when it is short.
pub(super) fn marks(first: Date, count: i32) -> Vec<(i32, String)> {
let long = count > 62;
(0..count)
.filter_map(|offset| {
let day = first
.checked_add(Span::new().days(offset))
.expect("a day within the schedule");
let marked = if long {
day.day() == 1
} else {
day.weekday() == Weekday::Monday
};
marked.then(|| {
(
offset,
day.strftime(if long { "%b" } else { "%b %-d" }).to_string(),
)
})
})
.collect()
}
/// Tasks on a timeline, a row each: bars from first day to last, filled as far as they are done, with arrows from the tasks they wait for and a line at today. Hover a row to read it.
#[derive(IntoElement)]
pub struct GanttChart {
base: Div,
id: ElementId,
tasks: Vec<Task>,
today: Option<Date>,
}
impl GanttChart {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
tasks: Vec::new(),
today: None,
}
}
/// A task; the ones it waits for come before it.
pub fn task(mut self, mut task: Task) -> Self {
let known = self.tasks.len();
if task.after.iter().any(|ix| *ix >= known) {
log::error!(
"gantt: task {} waits for one not given; that wait is dropped",
task.name
);
task.after.retain(|ix| *ix < known);
}
self.tasks.push(task);
self
}
/// Marks this day with a line.
pub fn today(mut self, today: Date) -> Self {
self.today = Some(today);
self
}
}
impl Styled for GanttChart {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for GanttChart {
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.tasks.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 count = self.tasks.len();
let first = self.tasks.iter().map(|task| task.start).min();
let last = self.tasks.iter().map(|task| task.end).max();
let span = first
.zip(last)
.map_or(1, |(first, last)| days(first, last) + 1);
let (names, axis, row, inset) = (
pixels(theme.label_width()),
pixels(sizes.foot),
pixels(theme.table_row(Density::Compact)),
pixels(sizes.inset),
);
let day = (f32::from(bounds.size.width) - names - inset).max(0.0) / span as f32;
let x_of = move |offset: i32| names + day * offset as f32;
let bars: Vec<Rect> = self
.tasks
.iter()
.enumerate()
.map(|(ix, task)| {
let from = first.map_or(0, |first| days(first, task.start));
Rect {
x: x_of(from),
y: axis + row * ix as f32 + row * 0.22,
w: day * (days(task.start, task.end) + 1) as f32,
h: row * 0.56,
}
})
.collect();
let text = |content: String, color| {
div()
.flex_none()
.whitespace_nowrap()
.text_size(theme.text_size(TextSize::Xs))
.text_color(color)
.child(content)
};
let ticks = first.map_or_else(Vec::new, |first| marks(first, span));
let tick_labels: Vec<Div> = ticks
.iter()
.map(|(offset, label)| {
div()
.absolute()
.left(Pixels::from(x_of(*offset)))
.top_0()
.h(Pixels::from(axis))
.flex()
.items_center()
.pl_1()
.child(text(label.clone(), colors.fg_subtle))
})
.collect();
let name_labels: Vec<Div> = self
.tasks
.iter()
.enumerate()
.map(|(ix, task)| {
let label = div()
.truncate()
.text_size(theme.text_size(TextSize::Sm))
.text_color(if hover == Some(ix) {
colors.fg
} else {
colors.fg_muted
})
.child(task.name.clone());
div()
.absolute()
.left_0()
.top(Pixels::from(axis + row * ix as f32))
.w(Pixels::from(names))
.h(Pixels::from(row))
.flex()
.items_center()
.pr_3()
.child(label)
})
.collect();
let today = self
.today
.zip(first)
.map(|(today, first)| x_of(days(first, today)) + day / 2.0)
.filter(|x| (names..=names + day * span as f32).contains(x));
let tooltip = hover.map(|ix| {
let (task, bar) = (&self.tasks[ix], bars[ix]);
let dates = format!(
"{} – {}",
task.start.strftime("%b %-d"),
task.end.strftime("%b %-d")
);
let card = ChartTooltip::new(task.name.clone())
.row(Some(tint(&colors, 0)), "Dates", dates)
.row(None, "Days", (days(task.start, task.end) + 1).to_string())
.row(
None,
"Done",
format::percent(f64::from(task.done), 0, false),
);
let x = (bar.x + bar.w).min(f32::from(bounds.size.width));
anchored(
(x, bar.y + bar.h / 2.0),
x > f32::from(bounds.size.width) * 0.6,
card,
)
});
let links: Vec<(Rect, Rect)> = self
.tasks
.iter()
.enumerate()
.flat_map(|(ix, task)| task.after.iter().map(move |before| (*before, ix)))
.map(|(before, ix)| (bars[before], bars[ix]))
.collect();
let done: Vec<f32> = self.tasks.iter().map(|task| task.done).collect();
let (stroke, hairline, corner, palette) = (
sizes.stroke.to_pixels(rem),
sizes.hairline,
theme.radius(Radius::Sm).to_pixels(rem),
colors.clone(),
);
let (width, grid) = (
f32::from(bounds.size.width),
ticks
.iter()
.map(|(offset, _)| x_of(*offset))
.collect::<Vec<_>>(),
);
let painted = bars.clone();
let mut root = div()
.debug_selector(|| "chart-root".into())
.relative()
.h(sizes.foot + theme.table_row(Density::Compact) * count as f32);
root.style().refine(self.base.style());
tracked(
root,
(self.id.clone(), "gantt").into(),
&tiles,
move |(_, y)| {
let at = ((y - axis) / row).floor();
(at >= 0.0 && (at as usize) < count).then_some(at as usize)
},
)
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let origin = bounds.origin;
let bottom = axis + row * count as f32;
if let Some(ix) = hover {
window.paint_quad(fill(
place(
origin,
Rect {
x: 0.0,
y: axis + row * ix as f32,
w: width,
h: row,
},
),
palette.hover,
));
}
for x in &grid {
window.paint_quad(fill(
Bounds::new(
at(origin, (*x, axis)),
size(hairline, Pixels::from(bottom - axis)),
),
palette.border.opacity(0.5),
));
}
window.paint_quad(fill(
Bounds::new(
at(origin, (names, axis)),
size(Pixels::from(width - names), hairline),
),
palette.border,
));
let ink = tint(&palette, 0);
for (bar, share) in painted.iter().zip(&done) {
window.paint_quad(
fill(place(origin, *bar), ink.opacity(0.22)).corner_radii(corner),
);
window.paint_quad(
fill(
place(
origin,
Rect {
w: bar.w * share,
..*bar
},
),
ink,
)
.corner_radii(corner),
);
}
let tip = f32::from(stroke) * 2.0;
for (from, to) in &links {
let (start, end) = (
(from.x + from.w, from.y + from.h / 2.0),
(to.x, to.y + to.h / 2.0),
);
let turn = start.0 + tip * 2.0;
let mut path = PathBuilder::stroke(hairline);
path.move_to(at(origin, start));
if turn + tip <= end.0 {
path.line_to(at(origin, (turn, start.1)));
path.line_to(at(origin, (turn, end.1)));
} else {
let between = from.y + from.h + (to.y - from.y - from.h) / 2.0;
path.line_to(at(origin, (turn, start.1)));
path.line_to(at(origin, (turn, between)));
path.line_to(at(origin, (end.0 - tip * 2.0, between)));
path.line_to(at(origin, (end.0 - tip * 2.0, end.1)));
}
path.line_to(at(origin, (end.0 - tip, end.1)));
finish(path, palette.fg_subtle, window);
let mut head = PathBuilder::fill();
head.move_to(at(origin, end));
head.line_to(at(origin, (end.0 - tip, end.1 - tip / 1.5)));
head.line_to(at(origin, (end.0 - tip, end.1 + tip / 1.5)));
head.close();
finish(head, palette.fg_subtle, window);
}
if let Some(x) = today {
window.paint_quad(fill(
Bounds::new(
at(origin, (x, axis)),
size(stroke / 2.0, Pixels::from(bottom - axis)),
),
tint(&palette, 3),
));
}
},
)
.absolute()
.inset_0(),
)
.children(tick_labels)
.children(name_labels)
.children(tooltip)
.child(measure(tiles, |tiles| &mut tiles.bounds))
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::{days, marks};
#[test]
fn short_spans_mark_mondays_and_long_ones_months() {
assert_eq!(days(date(2026, 3, 1), date(2026, 3, 15)), 14);
let weekly = marks(date(2026, 3, 1), 14);
assert_eq!(
weekly.iter().map(|mark| mark.0).collect::<Vec<_>>(),
[1, 8],
"the first of March 2026 is a Sunday"
);
let monthly = marks(date(2026, 1, 15), 90);
assert_eq!(
monthly
.iter()
.map(|mark| mark.1.as_str())
.collect::<Vec<_>>(),
["Feb", "Mar", "Apr"]
);
}
}