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>
413 lines
14 KiB
Rust
413 lines
14 KiB
Rust
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, task: Task) -> Self {
|
||
assert!(
|
||
task.after.iter().all(|ix| *ix < self.tasks.len()),
|
||
"task {} waits for one not yet given",
|
||
task.name
|
||
);
|
||
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"]
|
||
);
|
||
}
|
||
}
|