Skip to main content

ravenrs_widgets/lists/
sortable.rs

1//! A list whose rows the user puts in order.
2
3use std::cell::RefCell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Color, CursorIcon, Insets, Key, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::State;
10use ravenrs_style::Semantic;
11
12use super::reorder::{self, Extents, Reorder};
13use crate::IconName;
14use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
15use crate::util::{self, FocusTarget, Roving};
16use crate::view::{AnyView, View};
17
18/// A list that reorders by dragging a row, with a line showing where it will land, or with Alt
19/// and the arrows, Home and End. The arrows alone move between rows, and each move is announced to
20/// screen readers. The order belongs to the app, which applies what
21/// [`on_reorder`](Self::on_reorder) tells it.
22///
23/// ```
24/// use ravenrs_reactive::State;
25/// use ravenrs_widgets::{SortableList, Text, Ui};
26///
27/// let items = State::new(vec!["Warm", "Cool", "Matte"]);
28/// let shown = items.get();
29/// let moved = items.clone();
30/// let list = SortableList::new(shown.len(), move |i| Text::new(shown[i]))
31///     .on_reorder(move |from, to| moved.update(|v| {
32///         let item = v.remove(from);
33///         v.insert(to, item);
34///     }));
35/// let mut ui = Ui::new(list, ravenrs_style::Theme::raven_dark());
36/// ui.update();
37/// ```
38#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
39pub struct SortableList {
40    count: usize,
41    item: Rc<dyn Fn(usize) -> AnyView>,
42    key: Option<Rc<dyn Fn(usize) -> u64>>,
43    row_name: Option<Rc<dyn Fn(usize) -> String>>,
44    on_reorder: Option<Rc<dyn Fn(usize, usize)>>,
45    spacing: f32,
46    label: Option<String>,
47    grip: bool,
48}
49
50impl fmt::Debug for SortableList {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.debug_struct("SortableList").field("count", &self.count).finish_non_exhaustive()
53    }
54}
55
56#[cfg_attr(feature = "live", ravenrs_live::expose)]
57impl SortableList {
58    /// A list of `count` rows, in the order the app keeps.
59    pub fn new<V: View + 'static>(count: usize, item: impl Fn(usize) -> V + 'static) -> Self {
60        Self {
61            count,
62            item: Rc::new(move |index| Box::new(item(index))),
63            key: None,
64            row_name: None,
65            on_reorder: None,
66            spacing: 2.0,
67            label: None,
68            grip: true,
69        }
70    }
71
72    /// What identifies a row, which is its position by default. With it, what a row keeps of
73    /// its own goes with it when it moves.
74    pub fn key(mut self, key: impl Fn(usize) -> u64 + 'static) -> Self {
75        self.key = Some(Rc::new(key));
76        self
77    }
78
79    /// Called with where a row was and where it should go.
80    pub fn on_reorder(mut self, handler: impl Fn(usize, usize) + 'static) -> Self {
81        self.on_reorder = Some(Rc::new(handler));
82        self
83    }
84
85    /// What screen readers call each row, for rows whose content does not say it.
86    pub fn row_name(mut self, name: impl Fn(usize) -> String + 'static) -> Self {
87        self.row_name = Some(Rc::new(name));
88        self
89    }
90
91    /// Gap between rows (2 by default).
92    pub fn spacing(mut self, spacing: f32) -> Self {
93        self.spacing = spacing.max(0.0);
94        self
95    }
96
97    /// What screen readers call the list, instead of the name in [`Labels`](crate::Labels).
98    pub fn label(mut self, label: impl Into<String>) -> Self {
99        self.label = Some(label.into());
100        self
101    }
102
103    /// Whether a row shows the mark that says it can be dragged (on by default).
104    pub fn grip(mut self, grip: bool) -> Self {
105        self.grip = grip;
106        self
107    }
108}
109
110struct SortableRow<'a> {
111    list: &'a SortableList,
112    index: usize,
113    extents: Extents,
114    drag: State<Option<Reorder>>,
115    roving: Roving,
116    apply: Rc<dyn Fn(usize, usize)>,
117}
118
119impl View for SortableRow<'_> {
120    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
121        let theme = *cx.theme();
122        let labels = cx.labels();
123        let list = self.list;
124        let index = self.index;
125        let frame = cx.scroll_viewport();
126        if let (Some(size), Some(slot)) = (frame, self.extents.borrow_mut().get_mut(index)) {
127            *slot = size.height;
128        }
129        let state = self.drag.get();
130        let dragged = state.is_some_and(|d| d.moving && d.from == index);
131        let mut parts = Vec::with_capacity(2);
132        if list.grip {
133            parts.push(util::icon(cx, IconName::EllipsisVertical, 14.0, theme.color(Semantic::InkMuted)));
134        }
135        let item = (list.item)(index);
136        let content = cx.child(&*item);
137        let content = cx.container(
138            LayoutStyle {
139                grow: 1.0,
140                min_width: Some(0.0),
141                align: Align::Stretch,
142                ..LayoutStyle::stack(Axis::Vertical)
143            },
144            &[content],
145            Paint::None,
146        );
147        parts.push(content);
148        if let (Some(gap), Some(size)) = (state.and_then(|d| d.marked_gap()), frame) {
149            let half = list.spacing / 2.0;
150            if gap == index {
151                parts.push(reorder::marker(cx, true, size.width, -half));
152            } else if gap == index + 1 && gap == list.count {
153                parts.push(reorder::marker(cx, true, size.width, size.height + half));
154            }
155        }
156        let ink = theme.color(Semantic::Ink);
157        let fill = if dragged {
158            theme.color(Semantic::Accent).with_alpha(0.14)
159        } else if cx.is_hovered() {
160            ink.with_alpha(0.05)
161        } else {
162            Color::TRANSPARENT
163        };
164        let node = cx.container(
165            LayoutStyle {
166                spacing: theme.spacing.s2,
167                padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s2),
168                align: Align::Center,
169                ..LayoutStyle::stack(Axis::Horizontal)
170            },
171            &parts,
172            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
173        );
174        cx.report_frame(node);
175        cx.cursor(node, CursorIcon::Pointer);
176        let mut semantics =
177            Semantics::new(Role::ListItem).description(labels.sortable_hint.clone()).active(dragged);
178        if let Some(name) = &list.row_name {
179            semantics = semantics.name(name(index));
180        }
181        cx.semantics(node, semantics);
182        cx.focusable(node, theme.radii.sm);
183        cx.on_drag(
184            node,
185            reorder::drag_handler(
186                self.drag.clone(),
187                self.extents.clone(),
188                index,
189                list.spacing,
190                true,
191                self.apply.clone(),
192            ),
193        );
194        let (apply, count, called_off) = (self.apply.clone(), list.count, self.drag.clone());
195        let before: crate::KeyHandler = Rc::new(move |key, modifiers| {
196            if *key == Key::Escape && called_off.peek().is_some() {
197                called_off.set(None);
198                return true;
199            }
200            if let Some(to) = reorder::key_target(key, modifiers, index, count, true) {
201                apply(index, to);
202            }
203            reorder::is_move_key(key, modifiers)
204        });
205        self.roving.wire_with(cx, node, None, Some(before));
206        node
207    }
208}
209
210impl View for SortableList {
211    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
212        let labels = cx.labels();
213        let drag: State<Option<Reorder>> = cx.use_state(|| None);
214        let extents: Extents = cx.use_state(|| Rc::new(RefCell::new(Vec::new()))).peek().clone();
215        extents.borrow_mut().resize(self.count, 0.0);
216        let spoken: State<String> = cx.use_state(String::new);
217        let focus_to: FocusTarget = util::roving_cell(cx, None);
218        let count = self.count;
219        let apply: Rc<dyn Fn(usize, usize)> = {
220            let (handler, spoken, focus_to) = (self.on_reorder.clone(), spoken.clone(), focus_to.clone());
221            let words = labels.clone();
222            Rc::new(move |from, to| {
223                if let Some(handler) = &handler {
224                    handler(from, to);
225                    spoken.set(words.moved_to_position(to + 1, count));
226                    focus_to.request(to);
227                }
228            })
229        };
230        let mut nodes = Vec::with_capacity(count + 1);
231        for index in 0..count {
232            let row = SortableRow {
233                list: self,
234                index,
235                extents: extents.clone(),
236                drag: drag.clone(),
237                roving: Roving { index, count, focus_to: focus_to.clone() },
238                apply: apply.clone(),
239            };
240            let key = self.key.as_ref().map_or(index as u64, |key| key(index));
241            nodes.push(cx.keyed_child(key, &row));
242        }
243        let announcement = spoken.get();
244        nodes.push(reorder::announcer(cx, announcement));
245        let node = cx.container(
246            LayoutStyle {
247                spacing: self.spacing,
248                align: Align::Stretch,
249                align_self: Some(Align::Stretch),
250                ..LayoutStyle::stack(Axis::Vertical)
251            },
252            &nodes,
253            Paint::None,
254        );
255        let name = self.label.clone().unwrap_or_else(|| labels.sortable_name.clone());
256        cx.semantics(node, Semantics::new(Role::List).name(name).description(count.to_string()));
257        node
258    }
259}