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}