240 lines
7.6 KiB
Rust
240 lines
7.6 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
|
Pixels, RenderOnce, ScrollDelta, SharedString, StatefulInteractiveElement, Styled, TouchPhase,
|
|
Window, div, prelude::*,
|
|
};
|
|
use web_time::Instant;
|
|
|
|
use crate::{
|
|
data_display::Tone,
|
|
forms::Run,
|
|
motion,
|
|
primitives::{Icon, IconName},
|
|
theme::{ActiveTheme, IconSize, TextSize},
|
|
};
|
|
|
|
/// One action behind a swiped row.
|
|
pub struct SwipeAction {
|
|
label: SharedString,
|
|
icon: IconName,
|
|
tone: Tone,
|
|
run: Run,
|
|
}
|
|
|
|
impl SwipeAction {
|
|
pub fn new(
|
|
label: impl Into<SharedString>,
|
|
icon: IconName,
|
|
run: impl Fn(&mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
Self {
|
|
label: label.into(),
|
|
icon,
|
|
tone: Tone::Neutral,
|
|
run: Rc::new(run),
|
|
}
|
|
}
|
|
|
|
pub fn tone(mut self, tone: impl Into<Tone>) -> Self {
|
|
self.tone = tone.into();
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Where the row rests or follows the fingers, whether they are down and on which axis, and the glide to rest.
|
|
#[derive(Default)]
|
|
struct Swipe {
|
|
offset: Pixels,
|
|
fingers: bool,
|
|
across: Option<bool>,
|
|
from: Pixels,
|
|
since: Option<Instant>,
|
|
}
|
|
|
|
impl Swipe {
|
|
fn now(&self, length: std::time::Duration) -> Pixels {
|
|
let Some(since) = self.since else {
|
|
return self.offset;
|
|
};
|
|
let share = (since.elapsed().as_secs_f32() / length.as_secs_f32()).min(1.0);
|
|
self.from + (self.offset - self.from) * motion::ease_out_cubic(share)
|
|
}
|
|
|
|
fn settle(&mut self, to: Pixels, length: std::time::Duration) {
|
|
self.from = self.now(length);
|
|
self.offset = to;
|
|
self.since = Some(Instant::now());
|
|
}
|
|
}
|
|
|
|
/// Where a released row rests: open past half its reach, shut before.
|
|
fn rest(offset: Pixels, reach: Pixels) -> Pixels {
|
|
if offset < reach * -0.5 {
|
|
-reach
|
|
} else {
|
|
Pixels::ZERO
|
|
}
|
|
}
|
|
|
|
/// A row that slides left under a two-finger swipe to show the actions behind it, and snaps open or shut when the fingers lift. A press on the open row, or on an action, shuts it.
|
|
#[derive(IntoElement)]
|
|
pub struct SwipeableListItem {
|
|
id: ElementId,
|
|
item: AnyElement,
|
|
actions: Vec<SwipeAction>,
|
|
}
|
|
|
|
impl SwipeableListItem {
|
|
pub fn new(id: impl Into<ElementId>, item: impl IntoElement) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
item: item.into_any_element(),
|
|
actions: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn action(mut self, action: SwipeAction) -> Self {
|
|
self.actions.push(action);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for SwipeableListItem {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let swipe = window.use_keyed_state((self.id.clone(), "swipe"), cx, |_, _| Swipe::default());
|
|
let length = motion::duration(motion::BASE, cx);
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let wide = theme.swipe_action();
|
|
let reach = wide.to_pixels(window.rem_size()) * self.actions.len() as f32;
|
|
let (at, settling) = {
|
|
let swipe = swipe.read(cx);
|
|
let settling = swipe.since.is_some_and(|since| since.elapsed() < length);
|
|
(swipe.now(length), settling)
|
|
};
|
|
if settling {
|
|
window.request_animation_frame();
|
|
}
|
|
let shut = {
|
|
let (swipe, id) = (swipe.clone(), self.id.clone());
|
|
move |cx: &mut App| {
|
|
swipe.update(cx, |swipe, cx| {
|
|
log::info!("swipeable row {id:?}: shut");
|
|
swipe.settle(Pixels::ZERO, length);
|
|
cx.notify();
|
|
})
|
|
}
|
|
};
|
|
let actions: Vec<_> = self
|
|
.actions
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(ix, action)| {
|
|
let (fill, ink) = action.tone.colors(colors);
|
|
let (run, shut) = (action.run, shut.clone());
|
|
div()
|
|
.id((self.id.clone(), format!("action-{ix}")))
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap_0p5()
|
|
.w(wide)
|
|
.h_full()
|
|
.bg(fill)
|
|
.text_color(ink)
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.cursor_pointer()
|
|
.on_click(move |_, window, cx| {
|
|
shut(cx);
|
|
run(window, cx);
|
|
})
|
|
.child(Icon::new(action.icon).size(IconSize::Sm).color(ink))
|
|
.child(action.label)
|
|
})
|
|
.collect();
|
|
let (fingers, pressed) = (swipe.clone(), shut);
|
|
let id = self.id.clone();
|
|
div()
|
|
.id(self.id.clone())
|
|
.relative()
|
|
.overflow_hidden()
|
|
.on_scroll_wheel(move |event, _, cx| {
|
|
let ScrollDelta::Pixels(delta) = event.delta else {
|
|
return;
|
|
};
|
|
fingers.update(cx, |swipe, cx| {
|
|
match event.touch_phase {
|
|
TouchPhase::Started => {
|
|
swipe.offset = swipe.now(length);
|
|
swipe.since = None;
|
|
swipe.fingers = true;
|
|
swipe.across = None;
|
|
}
|
|
TouchPhase::Moved if swipe.fingers => {
|
|
if !*swipe.across.get_or_insert(delta.x.abs() > delta.y.abs()) {
|
|
return;
|
|
}
|
|
swipe.offset = (swipe.offset + delta.x).clamp(-reach, Pixels::ZERO);
|
|
cx.stop_propagation();
|
|
}
|
|
TouchPhase::Ended if swipe.fingers => {
|
|
swipe.fingers = false;
|
|
if swipe.across != Some(true) {
|
|
return;
|
|
}
|
|
let to = rest(swipe.offset, reach);
|
|
log::info!(
|
|
"swipeable row {id:?}: {}",
|
|
if to < Pixels::ZERO { "open" } else { "shut" }
|
|
);
|
|
swipe.settle(to, length);
|
|
}
|
|
_ => return,
|
|
}
|
|
cx.notify();
|
|
})
|
|
})
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.right_0()
|
|
.flex()
|
|
.children(actions),
|
|
)
|
|
.child(
|
|
div()
|
|
.id((self.id, "row"))
|
|
.relative()
|
|
.left(at)
|
|
.bg(colors.bg)
|
|
.block_mouse_except_scroll()
|
|
.when(at < Pixels::ZERO, |row| {
|
|
row.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
|
cx.stop_propagation();
|
|
pressed(cx);
|
|
})
|
|
})
|
|
.child(self.item),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use gpui::{Pixels, px};
|
|
|
|
use super::rest;
|
|
|
|
#[test]
|
|
fn a_released_row_rests_open_past_half_its_reach() {
|
|
assert_eq!(rest(px(-50.0), px(144.0)), Pixels::ZERO);
|
|
assert_eq!(rest(px(-80.0), px(144.0)), px(-144.0));
|
|
assert_eq!(rest(Pixels::ZERO, px(144.0)), Pixels::ZERO);
|
|
}
|
|
}
|