diff --git a/TASKS.md b/TASKS.md index 4e6e5d2..0ac6d00 100644 --- a/TASKS.md +++ b/TASKS.md @@ -44,7 +44,8 @@ Per-component lines live in `tasks/`. Tags there: - [x] T11d Motion · Effects - [ ] T12a Data Display · People & numbers - [ ] T12b Data Display · Records -- [ ] T12c Data Display · Media & measures +- [ ] T12c Data Display · Media +- [ ] T12d Data Display · Codes & measures - [ ] T13 Lists & Trees - [ ] T14 Tables - [ ] T15 Charts @@ -183,3 +184,4 @@ Per-component lines live in `tasks/`. Tags there: | 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 | +| T12c | 1 | — | pending: codex still logged out | diff --git a/assets/icons/chevrons-left-right.svg b/assets/icons/chevrons-left-right.svg new file mode 100644 index 0000000..2025a3b --- /dev/null +++ b/assets/icons/chevrons-left-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/gallery/assets/atrium-before.jpg b/examples/gallery/assets/atrium-before.jpg new file mode 100644 index 0000000..f89b728 Binary files /dev/null and b/examples/gallery/assets/atrium-before.jpg differ diff --git a/examples/gallery/assets/atrium-olive.jpg b/examples/gallery/assets/atrium-olive.jpg new file mode 100644 index 0000000..7d757aa Binary files /dev/null and b/examples/gallery/assets/atrium-olive.jpg differ diff --git a/examples/gallery/assets/atrium-stair.jpg b/examples/gallery/assets/atrium-stair.jpg new file mode 100644 index 0000000..24a54a4 Binary files /dev/null and b/examples/gallery/assets/atrium-stair.jpg differ diff --git a/examples/gallery/assets/dunes-grass.jpg b/examples/gallery/assets/dunes-grass.jpg new file mode 100644 index 0000000..563b161 Binary files /dev/null and b/examples/gallery/assets/dunes-grass.jpg differ diff --git a/examples/gallery/assets/dunes-sun.jpg b/examples/gallery/assets/dunes-sun.jpg new file mode 100644 index 0000000..28221ee Binary files /dev/null and b/examples/gallery/assets/dunes-sun.jpg differ diff --git a/examples/gallery/pages/data/media.rs b/examples/gallery/pages/data/media.rs new file mode 100644 index 0000000..c7d5260 --- /dev/null +++ b/examples/gallery/pages/data/media.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::{BeforeAfter, Carousel, Gallery, Watermark}, + overlays::Slide, + primitives::Image, + theme::{ActiveTheme, Radius, TextSize}, + typography::{Caption, Paragraph, Title}, +}; +use gpui::{App, IntoElement, ObjectFit, ParentElement, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +macro_rules! asset { + ($name:literal) => { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/", + $name + ) + }; +} + +const PICTURES: [(&str, &str); 6] = [ + (asset!("dunes-sun.jpg"), "Sunset over the dunes"), + (asset!("atrium-stair.jpg"), "A stair that turns"), + (asset!("dunes-grass.jpg"), "Marram grass"), + (asset!("atrium-olive.jpg"), "An olive by the glass"), + (asset!("dunes.jpg"), "Dunes at first light"), + (asset!("atrium.jpg"), "An atrium, noon"), +]; + +pub fn carousel(cx: &mut App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let colors = &theme.colors; + let slides = PICTURES[..4] + .iter() + .enumerate() + .map(|(ix, (path, caption))| { + div() + .relative() + .size_full() + .child( + Image::new(("carousel-picture", ix), Path::new(*path)) + .fit(ObjectFit::Cover) + .size_full(), + ) + .child( + div() + .absolute() + .left_4() + .bottom_4() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.media_backdrop.opacity(0.5)) + .text_color(colors.on_media) + .text_size(theme.text_size(TextSize::Sm)) + .child(*caption), + ) + }); + section( + "Carousel", + "Slides one at a time, gliding between them; past the last comes the first. Arrows show on hover, the dots stretch toward where it goes, and Left and Right step it. With autoplay it turns on its own and holds while pointed at.", + cx, + ) + .child(div().flex().child(probe( + "carousel", + Carousel::new("carousel").w(px(560.)).h_72().children(slides), + ))) +} + +pub fn gallery(cx: &mut App) -> impl IntoElement + use<> { + section( + "Gallery", + "Pictures in square tiles. Press one, or Enter on a focused tile, to open the lightbox there.", + cx, + ) + .child(div().flex().child(probe( + "gallery", + div().w(px(420.)).child(Gallery::new( + "gallery", + PICTURES.map(|(path, caption)| Slide::new(Path::new(path), caption)), + )), + ))) +} + +pub fn before_after(cx: &mut App) -> impl IntoElement + use<> { + let picture = |id: &'static str, path: &'static str| { + Image::new(id, Path::new(path)) + .fit(ObjectFit::Cover) + .size_full() + }; + section( + "BeforeAfter", + "One picture twice: flat as it came, and graded. Drag the divider, press anywhere on the picture, or use Left and Right.", + cx, + ) + .child(div().flex().child(probe( + "before-after", + BeforeAfter::new( + "before-after", + picture("before-flat", asset!("atrium-before.jpg")), + picture("before-graded", asset!("atrium.jpg")), + ) + .w(px(480.)) + .h(px(320.)), + ))) +} + +pub fn watermark(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let read = keep("watermark-read", || false, window, cx); + let (done, mark) = (*read.read(cx), read.clone()); + let theme = cx.theme(); + section( + "Watermark", + "Faint text over a page, for drafts and shared screens. The rows run level, and presses reach the page below.", + cx, + ) + .child( + Watermark::new("watermark", "Confidential · Ada Lovelace") + .w(px(560.)) + .p_6() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .child(Title::new("Plan for the fourth quarter")) + .child( + div().mt_3().child(Paragraph::new( + "Three launches, one migration, and a quieter support queue. The launches share a date so the notes can go out once; the migration waits for the new year.", + )), + ) + .child( + div() + .mt_4() + .flex() + .items_center() + .gap_3() + .child( + Button::new("watermark-read", "Mark as read") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| set(&mark, true, cx)), + ) + .child(Caption::new(if done { "Read." } else { "Unread." })), + ), + ) +} diff --git a/examples/gallery/pages/data/mod.rs b/examples/gallery/pages/data/mod.rs index 4da35e3..63b913c 100644 --- a/examples/gallery/pages/data/mod.rs +++ b/examples/gallery/pages/data/mod.rs @@ -1,3 +1,4 @@ +mod media; mod people; mod records; @@ -29,6 +30,24 @@ const SCRIPT: &[Step] = &[ Step::Click("feed-post"), Step::Wait(300), Step::Shot("feed-arrival"), + Step::Hover("carousel"), + Step::Wait(300), + Step::Shot("carousel-hover"), + Step::DownAt("carousel", 530.0, 133.0), + Step::UpAt("carousel", 530.0, 133.0), + Step::Wait(600), + Step::Shot("carousel-next"), + Step::DownAt("gallery", 60.0, 60.0), + Step::UpAt("gallery", 60.0, 60.0), + Step::Wait(400), + Step::Shot("gallery-lightbox"), + Step::Key("escape"), + Step::Wait(300), + Step::DownAt("before-after", 240.0, 160.0), + Step::DragTo("before-after", 390.0, 160.0), + Step::UpAt("before-after", 390.0, 160.0), + Step::Wait(200), + Step::Shot("before-after-drag"), Step::Rest, ]; @@ -44,5 +63,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(records::timelines(cx)) .child(records::feed(window, cx)) .child(records::changelog(cx)) + .child(media::carousel(cx)) + .child(media::gallery(cx)) + .child(media::before_after(cx)) + .child(media::watermark(window, cx)) .into_any_element() } diff --git a/src/data_display/before_after.rs b/src/data_display/before_after.rs new file mode 100644 index 0000000..ccb7591 --- /dev/null +++ b/src/data_display/before_after.rs @@ -0,0 +1,205 @@ +use gpui::{ + AnyElement, App, Bounds, Div, DragMoveEvent, ElementId, EmptyView, EntityId, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, + StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, prelude::*, relative, +}; + +use crate::{ + primitives::{FocusRing, Icon, IconName, tab_stop}, + theme::{ActiveTheme, ControlSize, Elevation, IconSize}, +}; + +/// A drag of one comparison's divider. +struct Divider { + owner: EntityId, +} + +/// Where the divider stands, as a share of its travel, and the box it travels in. +#[derive(Clone, Copy)] +struct Split { + share: f32, + bounds: Bounds, +} + +/// The share of the travel under `at`; the travel stops `inset` short of each side. None when the box is narrower than the handle. +fn share_at(at: Point, bounds: Bounds, inset: Pixels) -> Option { + let travel = bounds.size.width - inset * 2.0; + (travel > Pixels::ZERO).then(|| ((at.x - bounds.left() - inset) / travel).clamp(0.0, 1.0)) +} + +/// Two versions of a picture, one over the other: `before` shows left of the divider, `after` right of it. Drag the divider, press anywhere, or use Left and Right. Give it a size. +#[derive(IntoElement)] +pub struct BeforeAfter { + id: ElementId, + base: Div, + before: AnyElement, + after: AnyElement, +} + +impl BeforeAfter { + pub fn new( + id: impl Into, + before: impl IntoElement, + after: impl IntoElement, + ) -> Self { + Self { + id: id.into(), + base: div(), + before: before.into_any_element(), + after: after.into_any_element(), + } + } +} + +impl Styled for BeforeAfter { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for BeforeAfter { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let split = window.use_keyed_state((self.id.clone(), "split"), cx, |_, _| Split { + share: 0.5, + bounds: Bounds::default(), + }); + let Split { share, bounds } = *split.read(cx); + let owner = split.entity_id(); + let theme = cx.theme(); + let colors = &theme.colors; + let handle = theme.control_height(ControlSize::Lg); + let inset = handle.to_pixels(window.rem_size()) / 2.0; + let width = bounds.size.width; + let x = inset + (width - inset * 2.0) * share; + let set = { + let (split, id) = (split.clone(), self.id.clone()); + move |share: f32, cx: &mut App| { + split.update(cx, |split, cx| { + split.share = share; + log::debug!("before-after {id:?}: divider at {share:.2}"); + cx.notify(); + }) + } + }; + let (press, drag, keys, measured) = (set.clone(), set.clone(), set, split.clone()); + let overlay = (width > Pixels::ZERO).then(|| { + [ + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(x) + .overflow_hidden() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(width) + .child(self.before), + ) + .into_any_element(), + div() + .absolute() + .top_0() + .bottom_0() + .left(x) + .border_l_1() + .border_color(colors.on_media) + .into_any_element(), + div() + .absolute() + .top(relative(0.5)) + .mt(handle * -0.5) + .left(x - inset) + .flex() + .items_center() + .justify_center() + .size(handle) + .rounded_full() + .bg(colors.on_media) + .shadow(theme.elevation(Elevation::Raised)) + .child( + Icon::new(IconName::ChevronsLeftRight) + .size(IconSize::Sm) + .color(colors.media_backdrop), + ) + .into_any_element(), + ] + }); + self.base + .id(self.id) + .relative() + .overflow_hidden() + .border_1() + .border_color(gpui::transparent_black()) + .track_focus(&focus) + .focus_ring(cx) + .cursor_ew_resize() + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + let bounds = measured.read(cx).bounds; + if let Some(share) = share_at(event.position, bounds, inset) { + press(share, cx); + } + }) + .on_drag(Divider { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) + .on_drag_move(move |event: &DragMoveEvent, _, cx| { + if event.drag(cx).owner != owner { + return; + } + if let Some(share) = share_at(event.event.position, bounds, inset) { + drag(share, cx); + } + }) + .on_key_down(move |event, _, cx| { + let to = match event.keystroke.key.as_str() { + "left" => share - 0.05, + "right" => share + 0.05, + _ => return, + }; + cx.stop_propagation(); + keys(to.clamp(0.0, 1.0), cx); + }) + .child(div().absolute().inset_0().child(self.after)) + .children(overlay.into_iter().flatten()) + .child( + canvas( + move |bounds, window, cx| { + if split.read(cx).bounds != bounds { + split.update(cx, |split, cx| { + split.bounds = bounds; + cx.notify(); + }); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + } +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, px, size}; + + use super::share_at; + + #[test] + fn the_divider_travels_inside_the_handle_inset() { + let bounds = Bounds::new(point(px(100.0), px(0.0)), size(px(240.0), px(100.0))); + let at = |x: f32| share_at(point(px(x), px(50.0)), bounds, px(20.0)); + assert_eq!(at(120.0), Some(0.0)); + assert_eq!(at(220.0), Some(0.5)); + assert_eq!(at(320.0), Some(1.0)); + assert_eq!(at(90.0), Some(0.0), "left of the box holds at the start"); + assert_eq!(at(400.0), Some(1.0)); + let narrow = Bounds::new(point(px(0.0), px(0.0)), size(px(30.0), px(30.0))); + assert_eq!(share_at(point(px(10.0), px(10.0)), narrow, px(20.0)), None); + } +} diff --git a/src/data_display/carousel.rs b/src/data_display/carousel.rs new file mode 100644 index 0000000..9bf049a --- /dev/null +++ b/src/data_display/carousel.rs @@ -0,0 +1,302 @@ +use std::{ + rc::Rc, + time::{Duration, Instant}, +}; + +use gpui::{ + AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, StatefulInteractiveElement, StyleRefinement, Styled, Task, Window, div, prelude::*, + relative, +}; + +use crate::{ + motion, + primitives::{FocusRing, Icon, IconName, tab_stop}, + theme::{ActiveTheme, ControlSize, IconSize, Mix}, +}; + +/// Where the deck heads, counted past the ends so a wrap glides one step, where the glide began, and when. +struct Deck { + at: isize, + from: f32, + since: Instant, + length: Duration, + hovered: bool, + focused: bool, + shown: bool, + shown_since: Instant, + every: Option, + _ticker: Option>, +} + +impl Deck { + fn place(&self) -> f32 { + let share = (self.since.elapsed().as_secs_f32() / self.length.as_secs_f32()).min(1.0); + self.from + (self.at as f32 - self.from) * motion::ease_in_out_cubic(share) + } + + fn go(&mut self, to: isize) { + self.from = self.place(); + self.at = to; + self.since = Instant::now(); + } +} + +/// How far slide `ix` sits from `place`, in slides, on the nearer side of a ring of `count`. +fn offset(ix: usize, place: f32, count: usize) -> f32 { + let count = count as f32; + let ahead = (ix as f32 - place).rem_euclid(count); + if ahead > count / 2.0 { + ahead - count + } else { + ahead + } +} + +type Step = Rc; + +/// Slides one at a time, gliding between them. Arrows show on hover, dots below say where you are, and Left and Right step when it has focus. Past the last slide comes the first. Give it a height. +#[derive(IntoElement)] +pub struct Carousel { + id: ElementId, + base: Div, + slides: Vec, + autoplay: Option, +} + +impl Carousel { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + base: div(), + slides: Vec::new(), + autoplay: None, + } + } + + /// Steps on its own every `every`, holding while pointed at or focused. + pub fn autoplay(mut self, every: Duration) -> Self { + self.autoplay = Some(every); + self + } +} + +impl Styled for Carousel { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Carousel { + fn extend(&mut self, elements: impl IntoIterator) { + self.slides.extend(elements); + } +} + +impl RenderOnce for Carousel { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.slides.len(); + assert!(count > 0, "carousel {:?} has no slides", self.id); + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let deck = window.use_keyed_state((self.id.clone(), "deck"), cx, |_, _| Deck { + at: 0, + from: 0.0, + since: Instant::now(), + length: motion::SLOW, + hovered: false, + focused: false, + shown: false, + shown_since: Instant::now(), + every: None, + _ticker: None, + }); + let (length, fade) = ( + motion::duration(motion::SLOW, cx), + motion::duration(motion::FAST, cx), + ); + let focused = focus.contains_focused(window, cx); + let every = self.autoplay.filter(|_| count > 1); + let name = self.id.clone(); + deck.update(cx, |deck, cx| { + deck.length = length; + deck.focused = focused; + let shown = deck.hovered || focused; + if deck.shown != shown { + deck.shown = shown; + deck.shown_since = Instant::now(); + } + if deck.every != every { + deck.every = every; + deck._ticker = every.map(|every| { + cx.spawn(async move |deck, cx| { + loop { + cx.background_executor().timer(every).await; + let turned = deck.update(cx, |deck, cx| { + if !deck.hovered && !deck.focused { + deck.go(deck.at + 1); + log::debug!("carousel {name:?}: autoplay to {}", deck.at); + cx.notify(); + } + }); + if turned.is_err() { + return; + } + } + }) + }); + } + }); + let (place, current, arrows, moving) = { + let deck = deck.read(cx); + let fading = (deck.shown_since.elapsed().as_secs_f32() / fade.as_secs_f32()).min(1.0); + let arrows = if deck.shown { fading } else { 1.0 - fading }; + let moving = deck.since.elapsed() < length || fading < 1.0; + ( + deck.place(), + deck.at.rem_euclid(count as isize) as usize, + arrows, + moving, + ) + }; + if moving { + window.request_animation_frame(); + } + let step: Step = { + let (deck, id) = (deck.clone(), self.id.clone()); + Rc::new(move |delta, cx| { + deck.update(cx, |deck, cx| { + deck.go(deck.at + delta); + log::info!( + "carousel {id:?}: slide {}", + deck.at.rem_euclid(count as isize) + ); + cx.notify(); + }) + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let button = theme.control_height(ControlSize::Lg); + let dot = theme.status_dot(); + let arrow = |name: &'static str, icon: IconName, delta: isize| { + let step = step.clone(); + div() + .id((self.id.clone(), name)) + .absolute() + .top(relative(0.5)) + .mt(button * -0.5) + .flex() + .items_center() + .justify_center() + .size(button) + .rounded_full() + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .opacity(arrows) + .when(arrows > 0.0, |arrow| { + arrow + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_click(move |_, _, cx| step(delta, cx)) + }) + .child(Icon::new(icon).size(IconSize::Sm).color(colors.fg)) + }; + let keys = step.clone(); + let viewport = div() + .id((self.id.clone(), "viewport")) + .relative() + .flex_1() + .min_h_0() + .w_full() + .overflow_hidden() + .border_1() + .border_color(gpui::transparent_black()) + .track_focus(&focus) + .focus_ring(cx) + .when(count > 1, |viewport| { + viewport.on_key_down(move |event, _, cx| { + let delta = match event.keystroke.key.as_str() { + "left" => -1, + "right" => 1, + _ => return, + }; + cx.stop_propagation(); + keys(delta, cx); + }) + }) + .children(self.slides.into_iter().enumerate().map(|(ix, slide)| { + div() + .absolute() + .top_0() + .bottom_0() + .w_full() + .left(relative(offset(ix, place, count))) + .child(slide) + })) + .when(count > 1, |viewport| { + viewport + .child(arrow("back", IconName::ChevronLeft, -1).left_3()) + .child(arrow("next", IconName::ChevronRight, 1).right_3()) + }); + let dots: Vec<_> = (0..count) + .map(|ix| { + let near = (1.0 - offset(ix, place, count).abs()).max(0.0); + let step = step.clone(); + div() + .id((self.id.clone(), format!("dot-{ix}"))) + .py_1() + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| step(ix as isize - current as isize, cx)) + .child( + div() + .h(dot) + .w(dot + dot * 2.0 * near) + .rounded_full() + .bg(colors.fg_subtle.mix(&colors.fg, near)), + ) + }) + .collect(); + let hover = deck.clone(); + self.base + .id(self.id) + .flex() + .flex_col() + .gap_2() + .on_hover(move |hovered, _, cx| { + hover.update(cx, |deck, cx| { + deck.hovered = *hovered; + cx.notify(); + }) + }) + .child(viewport) + .when(count > 1, |carousel| { + carousel.child(div().flex().justify_center().gap_1p5().children(dots)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::offset; + + #[test] + fn slides_sit_on_the_nearer_side_of_the_ring() { + assert_eq!(offset(0, 0.0, 3), 0.0); + assert_eq!(offset(1, 0.0, 3), 1.0); + assert_eq!(offset(2, 0.0, 3), -1.0); + assert_eq!( + offset(0, 2.5, 3), + 0.5, + "past the last, the first enters from the right" + ); + assert_eq!(offset(2, 2.5, 3), -0.5); + assert_eq!(offset(1, 0.5, 2), 0.5); + assert_eq!(offset(0, 0.5, 2), -0.5); + } +} diff --git a/src/data_display/gallery.rs b/src/data_display/gallery.rs new file mode 100644 index 0000000..e8d2512 --- /dev/null +++ b/src/data_display/gallery.rs @@ -0,0 +1,112 @@ +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, + RenderOnce, StatefulInteractiveElement, Styled, Window, div, +}; + +use crate::{ + layout::AspectRatio, + overlays::{Lightbox, Slide}, + primitives::{FocusRing, Image, tab_stop}, +}; + +/// Pictures in square tiles. A press, or Enter on a focused tile, opens the lightbox at that picture. +#[derive(IntoElement)] +pub struct Gallery { + id: ElementId, + slides: Vec, + columns: u16, +} + +impl Gallery { + pub fn new(id: impl Into, slides: impl IntoIterator) -> Self { + Self { + id: id.into(), + slides: slides.into_iter().collect(), + columns: 3, + } + } + + pub fn columns(mut self, columns: u16) -> Self { + assert!(columns > 0, "a gallery needs a column"); + self.columns = columns; + self + } +} + +impl RenderOnce for Gallery { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| None::); + let show = |at: Option| { + let (open, id) = (open.clone(), self.id.clone()); + move |cx: &mut App| { + log::info!( + "gallery {id:?}: {}", + at.map_or("closed".into(), |at| format!("opened {at}")) + ); + open.update(cx, |open, cx| { + *open = at; + cx.notify(); + }) + } + }; + let mut tiles = Vec::with_capacity(self.slides.len()); + for (ix, slide) in self.slides.iter().enumerate() { + let focus = tab_stop( + (self.id.clone(), format!("focus-{ix}")).into(), + true, + window, + cx, + ); + let (press, keys) = (show(Some(ix)), show(Some(ix))); + tiles.push( + div() + .id((self.id.clone(), format!("tile-{ix}"))) + .border_1() + .border_color(gpui::transparent_black()) + .track_focus(&focus) + .focus_ring(cx) + .cursor_pointer() + .hover(|style| style.opacity(0.85)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| press(cx)) + .on_key_down(move |event, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + keys(cx); + } + }) + .child( + AspectRatio::new(1.0).child( + Image::new( + (self.id.clone(), format!("thumb-{ix}")), + slide.source.clone(), + ) + .fit(ObjectFit::Cover) + .size_full(), + ), + ), + ); + } + let lightbox = (*open.read(cx)).map(|at| { + let (close, step) = (show(None), open.clone()); + Lightbox::new( + (self.id.clone(), "lightbox"), + self.slides, + at, + move |_, cx| close(cx), + ) + .on_step(move |to, _, cx| { + step.update(cx, |open, cx| { + *open = Some(to); + cx.notify(); + }) + }) + }); + div() + .grid() + .grid_cols(self.columns) + .gap_1() + .children(tiles) + .children(lightbox) + } +} diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index 847864a..eb14e83 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -1,17 +1,25 @@ mod avatar; mod badge; +mod before_after; +mod carousel; mod changelog; mod feed; +mod gallery; mod records; mod stat; #[cfg(test)] mod tests; mod timeline; +mod watermark; pub use avatar::{Avatar, AvatarGroup, Presence, UserChip}; pub use badge::{Badge, CountBadge, DotBadge, Tag, Tone}; +pub use before_after::BeforeAfter; +pub use carousel::Carousel; pub use changelog::{Change, Changelog, Release}; pub use feed::{Activity, ActivityFeed}; +pub use gallery::Gallery; pub use records::{DescriptionList, PropertyGrid, PropertyGroup}; pub use stat::{KpiCard, Statistic, TrendIndicator}; pub use timeline::{Timeline, TimelineItem}; +pub use watermark::Watermark; diff --git a/src/data_display/tests.rs b/src/data_display/tests.rs index a1b8ea1..6fb551c 100644 --- a/src/data_display/tests.rs +++ b/src/data_display/tests.rs @@ -1,11 +1,15 @@ -use std::{cell::Cell, rc::Rc}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + time::Duration, +}; use gpui::{ Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px, }; -use super::{PropertyGrid, PropertyGroup}; +use super::{Carousel, PropertyGrid, PropertyGroup}; use crate::{primitives::Measure, theme::Theme}; /// A property grid whose drawn height the test reads. @@ -63,3 +67,74 @@ fn a_property_group_folds_from_its_header_and_stays_folded(cx: &mut TestAppConte settle(cx); assert_eq!(height.get(), open, "a second press opens it"); } + +/// A three-slide carousel, 200 wide, that turns every five seconds; each slide reports where it sits. +struct Deck(Rc>); + +impl Render for Deck { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let slides = (0..3).map(|ix| { + let seen = self.0.clone(); + Measure::new(("slide", ix), move |bounds, _, _| { + seen.borrow_mut()[ix] = bounds.origin.x + }) + .size_full() + }); + Carousel::new("deck") + .autoplay(Duration::from_secs(5)) + .w(px(200.0)) + .h(px(100.0)) + .children(slides) + } +} + +fn deck(cx: &mut TestAppContext) -> (Rc>, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + }); + let places = Rc::new(RefCell::new([Pixels::ZERO; 3])); + let seen = places.clone(); + let (_, cx) = cx.add_window_view(|_, _| Deck(seen)); + settle(cx); + (places, cx) +} + +/// The slide in view: the one at the viewport's left edge, inside its border. +fn front(places: &Rc>) -> Option { + places.borrow().iter().position(|x| *x == px(1.0)) +} + +fn wait(time: Duration, cx: &mut VisualTestContext) { + cx.executor().advance_clock(time); + settle(cx); +} + +#[gpui::test] +fn autoplay_turns_the_carousel_and_holds_while_pointed_at(cx: &mut TestAppContext) { + let (places, cx) = deck(cx); + assert_eq!(front(&places), Some(0)); + wait(Duration::from_secs(5), cx); + assert_eq!(front(&places), Some(1), "five seconds turn it once"); + cx.simulate_mouse_move(point(px(100.0), px(50.0)), None, Modifiers::none()); + settle(cx); + wait(Duration::from_secs(12), cx); + assert_eq!(front(&places), Some(1), "held while pointed at"); + cx.simulate_mouse_move(point(px(600.0), px(400.0)), None, Modifiers::none()); + settle(cx); + wait(Duration::from_secs(5), cx); + assert_eq!(front(&places), Some(2)); +} + +#[gpui::test] +fn left_from_the_first_slide_wraps_to_the_last(cx: &mut TestAppContext) { + let (places, cx) = deck(cx); + cx.simulate_click(point(px(100.0), px(50.0)), Modifiers::none()); + settle(cx); + cx.simulate_keystrokes("left"); + settle(cx); + assert_eq!(front(&places), Some(2)); + cx.simulate_keystrokes("right"); + settle(cx); + assert_eq!(front(&places), Some(0)); +} diff --git a/src/data_display/watermark.rs b/src/data_display/watermark.rs new file mode 100644 index 0000000..aa1f799 --- /dev/null +++ b/src/data_display/watermark.rs @@ -0,0 +1,88 @@ +use gpui::{ + AnyElement, App, Div, ElementId, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, + Size, StyleRefinement, Styled, Window, canvas, div, prelude::*, +}; + +use crate::theme::{ActiveTheme, TextSize}; + +/// Faint text over its content, repeated in level rows, every other row shifted half a tile. gpui draws text upright, so the tiles do not tilt. Presses pass through to the content. +#[derive(IntoElement)] +pub struct Watermark { + id: ElementId, + text: SharedString, + base: Div, +} + +impl Watermark { + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into(), + base: div(), + } + } +} + +impl Styled for Watermark { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Watermark { + fn extend(&mut self, elements: impl IntoIterator) { + self.base.extend(elements); + } +} + +impl RenderOnce for Watermark { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let area = window.use_keyed_state(self.id, cx, |_, _| Size::::default()); + let room = *area.read(cx); + let theme = cx.theme(); + let rem = window.rem_size(); + let tile = theme.watermark_tile(); + let (wide, tall) = (tile.width.to_pixels(rem), tile.height.to_pixels(rem)); + let columns = (room.width / wide).ceil() as usize + 1; + let rows = (room.height / tall).ceil() as usize; + let text = self.text; + let layer = div() + .absolute() + .inset_0() + .overflow_hidden() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg.opacity(0.07)) + .children((0..rows).map(|row| { + div() + .flex() + .when(row % 2 == 1, |row| row.ml(wide * -0.5)) + .children((0..columns).map(|_| { + div() + .flex_none() + .flex() + .items_center() + .justify_center() + .w(wide) + .h(tall) + .whitespace_nowrap() + .child(text.clone()) + })) + })); + self.base.relative().child(layer).child( + canvas( + move |bounds, window, cx| { + if *area.read(cx) != bounds.size { + area.update(cx, |area, cx| { + *area = bounds.size; + cx.notify(); + }); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + } +} diff --git a/src/overlays/lightbox.rs b/src/overlays/lightbox.rs index 15e4646..18e102e 100644 --- a/src/overlays/lightbox.rs +++ b/src/overlays/lightbox.rs @@ -16,8 +16,8 @@ use crate::{ /// One picture in a lightbox, and the line under it. #[derive(Clone)] pub struct Slide { - source: ImageSource, - caption: SharedString, + pub(crate) source: ImageSource, + pub(crate) caption: SharedString, } impl Slide { diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 9f600d9..a0fea02 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -74,6 +74,7 @@ icons! { ChevronRight => "chevron-right", ChevronUp => "chevron-up", ChevronsLeft => "chevrons-left", + ChevronsLeftRight => "chevrons-left-right", ChevronsRight => "chevrons-right", ChevronsUpDown => "chevrons-up-down", CircleAlert => "circle-alert", diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index c4f7b90..1355940 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -284,6 +284,11 @@ impl Theme { px_to_rems(128.0) } + /// One repeat of a watermark's text. + pub fn watermark_tile(&self) -> Size { + size(px_to_rems(200.0), px_to_rems(96.0)) + } + /// The label column of a description list. pub fn label_width(&self) -> Rems { px_to_rems(160.0) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index d27c265..12ebcf1 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -69,12 +69,17 @@ - [x] ActivityFeed → `data_display::ActivityFeed`, `Activity` - [x] Changelog → `data_display::Changelog`, `Release`, `Change` -## T12c Data Display · Media & measures +## T12c Data Display · Media + +- [x] Carousel → `data_display::Carousel` +- [x] Gallery → `data_display::Gallery`, opening `overlays::Lightbox` +- [x] Accordion list → `layout::Accordion` +- [x] Card Grid → `layout::SimpleGrid` +- [x] BeforeAfter → `data_display::BeforeAfter` +- [x] Watermark → `data_display::Watermark`; its rows run level, since gpui draws text upright + +## T12d Data Display · Codes & measures -- [ ] Carousel -- [ ] Gallery -- [ ] Accordion list → `layout::Accordion` -- [ ] Card Grid → `layout::SimpleGrid` - [ ] QRCode - [ ] Barcode - [ ] ColorSwatch display → `forms::ColorSwatch` @@ -83,8 +88,6 @@ - [ ] UsageBar - [ ] Rating display → `forms::Rating` - [ ] Comparison -- [ ] BeforeAfter -- [ ] Watermark ## T13 Lists & Trees — `src/lists`