T12c Data Display: Carousel, Gallery, BeforeAfter, Watermark (review pending: codex 401)
This commit is contained in:
19 files changed
+1004
-12
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user