rosace_widgets/tree/
dismissible.rs1use std::sync::Arc;
15use rosace_core::types::{Point, Rect, Size};
16use rosace_render::Color;
17
18use super::{avail_w, BoxedWidget, Children, LayoutCtx, PaintCtx, Widget};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DismissDirection {
23 Horizontal,
25 EndToStart,
28 StartToEnd,
30}
31
32const DEFAULT_THRESHOLD: f32 = 0.35;
35
36pub struct Dismissible {
37 child: BoxedWidget,
38 background: Option<BoxedWidget>,
39 direction: DismissDirection,
40 threshold: f32,
41 on_dismissed: Option<Arc<dyn Fn() + Send + Sync>>,
42}
43
44impl Dismissible {
45 pub fn new(child: impl Widget + 'static) -> Self {
46 Self {
47 child: Box::new(child),
48 background: None,
49 direction: DismissDirection::Horizontal,
50 threshold: DEFAULT_THRESHOLD,
51 on_dismissed: None,
52 }
53 }
54
55 pub fn background(mut self, w: impl Widget + 'static) -> Self {
58 self.background = Some(Box::new(w));
59 self
60 }
61 pub fn direction(mut self, d: DismissDirection) -> Self {
62 self.direction = d;
63 self
64 }
65 pub fn threshold(mut self, t: f32) -> Self {
68 self.threshold = t.clamp(0.05, 0.95);
69 self
70 }
71 pub fn on_dismissed(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
75 self.on_dismissed = Some(Arc::new(f));
76 self
77 }
78
79 fn allows(&self, dx: f32) -> bool {
80 match self.direction {
81 DismissDirection::Horizontal => true,
82 DismissDirection::EndToStart => dx < 0.0,
83 DismissDirection::StartToEnd => dx > 0.0,
84 }
85 }
86}
87
88impl Widget for Dismissible {
89 fn children(&self) -> Children<'_> {
90 Children::One(&*self.child)
91 }
92
93 fn layout(&self, ctx: &LayoutCtx) -> Size {
94 let child_size = self.child.layout(ctx);
95 Size { width: avail_w(ctx.constraints), height: child_size.height }
96 }
97
98 fn paint(&self, ctx: &mut PaintCtx) {
99 let r = ctx.rect;
100 let ctrl = ctx.scroll_controller();
101
102 let drag_ctrl = ctrl.clone();
103 ctx.on_press_at(move |x, y| {
104 let (dx, _) = drag_ctrl.drag_delta(x, y);
105 if dx != 0.0 {
106 let o = drag_ctrl.offset.get();
107 drag_ctrl.offset.set([o[0] + dx, o[1]]);
108 }
109 });
110
111 let is_pressed = ctx.pressed();
112 let was_pressed = ctrl.was_pressed();
113 let mut dx = ctrl.offset.get()[0];
114
115 if !is_pressed && was_pressed {
116 let commit = dx.abs() >= r.size.width * self.threshold && self.allows(dx);
117 if commit {
118 let target = if dx < 0.0 { -r.size.width } else { r.size.width };
119 ctrl.offset.set([target, 0.0]);
120 dx = target;
121 if let Some(cb) = &self.on_dismissed {
122 cb();
123 }
124 } else {
125 ctrl.offset.set([0.0, 0.0]);
126 dx = 0.0;
127 }
128 ctrl.end_drag();
129 }
130 ctrl.set_was_pressed(is_pressed);
131
132 if dx.abs() > 0.001 {
133 ctx.request_animation();
134 }
135
136 if dx.abs() > 0.001 {
138 match &self.background {
139 Some(bg) => bg.paint(&mut ctx.child(r)),
140 None => draw_default_background(ctx, r, dx),
141 }
142 }
143
144 let child_rect = Rect { origin: Point { x: r.origin.x + dx, y: r.origin.y }, size: r.size };
145 ctx.record(rosace_render::DrawCommand::PushClip { rect: r });
146 self.child.paint(&mut ctx.child(child_rect));
147 ctx.record(rosace_render::DrawCommand::PopClip);
148 }
149}
150
151fn draw_default_background(ctx: &mut PaintCtx, r: Rect, dx: f32) {
154 let red = Color::rgb(220, 62, 54);
155 ctx.fill_rect(r, red);
156 const ICON: f32 = 22.0;
157 let cy = r.origin.y + (r.size.height - ICON) / 2.0;
158 let cx = if dx < 0.0 {
159 r.origin.x + r.size.width - ICON - 18.0 } else {
161 r.origin.x + 18.0 };
163 let icon = super::Icon::new(super::IconKind::Trash).size(ICON).color(Color::rgb(255, 255, 255));
164 icon.paint(&mut ctx.child(Rect { origin: Point { x: cx, y: cy }, size: Size { width: ICON, height: ICON } }));
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use rosace_layout::Constraints;
171
172 struct Row;
173 impl Widget for Row {
174 fn layout(&self, _ctx: &LayoutCtx) -> Size {
175 Size { width: 300.0, height: 56.0 }
176 }
177 fn paint(&self, _ctx: &mut PaintCtx) {}
178 }
179
180 fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
181 (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
182 }
183
184 #[test]
185 fn height_matches_child_width_fills_parent() {
186 let d = Dismissible::new(Row);
187 let (font, theme) = test_env();
188 let ctx = LayoutCtx::new(Constraints::loose(390.0, 800.0), &font, &theme);
189 let size = d.layout(&ctx);
190 assert_eq!((size.width, size.height), (390.0, 56.0));
191 }
192
193 #[test]
194 fn allows_respects_direction() {
195 let both = Dismissible::new(Row);
196 assert!(both.allows(-50.0) && both.allows(50.0));
197
198 let end_to_start = Dismissible::new(Row).direction(DismissDirection::EndToStart);
199 assert!(end_to_start.allows(-50.0) && !end_to_start.allows(50.0));
200
201 let start_to_end = Dismissible::new(Row).direction(DismissDirection::StartToEnd);
202 assert!(!start_to_end.allows(-50.0) && start_to_end.allows(50.0));
203 }
204
205 #[test]
206 fn threshold_clamps_to_sane_range() {
207 let d = Dismissible::new(Row).threshold(5.0);
208 assert!(d.threshold <= 0.95);
209 let d2 = Dismissible::new(Row).threshold(-1.0);
210 assert!(d2.threshold >= 0.05);
211 }
212}