diff --git a/AGENTS.md b/AGENTS.md index 424534d..7086279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`. - gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. +- Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends. - The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. - An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`. - Keyed state lives while its element renders in consecutive frames. An overlay rendered only while open starts fresh each time. diff --git a/TASKS.md b/TASKS.md index eebb0cc..4e6e5d2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -182,3 +182,4 @@ Per-component lines live in `tasks/`. Tags there: | T11d | 2 | FAIL | Marquee's doc still said its content is drawn twice | | T11d | 3 | PASS | | | T12a | 1 | — | pending: codex answered 401 on every request; committed unreviewed, reviewed from its commit once codex logs in | +| T12b | 1 | — | pending: codex still logged out | diff --git a/examples/gallery/pages/data/mod.rs b/examples/gallery/pages/data/mod.rs index 03409f6..4da35e3 100644 --- a/examples/gallery/pages/data/mod.rs +++ b/examples/gallery/pages/data/mod.rs @@ -1,4 +1,5 @@ mod people; +mod records; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -25,6 +26,9 @@ const SCRIPT: &[Step] = &[ Step::Click("stats-next"), Step::Wait(300), Step::Shot("stats-next"), + Step::Click("feed-post"), + Step::Wait(300), + Step::Shot("feed-arrival"), Step::Rest, ]; @@ -35,5 +39,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(people::avatars(window, cx)) .child(people::groups(cx)) .child(people::numbers(window, cx)) + .child(records::descriptions(cx)) + .child(records::properties(window, cx)) + .child(records::timelines(cx)) + .child(records::feed(window, cx)) + .child(records::changelog(cx)) .into_any_element() } diff --git a/examples/gallery/pages/data/records.rs b/examples/gallery/pages/data/records.rs new file mode 100644 index 0000000..87e6a24 --- /dev/null +++ b/examples/gallery/pages/data/records.rs @@ -0,0 +1,349 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::{ + Activity, ActivityFeed, Badge, Change, Changelog, DescriptionList, PropertyGrid, + PropertyGroup, Release, Timeline, TimelineItem, UserChip, + }, + forms::{NumberInput, Slider, Switch}, + primitives::{IconName, Severity}, + theme::{ActiveTheme, ControlSize, Radius}, + typography::Code, +}; +use gpui::{ + App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, prelude::*, px, +}; +use jiff::{Timestamp, ToSpan}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +pub fn descriptions(cx: &mut App) -> impl IntoElement + use<> { + section( + "DescriptionList / KeyValueList / InfoRow", + "Labels and their values: in a quiet column beside them, or stacked where room is narrow.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_12() + .child( + div().flex_1().child( + DescriptionList::new() + .lined() + .item("Status", Badge::new("Paid").tone(Severity::Success).dot()) + .item( + "Customer", + UserChip::new("desc-customer", "Katherine Johnson"), + ) + .item("Amount", "$1,280.00") + .item("Placed", "Sep 24, 2026 · 14:32") + .item("Reference", Code::new("ORD-2419")), + ), + ) + .child( + div().w_64().child( + DescriptionList::new() + .stacked() + .item("Ship to", "12 Harbour Street, Wellington 6011, New Zealand") + .item("Delivery", "Standard, three to five days"), + ), + ), + ) +} + +#[derive(Clone)] +struct Props { + width: f64, + height: f64, + direction: SharedString, + opacity: f64, + visible: bool, +} + +fn edit( + props: &Entity, + apply: fn(&mut Props, V), +) -> impl Fn(V, &mut Window, &mut App) + 'static { + let props = props.clone(); + move |value, _, cx| { + props.update(cx, |props, cx| { + apply(props, value); + cx.notify(); + }) + } +} + +pub fn properties(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let props = keep( + "props", + || Props { + width: 240.0, + height: 144.0, + direction: "row".into(), + opacity: 0.8, + visible: true, + }, + window, + cx, + ); + let now = props.read(cx).clone(); + let theme = cx.theme(); + let colors = &theme.colors; + let direction = props.clone(); + let grid = PropertyGrid::new("props-grid") + .group( + PropertyGroup::new("Layout") + .row( + "Width", + NumberInput::new("prop-width", now.width) + .range(40.0, 320.0) + .size(ControlSize::Sm) + .on_change(edit(&props, |p, v| p.width = v)), + ) + .row( + "Height", + NumberInput::new("prop-height", now.height) + .range(40.0, 200.0) + .size(ControlSize::Sm) + .on_change(edit(&props, |p, v| p.height = v)), + ) + .row( + "Direction", + SegmentedControl::new("prop-direction", now.direction.clone()) + .segment("row", "Row", None) + .segment("column", "Column", None) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| { + direction.update(cx, |props, cx| { + props.direction = value.clone(); + cx.notify(); + }) + }), + ), + ) + .group( + PropertyGroup::new("Appearance") + .row( + "Opacity", + Slider::new("prop-opacity", now.opacity) + .range(0.0, 1.0) + .step(0.01) + .on_change(edit(&props, |p, v| p.opacity = v)), + ) + .row( + "Visible", + Switch::new("prop-visible", now.visible) + .on_change(edit(&props, |p, v| p.visible = v)), + ), + ) + .group( + PropertyGroup::new("Export").folded().row( + "Scale", + NumberInput::new("prop-scale", 2.0) + .range(1.0, 4.0) + .size(ControlSize::Sm), + ), + ); + let bar = |ix: usize| { + div() + .flex_1() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.accent.opacity(0.35 + 0.2 * ix as f32)) + }; + let frame = div() + .flex() + .when(now.direction == "column", |frame| frame.flex_col()) + .gap_2() + .p_2() + .w(px(now.width as f32)) + .h(px(now.height as f32)) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.accent) + .opacity(now.opacity as f32) + .children((0..3).map(bar)); + section( + "PropertyGrid", + "An inspector: groups fold, each row a name and its editor. The owner keeps the values; the frame on the right follows them.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_6() + .child( + div() + .w_80() + .flex_none() + .border_1() + .border_color(colors.border) + .rounded(theme.radius(Radius::Md)) + .child(grid), + ) + .child( + div() + .flex_1() + .h_64() + .flex() + .items_center() + .justify_center() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .when(now.visible, |stage| stage.child(frame)), + ), + ) +} + +pub fn timelines(cx: &mut App) -> impl IntoElement + use<> { + let deploy = Timeline::new() + .item( + TimelineItem::new("Build started") + .time("14:02") + .icon(IconName::GitCommitHorizontal) + .child("main at 4f2a9c1, by Radia Perlman"), + ) + .item( + TimelineItem::new("Tests passed") + .time("14:06") + .icon(IconName::Check) + .tone(Severity::Success) + .child("412 tests in 3 min 12 s"), + ) + .item( + TimelineItem::new("Deployed to staging") + .time("14:09") + .icon(IconName::Rocket) + .tone(Severity::Info), + ) + .item( + TimelineItem::new("Promote to production") + .icon(IconName::Hourglass) + .child("Waiting for approval"), + ); + let project = Timeline::new() + .item(TimelineItem::new("Kickoff").time("Jan 12")) + .item( + TimelineItem::new("Design review") + .time("Feb 3") + .child("Type scale settled; spacing loosened by one step."), + ) + .item( + TimelineItem::new("Beta") + .time("Mar 18") + .tone(Severity::Success), + ) + .item( + TimelineItem::new("Launch") + .time("Apr 30") + .tone(Severity::Warning) + .child("Planned"), + ); + section( + "Timeline / TimelineItem", + "Events down a rail, marked by an icon in a tinted ring or by a dot, with detail below.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_12() + .child(div().flex_1().child(deploy)) + .child(div().flex_1().child(project)), + ) +} + +/// The canned arrivals the demo cycles through: actor, verb, object. +const ARRIVALS: [(&str, &str, &str); 3] = [ + ("Sophie Wilson", "commented on", "Release notes"), + ("Tim Berners-Lee", "linked", "Spec draft"), + ("Radia Perlman", "approved", "Promote to production"), +]; + +pub fn feed(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let opened = *keep("feed-opened", Timestamp::now, window, cx).read(cx); + let arrivals = keep("feed-arrivals", Vec::::new, window, cx); + let (now, post) = (arrivals.read(cx).clone(), arrivals.clone()); + let ago = |span: jiff::Span| opened.checked_sub(span).expect("a time in range"); + let feed = now + .iter() + .enumerate() + .rev() + .fold(ActivityFeed::new("feed"), |feed, (ix, at)| { + let (actor, verb, object) = ARRIVALS[ix % ARRIVALS.len()]; + feed.entry(Activity::new(format!("arrival-{ix}"), actor, verb, *at).object(object)) + }); + let feed = feed + .entry( + Activity::new("comment", "Grace Hopper", "commented on", ago(3.minutes())) + .object("Design review") + .body("The cards finally breathe. Ship it."), + ) + .entry( + Activity::new("deploy", "Build bot", "deployed", ago(2.hours())) + .object("v0.4.0") + .icon(IconName::Rocket), + ) + .entry( + Activity::new("merge", "Alan Turing", "merged", ago(26.hours())) + .object("Timeline component"), + ) + .entry( + Activity::new("create", "Ada Lovelace", "created", ago(72.hours())) + .object("Q4 roadmap"), + ); + section( + "ActivityFeed", + "Who did what, and when, newest first. A new entry folds in at the top; the times stay fresh.", + cx, + ) + .child(div().max_w(px(560.)).child(feed)) + .child(div().flex().child(probe( + "feed-post", + Button::new("feed-post", "Something happens") + .variant(ButtonVariant::Ghost) + .icon(IconName::Plus) + .on_click(move |_, _, cx| { + let mut next = post.read(cx).clone(); + next.push(Timestamp::now()); + set(&post, next, cx) + }), + ))) +} + +pub fn changelog(cx: &mut App) -> impl IntoElement + use<> { + section( + "Changelog", + "Releases, newest first, each change under its kind.", + cx, + ) + .child( + div().max_w(px(560.)).child( + Changelog::new() + .release( + Release::new("2.4.0", "Sep 25, 2026") + .summary("Faster search, and a calmer sidebar.") + .change(Change::Added, "Search inside attachments.") + .change(Change::Fixed, "Pasting a table keeps its borders.") + .change(Change::Added, "Pin a note to the sidebar.") + .change(Change::Changed, "The sidebar folds with a narrow window."), + ) + .release( + Release::new("2.3.1", "Sep 9, 2026") + .change(Change::Fixed, "Sync resumes after sleep.") + .change(Change::Security, "Shared links expire when they should."), + ) + .release( + Release::new("2.3.0", "Aug 28, 2026") + .change(Change::Added, "Templates for new notes.") + .change(Change::Deprecated, "The legacy export format.") + .change(Change::Removed, "The old toolbar."), + ), + ), + ) +} diff --git a/src/data_display/badge.rs b/src/data_display/badge.rs index fa9aeac..74d9c81 100644 --- a/src/data_display/badge.rs +++ b/src/data_display/badge.rs @@ -37,7 +37,7 @@ impl From for Tone { impl Tone { /// The fill, then the text on it. - fn colors(self, colors: &Palette) -> (Hsla, Hsla) { + pub(super) fn colors(self, colors: &Palette) -> (Hsla, Hsla) { let severity = |severity: Severity| (severity.subtle(colors), severity.color(colors)); match self { Self::Neutral => (colors.hover, colors.fg_muted), @@ -50,7 +50,7 @@ impl Tone { } /// The color a dot of this tone takes. - fn dot(self, colors: &Palette) -> Hsla { + pub(super) fn dot(self, colors: &Palette) -> Hsla { match self { Self::Neutral => colors.fg_subtle, Self::Accent => colors.accent, diff --git a/src/data_display/changelog.rs b/src/data_display/changelog.rs new file mode 100644 index 0000000..7593179 --- /dev/null +++ b/src/data_display/changelog.rs @@ -0,0 +1,180 @@ +use gpui::{ + App, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use super::{Badge, Timeline, TimelineItem, Tone}; +use crate::theme::ActiveTheme; + +/// A kind of change, as Keep a Changelog names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Change { + Added, + Changed, + Deprecated, + Removed, + Fixed, + Security, +} + +impl Change { + fn label(self) -> &'static str { + match self { + Self::Added => "Added", + Self::Changed => "Changed", + Self::Deprecated => "Deprecated", + Self::Removed => "Removed", + Self::Fixed => "Fixed", + Self::Security => "Security", + } + } + + fn tone(self) -> Tone { + match self { + Self::Added => Tone::Success, + Self::Changed => Tone::Info, + Self::Deprecated => Tone::Warning, + Self::Removed | Self::Security => Tone::Danger, + Self::Fixed => Tone::Neutral, + } + } +} + +/// One version: its date, a line about it, and its changes. +pub struct Release { + version: SharedString, + date: SharedString, + summary: Option, + changes: Vec<(Change, SharedString)>, +} + +impl Release { + pub fn new(version: impl Into, date: impl Into) -> Self { + Self { + version: version.into(), + date: date.into(), + summary: None, + changes: Vec::new(), + } + } + + pub fn summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } + + pub fn change(mut self, kind: Change, text: impl Into) -> Self { + self.changes.push((kind, text.into())); + self + } +} + +/// Changes grouped by kind, kinds in Keep a Changelog order, each kind's changes as given. +fn grouped(changes: Vec<(Change, SharedString)>) -> Vec<(Change, Vec)> { + let mut groups: Vec<(Change, Vec)> = Vec::new(); + for (kind, text) in changes { + match groups.iter_mut().find(|(seen, _)| *seen == kind) { + Some((_, texts)) => texts.push(text), + None => groups.push((kind, vec![text])), + } + } + groups.sort_by_key(|(kind, _)| *kind); + groups +} + +/// Releases down a rail in the order given; the first reads Latest. +#[derive(IntoElement, Default)] +pub struct Changelog { + releases: Vec, +} + +impl Changelog { + pub fn new() -> Self { + Self::default() + } + + pub fn release(mut self, release: Release) -> Self { + self.releases.push(release); + self + } +} + +impl RenderOnce for Changelog { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = &cx.theme().colors; + let (fg, subtle) = (colors.fg, colors.fg_subtle); + self.releases + .into_iter() + .enumerate() + .fold(Timeline::new(), |timeline, (ix, release)| { + let head = div() + .flex() + .items_center() + .gap_2() + .font_weight(FontWeight::SEMIBOLD) + .child(release.version) + .when(ix == 0, |head| { + head.child(Badge::new("Latest").tone(Tone::Accent)) + }); + let groups = grouped(release.changes).into_iter().map(|(kind, texts)| { + div() + .flex() + .flex_col() + .items_start() + .gap_1() + .mt_3() + .child(Badge::new(kind.label()).tone(kind.tone())) + .children(texts.into_iter().map(|text| { + div() + .flex() + .gap_2() + .text_color(fg) + .child(div().text_color(subtle).child("•")) + .child(div().flex_1().min_w_0().child(text)) + })) + }); + timeline.item( + TimelineItem::new(head) + .tone(if ix == 0 { Tone::Accent } else { Tone::Neutral }) + .time(release.date) + .children(release.summary) + .children(groups), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Change, grouped}; + + #[test] + fn changes_group_by_kind_in_changelog_order() { + let changes = vec![ + (Change::Fixed, "a crash".into()), + (Change::Added, "a timeline".into()), + (Change::Fixed, "a typo".into()), + (Change::Removed, "the old feed".into()), + ]; + let groups: Vec<_> = grouped(changes) + .into_iter() + .map(|(kind, texts)| { + ( + kind, + texts.iter().map(|t| t.to_string()).collect::>(), + ) + }) + .collect(); + assert_eq!( + groups, + vec![ + (Change::Added, vec!["a timeline".to_string()]), + (Change::Removed, vec!["the old feed".to_string()]), + ( + Change::Fixed, + vec!["a crash".to_string(), "a typo".to_string()] + ), + ] + ); + } +} diff --git a/src/data_display/feed.rs b/src/data_display/feed.rs new file mode 100644 index 0000000..bc720fa --- /dev/null +++ b/src/data_display/feed.rs @@ -0,0 +1,175 @@ +use std::{iter, ops::Range}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, HighlightStyle, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, StyledText, Window, div, +}; +use jiff::Timestamp; + +use super::{Avatar, TimelineItem}; +use crate::{ + motion::AnimatePresence, + primitives::IconName, + theme::{ActiveTheme, AvatarSize}, + typography::RelativeTime, +}; + +/// Who did what, to what, and when: "Grace Hopper commented on Design review". +pub struct Activity { + key: SharedString, + actor: SharedString, + verb: SharedString, + object: Option, + at: Timestamp, + avatar: Option, + icon: Option, + body: Option, +} + +impl Activity { + /// `key` follows the entry as the feed changes; a key not seen before arrives with motion. + pub fn new( + key: impl Into, + actor: impl Into, + verb: impl Into, + at: Timestamp, + ) -> Self { + Self { + key: key.into(), + actor: actor.into(), + verb: verb.into(), + object: None, + at, + avatar: None, + icon: None, + body: None, + } + } + + /// What it was done to, set in the actor's weight. + pub fn object(mut self, object: impl Into) -> Self { + self.object = Some(object.into()); + self + } + + /// The actor's avatar, such as one with a picture; by default, their initials. + pub fn avatar(mut self, avatar: Avatar) -> Self { + self.avatar = Some(avatar); + self + } + + /// An icon in place of an avatar, for what a system did. + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + /// Detail below, such as the comment itself. + pub fn body(mut self, body: impl IntoElement) -> Self { + self.body = Some(body.into_any_element()); + self + } +} + +/// The sentence, where the actor sits in it, and where the object does. +fn sentence( + actor: &str, + verb: &str, + object: Option<&str>, +) -> (String, Range, Option>) { + let text = format!("{actor} {verb}"); + let Some(object) = object else { + return (text, 0..actor.len(), None); + }; + let start = text.len() + 1; + let text = format!("{text} {object}"); + let end = text.len(); + (text, 0..actor.len(), Some(start..end)) +} + +/// A stream of what people did, in the order given. An entry with a new key folds in; the times stay fresh. +#[derive(IntoElement)] +pub struct ActivityFeed { + id: ElementId, + entries: Vec, +} + +impl ActivityFeed { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + entries: Vec::new(), + } + } + + pub fn entry(mut self, activity: Activity) -> Self { + self.entries.push(activity); + self + } +} + +impl RenderOnce for ActivityFeed { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = &cx.theme().colors; + let strong = HighlightStyle { + color: Some(colors.fg), + font_weight: Some(FontWeight::MEDIUM), + ..Default::default() + }; + let (id, last) = (self.id.clone(), self.entries.len().saturating_sub(1)); + let muted = colors.fg_muted; + self.entries.into_iter().enumerate().fold( + AnimatePresence::new(self.id), + |feed, (ix, activity)| { + let (text, actor, object) = sentence( + &activity.actor, + &activity.verb, + activity.object.as_ref().map(|object| object.as_ref()), + ); + let title = + div() + .text_color(muted) + .child(StyledText::new(text).with_highlights( + iter::once(actor).chain(object).map(|range| (range, strong)), + )); + let item = TimelineItem::new(title) + .time(RelativeTime::new( + (id.clone(), format!("time-{}", activity.key)), + activity.at, + )) + .tail(ix < last) + .children(activity.body); + let item = match (activity.icon, activity.avatar) { + (Some(icon), _) => item.icon(icon), + (None, Some(avatar)) => item.marker(avatar.size(AvatarSize::Sm)), + (None, None) => item.marker( + Avatar::new( + (id.clone(), format!("face-{}", activity.key)), + activity.actor.clone(), + ) + .size(AvatarSize::Sm), + ), + }; + feed.row(activity.key, true, item) + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::sentence; + + #[test] + fn an_activity_sets_its_actor_and_object_apart() { + let (text, actor, object) = sentence("Lucía Romero", "commented on", Some("Design review")); + assert_eq!(text, "Lucía Romero commented on Design review"); + assert_eq!(&text[actor], "Lucía Romero"); + assert_eq!(&text[object.expect("an object")], "Design review"); + let (text, actor, object) = sentence("Build bot", "deployed", None); + assert_eq!( + (text.as_str(), actor, object), + ("Build bot deployed", 0..9, None) + ); + } +} diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index 29bdfda..847864a 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -1,7 +1,17 @@ mod avatar; mod badge; +mod changelog; +mod feed; +mod records; mod stat; +#[cfg(test)] +mod tests; +mod timeline; pub use avatar::{Avatar, AvatarGroup, Presence, UserChip}; pub use badge::{Badge, CountBadge, DotBadge, Tag, Tone}; +pub use changelog::{Change, Changelog, Release}; +pub use feed::{Activity, ActivityFeed}; +pub use records::{DescriptionList, PropertyGrid, PropertyGroup}; pub use stat::{KpiCard, Statistic, TrendIndicator}; +pub use timeline::{Timeline, TimelineItem}; diff --git a/src/data_display/records.rs b/src/data_display/records.rs new file mode 100644 index 0000000..b981360 --- /dev/null +++ b/src/data_display/records.rs @@ -0,0 +1,245 @@ +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement, + IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, + Window, div, prelude::*, radians, +}; + +use crate::{ + layout::{Collapsible, seeded::use_seeded}, + motion, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, +}; + +/// Labels and their values, one pair a row with the labels in a quiet column, or stacked with each label above its value. +#[derive(IntoElement, Default)] +pub struct DescriptionList { + items: Vec<(SharedString, AnyElement)>, + stacked: bool, + lined: bool, +} + +impl DescriptionList { + pub fn new() -> Self { + Self::default() + } + + /// A label, and its value: text, or any element such as a badge. + pub fn item(mut self, label: impl Into, value: impl IntoElement) -> Self { + self.items.push((label.into(), value.into_any_element())); + self + } + + /// Each label above its value, for narrow places. + pub fn stacked(mut self) -> Self { + self.stacked = true; + self + } + + /// A hairline between rows. + pub fn lined(mut self) -> Self { + self.lined = true; + self + } +} + +impl RenderOnce for DescriptionList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let (stacked, lined, last) = (self.stacked, self.lined, self.items.len().saturating_sub(1)); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Base)) + .children( + self.items + .into_iter() + .enumerate() + .map(|(ix, (label, value))| { + let label = div().text_color(colors.fg_muted).child(label); + div() + .flex() + .when(stacked, |row| row.flex_col().gap_0p5()) + .when(!stacked, |row| row.items_baseline().gap_4()) + .py_2() + .when(lined && ix < last, |row| { + row.border_b_1().border_color(colors.border) + }) + .child(if stacked { + label.text_size(theme.text_size(TextSize::Sm)) + } else { + label.flex_none().w(theme.label_width()) + }) + .child( + div() + .flex() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(value), + ) + }), + ) + } +} + +/// A titled group of a `PropertyGrid`, open unless `folded`. +pub struct PropertyGroup { + title: SharedString, + open: bool, + rows: Vec<(SharedString, AnyElement)>, +} + +impl PropertyGroup { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + open: true, + rows: Vec::new(), + } + } + + /// Starts folded. + pub fn folded(mut self) -> Self { + self.open = false; + self + } + + /// A property's name and the editor that changes it. + pub fn row(mut self, name: impl Into, editor: impl IntoElement) -> Self { + self.rows.push((name.into(), editor.into_any_element())); + self + } +} + +/// An inspector's table: groups that fold, each row a name and its editor. The owner supplies the editors and keeps the values. +#[derive(IntoElement)] +pub struct PropertyGrid { + id: ElementId, + groups: Vec, +} + +impl PropertyGrid { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + groups: Vec::new(), + } + } + + pub fn group(mut self, group: PropertyGroup) -> Self { + self.groups.push(group); + self + } +} + +impl RenderOnce for PropertyGrid { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let folds: Vec<_> = self + .groups + .iter() + .enumerate() + .map(|(ix, group)| { + use_seeded( + (self.id.clone(), format!("fold-{ix}")), + group.open, + window, + cx, + ) + }) + .collect(); + let turns: Vec<_> = folds + .iter() + .enumerate() + .map(|(ix, fold)| { + let shown = fold.read(cx).value; + motion::changes((self.id.clone(), format!("turns-{ix}")), shown, window, cx) + }) + .collect(); + let length = motion::duration(motion::FAST, cx); + let theme = cx.theme(); + let colors = &theme.colors; + let row_height = theme.control_height(ControlSize::Md); + let id = self.id.clone(); + div() + .id(self.id) + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(self.groups.into_iter().enumerate().map( + |(ix, PropertyGroup { title, rows, .. })| { + let shown = folds[ix].read(cx).value; + let (toggle, key) = (folds[ix].clone(), title.clone()); + let turn = |share: f32| radians(share * std::f32::consts::FRAC_PI_2); + let chevron = Icon::new(IconName::ChevronRight) + .size(IconSize::Xs) + .color(colors.fg_subtle); + let chevron = match turns[ix] { + 0 => chevron + .rotate(turn(if shown { 1.0 } else { 0.0 })) + .into_any_element(), + changes => chevron + .with_animation( + (id.clone(), format!("chevron-{ix}-{changes}")), + Animation::new(length).with_easing(motion::ease_out_cubic), + move |icon, t| icon.rotate(turn(if shown { t } else { 1.0 - t })), + ) + .into_any_element(), + }; + div() + .when(ix > 0, |group| { + group.border_t_1().border_color(colors.border) + }) + .child( + div() + .id((id.clone(), format!("group-{ix}"))) + .flex() + .items_center() + .gap_1() + .h(row_height) + .px_2() + .cursor_pointer() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .hover(|style| style.bg(colors.hover)) + .on_click(move |_, _, cx| { + log::info!( + "property grid: {key} {}", + if shown { "folded" } else { "opened" } + ); + toggle.update(cx, |fold, cx| { + fold.value = !shown; + cx.notify(); + }) + }) + .child(chevron) + .child(title), + ) + .child( + Collapsible::new((id.clone(), format!("rows-{ix}")), shown).child( + div() + .pb_1() + .children(rows.into_iter().map(|(name, editor)| { + div() + .flex() + .items_center() + .gap_3() + .min_h(row_height) + .pl_6() + .pr_2() + .child( + div() + .flex_none() + .w(theme.label_width() * 0.75) + .text_color(colors.fg_muted) + .child(name), + ) + .child(div().flex_1().min_w_0().child(editor)) + })), + ), + ) + }, + )) + } +} diff --git a/src/data_display/tests.rs b/src/data_display/tests.rs new file mode 100644 index 0000000..a1b8ea1 --- /dev/null +++ b/src/data_display/tests.rs @@ -0,0 +1,65 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled, TestAppContext, + VisualTestContext, Window, div, point, px, +}; + +use super::{PropertyGrid, PropertyGroup}; +use crate::{primitives::Measure, theme::Theme}; + +/// A property grid whose drawn height the test reads. +struct Inspector(Rc>); + +impl Render for Inspector { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.0.clone(); + let editor = || div().h(px(20.0)); + div().w(px(320.0)).child( + Measure::new("measure", move |bounds, _, _| { + height.set(bounds.size.height) + }) + .child( + PropertyGrid::new("grid") + .group( + PropertyGroup::new("Layout") + .row("Width", editor()) + .row("Height", editor()), + ) + .group(PropertyGroup::new("Export").folded().row("Scale", editor())), + ), + ) + } +} + +/// Frames 2ms apart, past reduced motion's 1ms folds. +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + std::thread::sleep(std::time::Duration::from_millis(2)); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } +} + +#[gpui::test] +fn a_property_group_folds_from_its_header_and_stays_folded(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + }); + let height = Rc::new(Cell::new(Pixels::ZERO)); + let seen = height.clone(); + let (_, cx) = cx.add_window_view(|_, _| Inspector(seen)); + settle(cx); + let open = height.get(); + let header = point(px(4.0), px(12.0)); + cx.simulate_click(header, Modifiers::none()); + settle(cx); + let folded = height.get(); + assert!(folded < open, "the rows fold away: {open:?} to {folded:?}"); + settle(cx); + assert_eq!(height.get(), folded, "a new frame keeps the fold"); + cx.simulate_click(header, Modifiers::none()); + settle(cx); + assert_eq!(height.get(), open, "a second press opens it"); +} diff --git a/src/data_display/timeline.rs b/src/data_display/timeline.rs new file mode 100644 index 0000000..5975cab --- /dev/null +++ b/src/data_display/timeline.rs @@ -0,0 +1,202 @@ +use gpui::{ + AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, Styled, Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use super::Tone; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, +}; + +/// What marks an event on the rail. +enum Marker { + Dot, + Icon(IconName), + Element(AnyElement), +} + +/// One event on a `Timeline`: its title, when it happened, and any detail below. +#[derive(IntoElement)] +pub struct TimelineItem { + title: AnyElement, + time: Option, + tone: Tone, + marker: Marker, + tail: bool, + body: SmallVec<[AnyElement; 2]>, +} + +impl TimelineItem { + pub fn new(title: impl IntoElement) -> Self { + Self { + title: title.into_any_element(), + time: None, + tone: Tone::Neutral, + marker: Marker::Dot, + tail: false, + body: SmallVec::new(), + } + } + + /// When it happened: a date, or a `RelativeTime`. + pub fn time(mut self, time: impl IntoElement) -> Self { + self.time = Some(time.into_any_element()); + self + } + + /// Tints the dot or the icon's ring. + pub fn tone(mut self, tone: impl Into) -> Self { + self.tone = tone.into(); + self + } + + /// An icon in a ring in place of the dot. + pub fn icon(mut self, icon: IconName) -> Self { + self.marker = Marker::Icon(icon); + self + } + + /// An element the size of a small avatar in place of the dot. + pub fn marker(mut self, marker: impl IntoElement) -> Self { + self.marker = Marker::Element(marker.into_any_element()); + self + } + + /// The rail runs on below this event, to the next. + pub(super) fn tail(mut self, tail: bool) -> Self { + self.tail = tail; + self + } +} + +impl ParentElement for TimelineItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for TimelineItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let slot = theme.control_height(ControlSize::Sm); + let dot = theme.status_dot() * 1.34; + let (marker, inset) = match self.marker { + Marker::Dot => ( + div() + .size(dot) + .rounded_full() + .bg(self.tone.dot(colors)) + .into_any_element(), + (slot - dot) / 2.0, + ), + Marker::Icon(icon) => { + let (fill, ink) = self.tone.colors(colors); + let ring = div() + .flex() + .items_center() + .justify_center() + .size(slot) + .rounded_full() + .bg(fill) + .child(Icon::new(icon).size(IconSize::Xs).color(ink)); + (ring.into_any_element(), Rems::default()) + } + Marker::Element(element) => (element, Rems::default()), + }; + let reach = dot / 2.0 - inset; + div() + .flex() + .gap_3() + .child( + div() + .flex() + .flex_col() + .items_center() + .flex_none() + .w(slot) + .child( + div() + .flex() + .items_center() + .justify_center() + .size(slot) + .child(marker), + ) + .when(self.tail, |rail| { + rail.child( + div() + .flex_1() + .mt(reach) + .mb(reach) + .border_l_1() + .border_color(colors.border), + ) + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .when(self.tail, |content| content.pb_6()) + .child( + div() + .flex() + .items_center() + .gap_3() + .min_h(slot) + .text_size(theme.text_size(TextSize::Base)) + .text_color(colors.fg) + .child(div().flex_1().min_w_0().child(self.title)) + .when_some(self.time, |head, time| { + head.child( + div() + .flex_none() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_subtle) + .child(time), + ) + }), + ) + .when(!self.body.is_empty(), |content| { + content.child( + div() + .mt_1() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .children(self.body), + ) + }), + ) + } +} + +/// Events down a rail in the order given, each marked by a dot, an icon or an avatar. +#[derive(IntoElement, Default)] +pub struct Timeline { + items: Vec, +} + +impl Timeline { + pub fn new() -> Self { + Self::default() + } + + pub fn item(mut self, item: TimelineItem) -> Self { + self.items.push(item); + self + } +} + +impl RenderOnce for Timeline { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let last = self.items.len().saturating_sub(1); + div().flex().flex_col().children( + self.items + .into_iter() + .enumerate() + .map(|(ix, item)| item.tail(ix < last)), + ) + } +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 333d47f..c4f7b90 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -284,6 +284,11 @@ impl Theme { px_to_rems(128.0) } + /// The label column of a description list. + pub fn label_width(&self) -> Rems { + px_to_rems(160.0) + } + /// Widest a centered block of prose runs, such as an empty state's help. pub fn prose_width(&self) -> Rems { px_to_rems(320.0) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 4f9c726..d27c265 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -61,13 +61,13 @@ ## T12b Data Display · Records -- [ ] DescriptionList -- [ ] PropertyGrid -- [ ] KeyValueList → `data_display::DescriptionList` -- [ ] InfoRow → `data_display::DescriptionList` -- [ ] Timeline / TimelineItem -- [ ] ActivityFeed -- [ ] Changelog +- [x] DescriptionList → `data_display::DescriptionList` +- [x] PropertyGrid → `data_display::PropertyGrid`, `PropertyGroup` +- [x] KeyValueList → `data_display::DescriptionList` +- [x] InfoRow → `data_display::DescriptionList` +- [x] Timeline / TimelineItem → `data_display::Timeline`, `TimelineItem` +- [x] ActivityFeed → `data_display::ActivityFeed`, `Activity` +- [x] Changelog → `data_display::Changelog`, `Release`, `Change` ## T12c Data Display · Media & measures