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, 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) -> 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, from: Pixels, since: Option, } 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, } impl SwipeableListItem { pub fn new(id: impl Into, 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); } }