T12c Data Display: Carousel, Gallery, BeforeAfter, Watermark (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 19:08:08 -04:00
1 parent 111a15c21c
commit 8798d95d7a
19 files changed
+1004 -12

No files matched your search

+3 -1
View File
@@ -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 |
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-chevrons-left-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 7-5 5 5 5" />
<path d="m15 7 5 5-5 5" />
</svg>

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+152
View File
@@ -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." })),
),
)
}
+23
View File
@@ -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()
}
+205
View File
@@ -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<Pixels>,
}
/// 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<Pixels>, bounds: Bounds<Pixels>, inset: Pixels) -> Option<f32> {
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<ElementId>,
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<Divider>, _, 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);
}
}
+302
View File
@@ -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<Duration>,
_ticker: Option<Task<()>>,
}
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<dyn Fn(isize, &mut App)>;
/// 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<AnyElement>,
autoplay: Option<Duration>,
}
impl Carousel {
pub fn new(id: impl Into<ElementId>) -> 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<Item = AnyElement>) {
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);
}
}
+112
View File
@@ -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<Slide>,
columns: u16,
}
impl Gallery {
pub fn new(id: impl Into<ElementId>, slides: impl IntoIterator<Item = Slide>) -> 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::<usize>);
let show = |at: Option<usize>| {
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)
}
}
+8
View File
@@ -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;
+77 -2
View File
@@ -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<RefCell<[Pixels; 3]>>);
impl Render for Deck {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<RefCell<[Pixels; 3]>>, &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<RefCell<[Pixels; 3]>>) -> Option<usize> {
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));
}
+88
View File
@@ -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<ElementId>, text: impl Into<SharedString>) -> 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<Item = AnyElement>) {
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::<Pixels>::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(),
)
}
}
+2 -2
View File
@@ -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 {
+1
View File
@@ -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",
+5
View File
@@ -284,6 +284,11 @@ impl Theme {
px_to_rems(128.0)
}
/// One repeat of a watermark's text.
pub fn watermark_tile(&self) -> Size<Rems> {
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)
+10 -7
View File
@@ -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`