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, } impl Task { /// A task from its first day through its last. pub fn new(name: impl Into, 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, today: Option, } impl GanttChart { pub fn new(id: impl Into) -> 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 = 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 = 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
= 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
= 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 = 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::>(), ); 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::>(), [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::>(), ["Feb", "Mar", "Apr"] ); } }