1use std::rc::Rc;
2
3use crate::motion::{Motion, StyledSlot};
4use crate::theme::ActiveTheme;
5use gpui::{
6 div, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight, IntoElement, KeyDownEvent,
7 RenderOnce, SharedString, StyleRefinement, Styled, Window,
8};
9
10use crate::compat::{AccessibilityExt, Role, Toggled};
11
12use crate::button::ButtonVariant;
13use crate::chrome::{box_shadow, button_chrome, focus_ring};
14use crate::icon::{Icon, IconName};
15
16type CheckboxClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
17type CheckboxChangeHandler = Rc<dyn Fn(CheckState, &mut Window, &mut App) + 'static>;
18
19struct CheckboxState {
20 focus_handle: FocusHandle,
21 state: CheckState,
22 last_prop: CheckState,
23}
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum CheckState {
28 #[default]
29 Off,
30 On,
31 Mixed,
32}
33
34impl CheckState {
35 fn next(self) -> Self {
36 match self {
37 Self::Off => Self::On,
38 Self::On => Self::Off,
39 Self::Mixed => Self::On,
40 }
41 }
42
43 fn debug_name(self) -> &'static str {
44 match self {
45 Self::Off => "off",
46 Self::On => "on",
47 Self::Mixed => "mixed",
48 }
49 }
50
51 fn toggled(self) -> Toggled {
52 match self {
53 Self::Off => Toggled::False,
54 Self::On => Toggled::True,
55 Self::Mixed => Toggled::Mixed,
56 }
57 }
58}
59
60#[derive(IntoElement)]
66pub struct Checkbox {
67 id: SharedString,
68 state: CheckState,
69 disabled: bool,
70 label: Option<SharedString>,
71 style: StyleRefinement,
72 on_click: Option<CheckboxClickHandler>,
73 on_change: Option<CheckboxChangeHandler>,
74}
75
76impl Checkbox {
77 pub fn new(id: impl Into<SharedString>) -> Self {
78 Self {
79 id: id.into(),
80 state: CheckState::Off,
81 disabled: false,
82 label: None,
83 style: StyleRefinement::default(),
84 on_click: None,
85 on_change: None,
86 }
87 }
88
89 pub fn checked(mut self, checked: bool) -> Self {
90 self.state = if checked {
91 CheckState::On
92 } else {
93 CheckState::Off
94 };
95 self
96 }
97
98 pub fn state(mut self, state: CheckState) -> Self {
99 self.state = state;
100 self
101 }
102
103 pub fn disabled(mut self, disabled: bool) -> Self {
104 self.disabled = disabled;
105 self
106 }
107
108 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
109 self.label = Some(label.into());
110 self
111 }
112
113 pub fn on_click(
114 mut self,
115 listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
116 ) -> Self {
117 self.on_click = Some(Rc::new(listener));
118 self
119 }
120
121 pub fn on_change(
123 mut self,
124 listener: impl Fn(CheckState, &mut Window, &mut App) + 'static,
125 ) -> Self {
126 self.on_change = Some(Rc::new(listener));
127 self
128 }
129}
130
131impl Styled for Checkbox {
132 fn style(&mut self) -> &mut StyleRefinement {
133 &mut self.style
134 }
135}
136
137impl RenderOnce for Checkbox {
138 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
139 let motion_id = self.id.clone();
140 let initial = self.state;
141 let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| CheckboxState {
142 focus_handle: cx.focus_handle(),
143 state: initial,
144 last_prop: initial,
145 });
146 let controlled = self.on_change.is_some() || self.on_click.is_some();
147 if controlled {
148 if state.read(cx).state != self.state {
149 state.update(cx, |checkbox, _| checkbox.state = self.state);
150 }
151 } else if state.read(cx).last_prop != self.state {
152 state.update(cx, |checkbox, _| {
153 checkbox.state = self.state;
154 checkbox.last_prop = self.state;
155 });
156 }
157
158 let check = if controlled {
159 self.state
160 } else {
161 state.read(cx).state
162 };
163 let theme = cx.theme();
164 let filled = matches!(check, CheckState::On | CheckState::Mixed);
165 let variant = if self.disabled {
166 ButtonVariant::Ghost
167 } else if filled {
168 ButtonVariant::Primary
169 } else {
170 ButtonVariant::Outline
171 };
172 let chrome = button_chrome(theme, variant);
173 let mark = if self.disabled && filled {
174 theme.muted_fg()
175 } else {
176 theme.on_solid
177 };
178
179 let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
180 if chrome.shadow_blur > 0.0 {
181 shadows.push(box_shadow(
182 0.,
183 chrome.shadow_y,
184 chrome.shadow,
185 chrome.shadow_blur,
186 0.,
187 ));
188 }
189
190 let interactive = !self.disabled;
191 let focus_handle = state.read(cx).focus_handle.clone().tab_stop(interactive);
192 let focused = focus_handle.is_focused(window);
193 if focused {
194 shadows.push(focus_ring(theme));
195 }
196
197 let box_el = div()
198 .flex()
199 .items_center()
200 .justify_center()
201 .size(px(16.))
202 .flex_shrink_0()
203 .rounded(px(6.))
204 .border_1()
205 .border_color(chrome.border)
206 .bg(chrome.bg)
207 .shadow(shadows)
208 .when(check == CheckState::On, |el| {
209 el.child(
210 Motion::new()
211 .id(format!("{motion_id}-check-on"))
212 .selection_in()
213 .child(Icon::new(IconName::Check).px(px(10.)).color(mark)),
214 )
215 })
216 .when(check == CheckState::Mixed, |el| {
217 el.child(
218 Motion::new()
219 .id(format!("{motion_id}-check-mixed"))
220 .selection_in()
221 .child(
222 div()
223 .w(px(8.))
224 .h(px(1.5))
225 .flex_shrink_0()
226 .rounded(px(1.))
227 .bg(mark),
228 ),
229 )
230 });
231
232 let label_color = if self.disabled {
233 theme.muted_fg()
234 } else {
235 theme.ink
236 };
237 let aria_label = self.label.clone();
238 let debug_selector = format!("{}-{}", self.id, check.debug_name());
239
240 let el = div()
241 .id(self.id)
242 .debug_selector(move || debug_selector.clone())
243 .role(Role::CheckBox)
244 .aria_toggled(check.toggled())
245 .when_some(aria_label, |el, label| el.aria_label(label))
246 .track_focus(&focus_handle)
247 .tab_stop(interactive)
248 .flex()
249 .items_center()
250 .gap(px(8.))
251 .refine_style(&self.style)
252 .when(interactive, |el| el.cursor_pointer())
253 .when(!interactive, |el| el.cursor_default())
254 .child(box_el)
255 .when_some(self.label, |el, label| {
256 el.child(
257 div()
258 .font_family(theme.font_family)
259 .font_weight(FontWeight::NORMAL)
260 .text_size(px(14.))
261 .line_height(px(20.))
262 .text_color(label_color)
263 .child(label),
264 )
265 });
266
267 if interactive {
268 let on_click = self.on_click;
269 let on_change = self.on_change;
270 let activate = Rc::new(
271 move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
272 let next = state.read(cx).state.next();
273 if !controlled {
274 state.update(cx, |checkbox, cx| {
275 checkbox.state = next;
276 cx.notify();
277 });
278 }
279 if let Some(on_change) = &on_change {
280 on_change(next, window, cx);
281 }
282 if let Some(on_click) = &on_click {
283 on_click(event, window, cx);
284 }
285 },
286 );
287 let keyboard = activate.clone();
288 let click_focus = focus_handle.clone();
289 el.on_key_down(move |event: &KeyDownEvent, window, cx| {
290 if event.keystroke.modifiers.modified() {
291 return;
292 }
293 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
294 keyboard(&ClickEvent::default(), window, cx);
295 cx.stop_propagation();
296 }
297 })
298 .on_click(move |event, window, cx| {
299 click_focus.focus(window);
300 activate(event, window, cx);
301 })
302 } else {
303 el
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn checked_false_clears_the_mark() {
314 assert_eq!(Checkbox::new("x").checked(true).state, CheckState::On);
315 assert_eq!(
316 Checkbox::new("x").checked(true).checked(false).state,
317 CheckState::Off
318 );
319 }
320
321 #[test]
322 fn mixed_activates_to_on() {
323 assert_eq!(CheckState::Mixed.next(), CheckState::On);
324 assert_eq!(CheckState::On.next(), CheckState::Off);
325 assert_eq!(CheckState::Off.next(), CheckState::On);
326 }
327}