Skip to main content

guise/input/
transfer.rs

1//! `Transfer` — a dual-list membership editor (gpui entity).
2//!
3//! One item pool, two panes: items are either left (available) or right
4//! (chosen). Click rows to check them, move checked items with the middle
5//! buttons. Emits [`TransferEvent`] with the right side's indices after
6//! every move.
7
8use std::collections::BTreeSet;
9
10use gpui::prelude::*;
11use gpui::{div, px, Context, EventEmitter, FocusHandle, IntoElement, SharedString, Window};
12
13use crate::devtools::Probed;
14use crate::icon::{Icon, IconName};
15use crate::theme::{theme, Size};
16
17/// Emitted after a move. Carries the right pane's item indices, ascending.
18#[derive(Debug, Clone)]
19pub struct TransferEvent(pub Vec<usize>);
20
21/// Move `checked ∩ side` to the other side; returns whether anything moved.
22fn move_checked(
23    right: &mut BTreeSet<usize>,
24    checked: &mut BTreeSet<usize>,
25    to_right: bool,
26) -> bool {
27    let movers: Vec<usize> = checked
28        .iter()
29        .copied()
30        .filter(|i| right.contains(i) != to_right)
31        .collect();
32    for i in &movers {
33        if to_right {
34            right.insert(*i);
35        } else {
36            right.remove(i);
37        }
38        checked.remove(i);
39    }
40    !movers.is_empty()
41}
42
43/// A dual-list picker. Create with `cx.new(|cx| Transfer::new(cx).data([..]))`.
44pub struct Transfer {
45    items: Vec<SharedString>,
46    right: BTreeSet<usize>,
47    checked: BTreeSet<usize>,
48    titles: (SharedString, SharedString),
49    height: f32,
50    focus: FocusHandle,
51    disabled: bool,
52}
53
54impl EventEmitter<TransferEvent> for Transfer {}
55
56impl Transfer {
57    pub fn new(cx: &mut Context<Self>) -> Self {
58        Transfer {
59            items: Vec::new(),
60            right: BTreeSet::new(),
61            checked: BTreeSet::new(),
62            titles: (
63                SharedString::new_static("Available"),
64                SharedString::new_static("Chosen"),
65            ),
66            height: 200.0,
67            focus: cx.focus_handle(),
68            disabled: false,
69        }
70    }
71
72    pub fn data<I, S>(mut self, items: I) -> Self
73    where
74        I: IntoIterator<Item = S>,
75        S: Into<SharedString>,
76    {
77        self.items = items.into_iter().map(Into::into).collect();
78        self
79    }
80
81    /// Start with these item indices on the right side.
82    pub fn chosen(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
83        self.right = indices.into_iter().collect();
84        self
85    }
86
87    pub fn titles(mut self, left: impl Into<SharedString>, right: impl Into<SharedString>) -> Self {
88        self.titles = (left.into(), right.into());
89        self
90    }
91
92    pub fn height(mut self, height: f32) -> Self {
93        self.height = height.max(80.0);
94        self
95    }
96
97    pub fn disabled(mut self, disabled: bool) -> Self {
98        self.disabled = disabled;
99        self
100    }
101
102    /// The right pane's item indices, ascending.
103    pub fn chosen_indices(&self) -> Vec<usize> {
104        self.right.iter().copied().collect()
105    }
106
107    fn toggle_checked(&mut self, index: usize, cx: &mut Context<Self>) {
108        if !self.checked.remove(&index) {
109            self.checked.insert(index);
110        }
111        cx.notify();
112    }
113
114    fn transfer(&mut self, to_right: bool, cx: &mut Context<Self>) {
115        if move_checked(&mut self.right, &mut self.checked, to_right) {
116            cx.emit(TransferEvent(self.chosen_indices()));
117            cx.notify();
118        }
119    }
120
121    fn pane(
122        &self,
123        title: &SharedString,
124        indices: Vec<usize>,
125        list_id: &'static str,
126        cx: &mut Context<Self>,
127    ) -> impl IntoElement {
128        let t = theme(cx);
129        let radius = t.radius(t.default_radius);
130        let surface = t.surface().hsla();
131        let surface_hover = t.surface_hover().hsla();
132        let border = t.border().hsla();
133        let text_color = t.text().hsla();
134        let dimmed = t.dimmed().hsla();
135        let checked_bg = t.primary().alpha(0.12);
136        let font = t.font_size(Size::Sm);
137
138        let mut rows = div()
139            .id(list_id)
140            .flex()
141            .flex_col()
142            .gap(px(2.0))
143            .p(px(4.0))
144            .flex_1()
145            .overflow_y_scroll();
146        for i in indices {
147            let is_checked = self.checked.contains(&i);
148            let mut row = div()
149                .id((list_id, i))
150                .px(px(8.0))
151                .py(px(5.0))
152                .rounded(px(4.0))
153                .text_size(px(font))
154                .text_color(text_color)
155                .child(self.items[i].clone())
156                .on_click(cx.listener(move |this, _ev, _window, cx| {
157                    if !this.disabled {
158                        this.toggle_checked(i, cx);
159                    }
160                }));
161            if is_checked {
162                row = row.bg(checked_bg);
163            } else {
164                row = row.hover(move |s| s.bg(surface_hover));
165            }
166            rows = rows.child(row);
167        }
168
169        div()
170            .flex()
171            .flex_col()
172            .flex_1()
173            .h(px(self.height))
174            .rounded(px(radius))
175            .border_1()
176            .border_color(border)
177            .bg(surface)
178            .child(
179                div()
180                    .px(px(8.0))
181                    .py(px(5.0))
182                    .border_b_1()
183                    .border_color(border)
184                    .text_size(px(font - 1.0))
185                    .text_color(dimmed)
186                    .child(title.clone()),
187            )
188            .child(rows)
189    }
190}
191
192impl Render for Transfer {
193    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
194        let t = theme(cx);
195        let surface = t.surface().hsla();
196        let surface_hover = t.surface_hover().hsla();
197        let border = t.border().hsla();
198        let dimmed = t.dimmed().hsla();
199
200        let left: Vec<usize> = (0..self.items.len())
201            .filter(|i| !self.right.contains(i))
202            .collect();
203        let right: Vec<usize> = self.chosen_indices();
204
205        let (left_title, right_title) = self.titles.clone();
206        let left_pane = self.pane(&left_title, left, "guise-transfer-left", cx);
207        let right_pane = self.pane(&right_title, right, "guise-transfer-right", cx);
208
209        let mut buttons = div().flex().flex_col().justify_center().gap(px(6.0));
210        for (key, icon, to_right) in [
211            ("guise-transfer-toright", IconName::ChevronRight, true),
212            ("guise-transfer-toleft", IconName::ChevronLeft, false),
213        ] {
214            buttons = buttons.child(
215                div()
216                    .id(key)
217                    .flex()
218                    .items_center()
219                    .justify_center()
220                    .w(px(26.0))
221                    .h(px(26.0))
222                    .rounded(px(6.0))
223                    .bg(surface)
224                    .border_1()
225                    .border_color(border)
226                    .text_color(dimmed)
227                    .hover(move |s| s.bg(surface_hover))
228                    .child(Icon::new(icon).size(Size::Xs))
229                    .on_click(cx.listener(move |this, _ev, _window, cx| {
230                        if !this.disabled {
231                            this.transfer(to_right, cx);
232                        }
233                    })),
234            );
235        }
236
237        let row = div()
238            .track_focus(&self.focus)
239            .flex()
240            .items_center()
241            .gap(px(10.0))
242            .w_full()
243            .child(left_pane)
244            .child(buttons)
245            .child(right_pane);
246
247        let element = if self.disabled { row.opacity(0.6) } else { row };
248
249        element.probe("Transfer")
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn moves_only_checked_items_on_the_source_side() {
259        let mut right: BTreeSet<usize> = [3].into_iter().collect();
260        let mut checked: BTreeSet<usize> = [0, 1, 3].into_iter().collect();
261        // Moving right: 0 and 1 cross; 3 is already right so it stays checked-cleared? No —
262        // it's skipped (not a mover) and stays checked.
263        assert!(move_checked(&mut right, &mut checked, true));
264        assert_eq!(right.iter().copied().collect::<Vec<_>>(), vec![0, 1, 3]);
265        assert!(checked.contains(&3) && !checked.contains(&0));
266
267        // Move 3 back left.
268        assert!(move_checked(&mut right, &mut checked, false));
269        assert_eq!(right.iter().copied().collect::<Vec<_>>(), vec![0, 1]);
270        assert!(checked.is_empty());
271    }
272
273    #[test]
274    fn no_movers_reports_false() {
275        let mut right = BTreeSet::new();
276        let mut checked = BTreeSet::new();
277        assert!(!move_checked(&mut right, &mut checked, true));
278        checked.insert(2);
279        // 2 is on the left; moving left is a no-op.
280        assert!(!move_checked(&mut right, &mut checked, false));
281        assert!(checked.contains(&2));
282    }
283}