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}