liora_components/
collapse.rs1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{AnyElement, Context, IntoElement, Render, SharedString, Window, div, prelude::*, px};
4use liora_core::{Config, unique_id};
5use liora_icons::Icon;
6use liora_icons_lucide::IconName;
7use std::collections::HashSet;
8use std::sync::Arc;
9
10pub struct CollapseItem {
11 pub name: SharedString,
12 pub title: SharedString,
13 pub content: Arc<dyn Fn(&mut Window, &mut Context<Collapse>) -> AnyElement + 'static>,
14}
15
16pub struct Collapse {
17 items: Vec<CollapseItem>,
18 active_names: HashSet<SharedString>,
19 accordion: bool,
20 id: SharedString,
21}
22
23impl Collapse {
24 pub fn new() -> Self {
25 Self {
26 items: vec![],
27 active_names: HashSet::new(),
28 accordion: false,
29 id: unique_id("collapse"),
30 }
31 }
32
33 pub fn accordion(mut self) -> Self {
34 self.accordion = true;
35 self
36 }
37
38 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
39 self.id = id.into();
40 self
41 }
42
43 pub fn item<F, E>(
44 mut self,
45 name: impl Into<SharedString>,
46 title: impl Into<SharedString>,
47 f: F,
48 ) -> Self
49 where
50 F: Fn(&mut Window, &mut Context<Self>) -> E + 'static,
51 E: IntoElement,
52 {
53 self.items.push(CollapseItem {
54 name: name.into(),
55 title: title.into(),
56 content: Arc::new(move |window, cx| f(window, cx).into_any_element()),
57 });
58 self
59 }
60
61 fn toggle(&mut self, name: SharedString, cx: &mut Context<Self>) {
62 if self.active_names.contains(&name) {
63 self.active_names.remove(&name);
64 } else {
65 if self.accordion {
66 self.active_names.clear();
67 }
68 self.active_names.insert(name);
69 }
70 cx.notify();
71 }
72}
73
74impl Render for Collapse {
75 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
76 let theme = cx.global::<Config>().theme.clone();
77
78 div()
79 .flex()
80 .flex_col()
81 .border_1()
82 .border_color(theme.neutral.border)
83 .rounded(px(theme.radius.md))
84 .children(self.items.iter().enumerate().map(|(i, item)| {
85 let name = item.name.clone();
86 let is_active = self.active_names.contains(&name);
87 let is_last = i == self.items.len() - 1;
88 let header_id = format!("{}-header-{}", self.id, name);
89 let content_motion_id = format!("{}-content-motion-{}", self.id, name);
90
91 div()
92 .flex()
93 .flex_col()
94 .child(
95 div()
96 .id(element_id(header_id))
97 .cursor_pointer()
98 .px_4()
99 .py_3()
100 .flex()
101 .flex_row()
102 .items_center()
103 .justify_between()
104 .bg(if is_active {
105 theme.neutral.hover
106 } else {
107 theme.neutral.card
108 })
109 .hover(|s| s.bg(theme.neutral.hover))
110 .when(!is_last, |s| {
111 s.border_b_1().border_color(theme.neutral.border)
112 })
113 .on_click(cx.listener(move |this, _, _, cx| {
114 this.toggle(name.clone(), cx);
115 }))
116 .child(
117 div()
118 .font_weight(gpui::FontWeight::BOLD)
119 .child(item.title.clone()),
120 )
121 .child(
122 Icon::new(if is_active {
123 IconName::ChevronDown
124 } else {
125 IconName::ChevronRight
126 })
127 .size(px(16.0))
128 .color(theme.neutral.icon),
129 ),
130 )
131 .when(is_active, |s| {
132 s.child(pop_in(
133 element_id(content_motion_id),
134 div()
135 .p_4()
136 .bg(theme.neutral.card)
137 .when(!is_last, |s| {
138 s.border_b_1().border_color(theme.neutral.border)
139 })
140 .child((item.content)(_window, cx)),
141 ))
142 })
143 }))
144 }
145}