Skip to main content

liora_components/
transfer.rs

1use crate::gpui_compat::element_id;
2use gpui::{
3    App, Context, Entity, IntoElement, MouseButton, Render, SharedString, Window, div, prelude::*,
4    px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use std::collections::HashSet;
10use std::sync::Arc;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct TransferItem {
14    pub key: SharedString,
15    pub label: SharedString,
16    pub description: Option<SharedString>,
17    pub disabled: bool,
18}
19
20pub struct Transfer {
21    id: SharedString,
22    items: Vec<TransferItem>,
23    target_keys: Vec<SharedString>,
24    checked_source_keys: Vec<SharedString>,
25    checked_target_keys: Vec<SharedString>,
26    source_title: SharedString,
27    target_title: SharedString,
28    source_filter: SharedString,
29    target_filter: SharedString,
30    filterable: bool,
31    empty_text: SharedString,
32    width: gpui::Pixels,
33    height: gpui::Pixels,
34    on_change: Option<Arc<dyn Fn(Vec<SharedString>, &mut Window, &mut App) + 'static>>,
35}
36
37impl TransferItem {
38    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
39        Self {
40            key: key.into(),
41            label: label.into(),
42            description: None,
43            disabled: false,
44        }
45    }
46
47    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
48        self.description = Some(description.into());
49        self
50    }
51
52    pub fn disabled(mut self, disabled: bool) -> Self {
53        self.disabled = disabled;
54        self
55    }
56}
57
58impl Transfer {
59    pub fn new(items: Vec<TransferItem>) -> Self {
60        Self {
61            id: liora_core::unique_id("transfer"),
62            items,
63            target_keys: vec![],
64            checked_source_keys: vec![],
65            checked_target_keys: vec![],
66            source_title: "Source".into(),
67            target_title: "Target".into(),
68            source_filter: SharedString::default(),
69            target_filter: SharedString::default(),
70            filterable: false,
71            empty_text: "暂无数据".into(),
72            width: px(620.0),
73            height: px(300.0),
74            on_change: None,
75        }
76    }
77
78    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
79        self.id = id.into();
80        self
81    }
82
83    pub fn target_keys(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
84        self.target_keys = keys.into_iter().map(Into::into).collect();
85        self
86    }
87
88    pub fn checked_source_keys(
89        mut self,
90        keys: impl IntoIterator<Item = impl Into<SharedString>>,
91    ) -> Self {
92        self.checked_source_keys = keys.into_iter().map(Into::into).collect();
93        self
94    }
95
96    pub fn checked_target_keys(
97        mut self,
98        keys: impl IntoIterator<Item = impl Into<SharedString>>,
99    ) -> Self {
100        self.checked_target_keys = keys.into_iter().map(Into::into).collect();
101        self
102    }
103
104    pub fn titles(
105        mut self,
106        source: impl Into<SharedString>,
107        target: impl Into<SharedString>,
108    ) -> Self {
109        self.source_title = source.into();
110        self.target_title = target.into();
111        self
112    }
113
114    pub fn filterable(mut self, filterable: bool) -> Self {
115        self.filterable = filterable;
116        self
117    }
118
119    pub fn source_filter(mut self, query: impl Into<SharedString>) -> Self {
120        self.source_filter = query.into();
121        self
122    }
123
124    pub fn target_filter(mut self, query: impl Into<SharedString>) -> Self {
125        self.target_filter = query.into();
126        self
127    }
128
129    pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
130        self.empty_text = text.into();
131        self
132    }
133
134    pub fn width(mut self, width: impl Into<gpui::Pixels>) -> Self {
135        self.width = width.into();
136        self
137    }
138
139    pub fn width_lg(self) -> Self {
140        self.width(px(680.0))
141    }
142
143    pub fn height(mut self, height: impl Into<gpui::Pixels>) -> Self {
144        self.height = height.into();
145        self
146    }
147
148    pub fn on_change(
149        mut self,
150        f: impl Fn(Vec<SharedString>, &mut Window, &mut App) + 'static,
151    ) -> Self {
152        self.on_change = Some(Arc::new(f));
153        self
154    }
155
156    pub fn set_target_keys(&mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) {
157        self.target_keys = keys.into_iter().map(Into::into).collect();
158    }
159
160    pub fn filter_items(items: &[TransferItem], query: &str) -> Vec<SharedString> {
161        let query = query.trim().to_lowercase();
162        items
163            .iter()
164            .filter(|item| {
165                query.is_empty()
166                    || item.key.to_string().to_lowercase().contains(&query)
167                    || item.label.to_string().to_lowercase().contains(&query)
168                    || item
169                        .description
170                        .as_ref()
171                        .is_some_and(|desc| desc.to_string().to_lowercase().contains(&query))
172            })
173            .map(|item| item.key.clone())
174            .collect()
175    }
176
177    pub fn move_to_target(
178        items: &[TransferItem],
179        target_keys: &mut Vec<SharedString>,
180        checked_source_keys: &mut Vec<SharedString>,
181    ) -> Vec<SharedString> {
182        let disabled = disabled_keys(items);
183        let mut moved = Vec::new();
184        for key in checked_source_keys.iter() {
185            if disabled.contains(key) || target_keys.contains(key) {
186                continue;
187            }
188            target_keys.push(key.clone());
189            moved.push(key.clone());
190        }
191        checked_source_keys.clear();
192        moved
193    }
194
195    pub fn move_to_source(
196        items: &[TransferItem],
197        target_keys: &mut Vec<SharedString>,
198        checked_target_keys: &mut Vec<SharedString>,
199    ) -> Vec<SharedString> {
200        let disabled = disabled_keys(items);
201        let mut moved = Vec::new();
202        target_keys.retain(|key| {
203            let should_move = checked_target_keys.contains(key) && !disabled.contains(key);
204            if should_move {
205                moved.push(key.clone());
206            }
207            !should_move
208        });
209        checked_target_keys.clear();
210        moved
211    }
212
213    pub fn move_to_target_with_checked(
214        items: &[TransferItem],
215        target_keys: &mut Vec<SharedString>,
216        checked_source_keys: &mut Vec<SharedString>,
217        checked_target_keys: &mut Vec<SharedString>,
218    ) -> Vec<SharedString> {
219        let moved = Self::move_to_target(items, target_keys, checked_source_keys);
220        for key in &moved {
221            if !checked_target_keys.contains(key) {
222                checked_target_keys.push(key.clone());
223            }
224        }
225        moved
226    }
227
228    pub fn move_to_source_with_checked(
229        items: &[TransferItem],
230        target_keys: &mut Vec<SharedString>,
231        checked_target_keys: &mut Vec<SharedString>,
232        checked_source_keys: &mut Vec<SharedString>,
233    ) -> Vec<SharedString> {
234        let moved = Self::move_to_source(items, target_keys, checked_target_keys);
235        for key in &moved {
236            if !checked_source_keys.contains(key) {
237                checked_source_keys.push(key.clone());
238            }
239        }
240        moved
241    }
242
243    fn source_items(&self) -> Vec<TransferItem> {
244        self.items
245            .iter()
246            .filter(|item| !self.target_keys.contains(&item.key))
247            .cloned()
248            .collect()
249    }
250
251    fn target_items(&self) -> Vec<TransferItem> {
252        self.target_keys
253            .iter()
254            .filter_map(|key| self.items.iter().find(|item| &item.key == key).cloned())
255            .collect()
256    }
257
258    fn toggle_source_key(&mut self, key: SharedString) {
259        toggle_key(&mut self.checked_source_keys, key);
260    }
261
262    fn toggle_target_key(&mut self, key: SharedString) {
263        toggle_key(&mut self.checked_target_keys, key);
264    }
265
266    fn move_checked_to_target(&mut self, window: &mut Window, cx: &mut App) {
267        let moved = Self::move_to_target_with_checked(
268            &self.items,
269            &mut self.target_keys,
270            &mut self.checked_source_keys,
271            &mut self.checked_target_keys,
272        );
273        if !moved.is_empty() {
274            if let Some(on_change) = &self.on_change {
275                on_change(self.target_keys.clone(), window, cx);
276            }
277        }
278    }
279
280    fn move_checked_to_source(&mut self, window: &mut Window, cx: &mut App) {
281        let moved = Self::move_to_source_with_checked(
282            &self.items,
283            &mut self.target_keys,
284            &mut self.checked_target_keys,
285            &mut self.checked_source_keys,
286        );
287        if !moved.is_empty() {
288            if let Some(on_change) = &self.on_change {
289                on_change(self.target_keys.clone(), window, cx);
290            }
291        }
292    }
293}
294
295impl Render for Transfer {
296    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
297        let theme = cx.global::<Config>().theme.clone();
298        let source_items = self.source_items();
299        let target_items = self.target_items();
300        let can_move_right = has_enabled_checked(&source_items, &self.checked_source_keys);
301        let can_move_left = has_enabled_checked(&target_items, &self.checked_target_keys);
302        let entity = cx.entity().clone();
303        let id = self.id.clone();
304        let width = self.width;
305        let height = self.height;
306
307        div()
308            .flex()
309            .items_center()
310            .gap_4()
311            .w(width)
312            .child(render_panel(
313                format!("{}-source", id),
314                self.source_title.clone(),
315                source_items,
316                self.checked_source_keys.clone(),
317                self.source_filter.clone(),
318                self.filterable,
319                self.empty_text.clone(),
320                height,
321                true,
322                &theme,
323                entity.clone(),
324            ))
325            .child(
326                div()
327                    .flex()
328                    .flex_col()
329                    .gap_2()
330                    .child(action_button(
331                        format!("{}-to-target", id),
332                        IconName::ChevronRight,
333                        can_move_right,
334                        theme.clone(),
335                        entity.clone(),
336                        true,
337                    ))
338                    .child(action_button(
339                        format!("{}-to-source", id),
340                        IconName::ChevronLeft,
341                        can_move_left,
342                        theme.clone(),
343                        entity.clone(),
344                        false,
345                    )),
346            )
347            .child(render_panel(
348                format!("{}-target", id),
349                self.target_title.clone(),
350                target_items,
351                self.checked_target_keys.clone(),
352                self.target_filter.clone(),
353                self.filterable,
354                self.empty_text.clone(),
355                height,
356                false,
357                &theme,
358                entity,
359            ))
360    }
361}
362
363#[allow(clippy::too_many_arguments)]
364fn render_panel(
365    id: String,
366    title: SharedString,
367    items: Vec<TransferItem>,
368    checked_keys: Vec<SharedString>,
369    filter_query: SharedString,
370    filterable: bool,
371    empty_text: SharedString,
372    height: gpui::Pixels,
373    is_source: bool,
374    theme: &liora_theme::Theme,
375    transfer: Entity<Transfer>,
376) -> impl IntoElement {
377    let visible_keys = Transfer::filter_items(&items, filter_query.as_ref());
378    let visible_key_set = visible_keys.iter().cloned().collect::<HashSet<_>>();
379    let enabled_count = items.iter().filter(|item| !item.disabled).count();
380    let checked_count = checked_keys.len();
381
382    div()
383        .id(element_id(id.clone()))
384        .flex()
385        .flex_col()
386        .w(px(260.0))
387        .h(height)
388        .bg(theme.neutral.card)
389        .border_1()
390        .border_color(theme.neutral.border)
391        .rounded(px(theme.radius.md))
392        .overflow_hidden()
393        .child(
394            div()
395                .h(px(42.0))
396                .px_3()
397                .flex()
398                .items_center()
399                .justify_between()
400                .bg(theme.neutral.hover)
401                .border_b_1()
402                .border_color(theme.neutral.border)
403                .child(
404                    div()
405                        .font_weight(gpui::FontWeight::BOLD)
406                        .text_color(theme.neutral.text_1)
407                        .child(title),
408                )
409                .child(
410                    div()
411                        .text_xs()
412                        .text_color(theme.neutral.text_3)
413                        .child(format!("{checked_count}/{enabled_count}")),
414                ),
415        )
416        .when(filterable, |panel| {
417            panel.child(
418                div()
419                    .px_3()
420                    .py_2()
421                    .border_b_1()
422                    .border_color(theme.neutral.divider)
423                    .child(
424                        div()
425                            .h(px(30.0))
426                            .px_2()
427                            .flex()
428                            .items_center()
429                            .gap_2()
430                            .rounded(px(theme.radius.sm))
431                            .border_1()
432                            .border_color(theme.neutral.border)
433                            .text_sm()
434                            .text_color(if filter_query.is_empty() {
435                                theme.neutral.placeholder
436                            } else {
437                                theme.neutral.text_1
438                            })
439                            .child(
440                                Icon::new(IconName::Search)
441                                    .size(px(14.0))
442                                    .color(theme.neutral.icon),
443                            )
444                            .child(if filter_query.is_empty() {
445                                "Filter...".into()
446                            } else {
447                                filter_query.clone()
448                            }),
449                    ),
450            )
451        })
452        .child(
453            div()
454                .id(element_id(format!("{}-list", id)))
455                .flex_1()
456                .overflow_y_scroll()
457                .children(
458                    items
459                        .into_iter()
460                        .filter(move |item| visible_key_set.contains(&item.key))
461                        .map(move |item| {
462                            render_item(
463                                id.clone(),
464                                item,
465                                checked_keys.clone(),
466                                is_source,
467                                theme.clone(),
468                                transfer.clone(),
469                            )
470                        }),
471                )
472                .when(visible_keys.is_empty(), |list| {
473                    list.child(
474                        div()
475                            .h_full()
476                            .flex()
477                            .items_center()
478                            .justify_center()
479                            .text_sm()
480                            .text_color(theme.neutral.text_3)
481                            .child(empty_text),
482                    )
483                }),
484        )
485}
486
487fn render_item(
488    panel_id: String,
489    item: TransferItem,
490    checked_keys: Vec<SharedString>,
491    is_source: bool,
492    theme: liora_theme::Theme,
493    transfer: Entity<Transfer>,
494) -> impl IntoElement {
495    let checked = checked_keys.contains(&item.key);
496    let disabled = item.disabled;
497    let key = item.key.clone();
498    let item_id = format!("{}-item-{}", panel_id, key);
499
500    div()
501        .id(element_id(item_id))
502        .min_h(px(38.0))
503        .px_3()
504        .py_2()
505        .flex()
506        .items_center()
507        .gap_2()
508        .text_color(if disabled {
509            theme.neutral.text_disabled
510        } else {
511            theme.neutral.text_1
512        })
513        .when(!disabled, |s| {
514            s.cursor_pointer()
515                .hover(|s| s.bg(theme.neutral.hover).cursor_pointer())
516        })
517        .when(disabled, |s| s.cursor_not_allowed())
518        .child(check_box_visual(checked, disabled, &theme))
519        .child(
520            div()
521                .flex_1()
522                .flex()
523                .flex_col()
524                .gap_1()
525                .child(div().text_sm().child(item.label))
526                .when_some(item.description, |s, desc| {
527                    s.child(div().text_xs().text_color(theme.neutral.text_3).child(desc))
528                }),
529        )
530        .when(!disabled, |s| {
531            s.on_mouse_down(MouseButton::Left, move |_, _, cx| {
532                transfer.update(cx, |transfer, cx| {
533                    if is_source {
534                        transfer.toggle_source_key(key.clone());
535                    } else {
536                        transfer.toggle_target_key(key.clone());
537                    }
538                    cx.notify();
539                });
540            })
541        })
542}
543
544fn check_box_visual(checked: bool, disabled: bool, theme: &liora_theme::Theme) -> impl IntoElement {
545    div()
546        .w(px(16.0))
547        .h(px(16.0))
548        .rounded(px(2.0))
549        .border_1()
550        .border_color(if checked {
551            theme.primary.base
552        } else {
553            theme.neutral.border
554        })
555        .bg(if checked {
556            theme.primary.base
557        } else {
558            theme.neutral.card
559        })
560        .opacity(if disabled { 0.45 } else { 1.0 })
561        .flex()
562        .items_center()
563        .justify_center()
564        .when(checked, |s| {
565            s.child(
566                Icon::new(IconName::Check)
567                    .size(px(12.0))
568                    .color(theme.neutral.card),
569            )
570        })
571}
572
573fn action_button(
574    id: String,
575    icon: IconName,
576    enabled: bool,
577    theme: liora_theme::Theme,
578    transfer: Entity<Transfer>,
579    to_target: bool,
580) -> impl IntoElement {
581    div()
582        .id(element_id(id))
583        .w(px(34.0))
584        .h(px(30.0))
585        .flex()
586        .items_center()
587        .justify_center()
588        .rounded(px(theme.radius.md))
589        .bg(if enabled {
590            theme.primary.base
591        } else {
592            theme.neutral.hover
593        })
594        .text_color(if enabled {
595            theme.neutral.card
596        } else {
597            theme.neutral.text_3
598        })
599        .when(enabled, |s| {
600            s.cursor_pointer()
601                .hover(|s| s.bg(theme.primary.hover).cursor_pointer())
602                .on_mouse_down(MouseButton::Left, move |_, window, cx| {
603                    transfer.update(cx, |transfer, cx| {
604                        if to_target {
605                            transfer.move_checked_to_target(window, cx);
606                        } else {
607                            transfer.move_checked_to_source(window, cx);
608                        }
609                        cx.notify();
610                    });
611                })
612        })
613        .when(!enabled, |s| s.cursor_not_allowed())
614        .child(Icon::new(icon).size(px(16.0)).color(if enabled {
615            theme.neutral.card
616        } else {
617            theme.neutral.text_3
618        }))
619}
620
621fn toggle_key(keys: &mut Vec<SharedString>, key: SharedString) {
622    if keys.contains(&key) {
623        keys.retain(|existing| existing != &key);
624    } else {
625        keys.push(key);
626    }
627}
628
629fn disabled_keys(items: &[TransferItem]) -> HashSet<SharedString> {
630    items
631        .iter()
632        .filter(|item| item.disabled)
633        .map(|item| item.key.clone())
634        .collect()
635}
636
637fn has_enabled_checked(items: &[TransferItem], checked_keys: &[SharedString]) -> bool {
638    items
639        .iter()
640        .any(|item| !item.disabled && checked_keys.contains(&item.key))
641}
642
643#[cfg(test)]
644mod demo_width_tests {
645    use super::*;
646
647    #[test]
648    fn transfer_width_lg_sets_demo_width() {
649        assert_eq!(
650            Transfer::new(vec![TransferItem::new("a", "A")])
651                .width_lg()
652                .width,
653            px(680.0)
654        );
655    }
656}