1use crate::{Checkbox, CheckboxChanged};
2use gpui::{
3 AnyElement, App, Context, Entity, FocusHandle, Focusable, Hsla, MouseButton, MouseUpEvent,
4 Pixels, Render, Rgba, SharedString, Window, prelude::*, px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9
10fn rgba(r: u8, g: u8, b: u8, a: f32) -> Hsla {
11 Rgba {
12 r: r as f32 / 255.0,
13 g: g as f32 / 255.0,
14 b: b as f32 / 255.0,
15 a,
16 }
17 .into()
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum CheckboxGroupLayout {
22 #[default]
23 Vertical,
24 Horizontal,
25 Button,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum CheckboxGroupSize {
30 Large,
31 #[default]
32 Default,
33 Small,
34}
35
36impl CheckboxGroupSize {
37 fn height(self) -> Pixels {
38 match self {
39 CheckboxGroupSize::Large => px(38.0),
40 CheckboxGroupSize::Default => px(32.0),
41 CheckboxGroupSize::Small => px(24.0),
42 }
43 }
44
45 fn padding_x(self) -> Pixels {
46 match self {
47 CheckboxGroupSize::Large => px(18.0),
48 CheckboxGroupSize::Default => px(14.0),
49 CheckboxGroupSize::Small => px(10.0),
50 }
51 }
52
53 fn text_size(self, theme: &liora_theme::Theme) -> Pixels {
54 match self {
55 CheckboxGroupSize::Large => px(theme.font_size.md),
56 CheckboxGroupSize::Default => px(theme.font_size.md),
57 CheckboxGroupSize::Small => px(theme.font_size.sm),
58 }
59 }
60}
61
62#[derive(Clone, Debug, Default)]
63pub struct CheckboxOptionStyle {
64 pub bg: Option<Hsla>,
65 pub selected_bg: Option<Hsla>,
66 pub hover_bg: Option<Hsla>,
67 pub text_color: Option<Hsla>,
68 pub selected_text_color: Option<Hsla>,
69 pub border_color: Option<Hsla>,
70 pub selected_border_color: Option<Hsla>,
71 pub radius: Option<Pixels>,
72 pub padding_x: Option<Pixels>,
73 pub padding_y: Option<Pixels>,
74 pub gap: Option<Pixels>,
75 pub show_indicator: Option<bool>,
76 pub show_selected_icon: Option<bool>,
77}
78
79impl CheckboxOptionStyle {
80 pub fn new() -> Self {
81 Self::default()
82 }
83
84 pub fn bg(mut self, color: Hsla) -> Self {
85 self.bg = Some(color);
86 self
87 }
88
89 pub fn selected_bg(mut self, color: Hsla) -> Self {
90 self.selected_bg = Some(color);
91 self
92 }
93
94 pub fn hover_bg(mut self, color: Hsla) -> Self {
95 self.hover_bg = Some(color);
96 self
97 }
98
99 pub fn text_color(mut self, color: Hsla) -> Self {
100 self.text_color = Some(color);
101 self
102 }
103
104 pub fn selected_text_color(mut self, color: Hsla) -> Self {
105 self.selected_text_color = Some(color);
106 self
107 }
108
109 pub fn border_color(mut self, color: Hsla) -> Self {
110 self.border_color = Some(color);
111 self
112 }
113
114 pub fn selected_border_color(mut self, color: Hsla) -> Self {
115 self.selected_border_color = Some(color);
116 self
117 }
118
119 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
120 self.radius = Some(radius.into());
121 self
122 }
123
124 pub fn radius_px(self, radius: f32) -> Self {
125 self.radius(px(radius))
126 }
127
128 pub fn radius_units(self, radius: f32) -> Self {
129 self.radius_px(radius)
130 }
131
132 pub fn padding(mut self, x: impl Into<Pixels>, y: impl Into<Pixels>) -> Self {
133 self.padding_x = Some(x.into());
134 self.padding_y = Some(y.into());
135 self
136 }
137
138 pub fn padding_px(self, x: f32, y: f32) -> Self {
139 self.padding(px(x), px(y))
140 }
141
142 pub fn padding_units(self, x: f32, y: f32) -> Self {
143 self.padding_px(x, y)
144 }
145
146 pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
147 self.gap = Some(gap.into());
148 self
149 }
150
151 pub fn gap_px(self, gap: f32) -> Self {
152 self.gap(px(gap))
153 }
154
155 pub fn gap_units(self, gap: f32) -> Self {
156 self.gap_px(gap)
157 }
158
159 pub fn show_indicator(mut self, show: bool) -> Self {
160 self.show_indicator = Some(show);
161 self
162 }
163
164 pub fn show_selected_icon(mut self, show: bool) -> Self {
165 self.show_selected_icon = Some(show);
166 self
167 }
168}
169
170pub struct CheckboxGroup {
171 selected: Vec<usize>,
172 disabled: bool,
173 focus_handle: FocusHandle,
174 options: Vec<SharedString>,
175 checkboxes: Vec<Entity<Checkbox>>,
176 layout: CheckboxGroupLayout,
177 size: CheckboxGroupSize,
178 stretch: bool,
179 option_style: Option<CheckboxOptionStyle>,
180 option_renderer: Option<Box<dyn Fn(CheckboxOptionRenderContext) -> AnyElement + 'static>>,
181 on_change: Option<Box<dyn Fn(Vec<usize>, &mut Window, &mut App) + 'static>>,
182}
183
184#[derive(Clone, Debug)]
185pub struct CheckboxOptionRenderContext {
186 pub index: usize,
187 pub label: SharedString,
188 pub selected: bool,
189 pub disabled: bool,
190}
191
192impl CheckboxGroup {
193 pub fn new(
194 options: Vec<impl Into<SharedString>>,
195 selected: Vec<usize>,
196 cx: &mut Context<Self>,
197 ) -> Self {
198 let options: Vec<SharedString> = options.into_iter().map(|o| o.into()).collect();
199 let mut checkboxes = Vec::new();
200
201 for (i, label) in options.iter().enumerate() {
202 let is_checked = selected.contains(&i);
203 let checkbox = cx.new(|cx| Checkbox::new(is_checked, cx).label(label.clone()));
204
205 cx.subscribe(
207 &checkbox,
208 move |this, _checkbox, event: &CheckboxChanged, cx| {
209 this.update_selection(i, event.0, cx);
210 },
211 )
212 .detach();
213
214 checkboxes.push(checkbox);
215 }
216
217 Self {
218 selected,
219 disabled: false,
220 focus_handle: cx.focus_handle(),
221 options,
222 checkboxes,
223 layout: CheckboxGroupLayout::Vertical,
224 size: CheckboxGroupSize::Default,
225 stretch: false,
226 option_style: None,
227 option_renderer: None,
228 on_change: None,
229 }
230 }
231
232 pub fn disabled(mut self, d: bool, cx: &mut Context<Self>) -> Self {
233 self.disabled = d;
234 for cb in &self.checkboxes {
235 cb.update(cx, |cb, cx| {
236 cb.set_disabled(d, cx);
237 });
238 }
239 self
240 }
241
242 pub fn on_change(mut self, cb: impl Fn(Vec<usize>, &mut Window, &mut App) + 'static) -> Self {
243 self.on_change = Some(Box::new(cb));
244 self
245 }
246
247 pub fn layout(mut self, layout: CheckboxGroupLayout) -> Self {
248 self.layout = layout;
249 self
250 }
251
252 pub fn vertical(mut self) -> Self {
253 self.layout = CheckboxGroupLayout::Vertical;
254 self
255 }
256
257 pub fn horizontal(mut self) -> Self {
258 self.layout = CheckboxGroupLayout::Horizontal;
259 self
260 }
261
262 pub fn button(mut self) -> Self {
263 self.layout = CheckboxGroupLayout::Button;
264 self
265 }
266
267 pub fn size(mut self, size: CheckboxGroupSize) -> Self {
268 self.size = size;
269 self
270 }
271
272 pub fn large(mut self) -> Self {
273 self.size = CheckboxGroupSize::Large;
274 self
275 }
276
277 pub fn small(mut self) -> Self {
278 self.size = CheckboxGroupSize::Small;
279 self
280 }
281
282 pub fn stretch(mut self, stretch: bool) -> Self {
283 self.stretch = stretch;
284 self
285 }
286
287 pub fn block(self, block: bool) -> Self {
288 self.stretch(block)
289 }
290
291 pub fn option_style(mut self, style: CheckboxOptionStyle) -> Self {
292 self.option_style = Some(style);
293 self
294 }
295
296 pub fn option_renderer(
297 mut self,
298 renderer: impl Fn(CheckboxOptionRenderContext) -> AnyElement + 'static,
299 ) -> Self {
300 self.option_renderer = Some(Box::new(renderer));
301 self
302 }
303
304 pub fn card_options(mut self) -> Self {
305 self.option_style = Some(
306 CheckboxOptionStyle::new()
307 .radius(px(10.0))
308 .padding(px(12.0), px(8.0)),
309 );
310 self
311 }
312
313 pub fn is_stretched(&self) -> bool {
314 self.stretch
315 }
316
317 pub fn layout_kind(&self) -> CheckboxGroupLayout {
318 self.layout
319 }
320
321 pub fn size_kind(&self) -> CheckboxGroupSize {
322 self.size
323 }
324
325 pub fn register_key_bindings(_cx: &mut App) {}
326
327 fn update_selection(&mut self, idx: usize, checked: bool, cx: &mut Context<Self>) {
328 if checked {
329 if !self.selected.contains(&idx) {
330 self.selected.push(idx);
331 self.selected.sort();
332 }
333 } else {
334 self.selected.retain(|&i| i != idx);
335 }
336 cx.notify();
337 }
338
339 fn toggle_idx(&mut self, idx: usize, cx: &mut Context<Self>) {
340 if self.disabled || idx >= self.options.len() {
341 return;
342 }
343 let checked = !self.selected.contains(&idx);
344 self.update_selection(idx, checked, cx);
345 }
346
347 fn render_indicator(
348 &self,
349 checked: bool,
350 border: Hsla,
351 bg: Hsla,
352 check_color: Hsla,
353 show_selected_icon: bool,
354 ) -> impl IntoElement {
355 let mut indicator = gpui::div()
356 .flex_none()
357 .w(px(16.0))
358 .h(px(16.0))
359 .rounded(px(3.0))
360 .bg(bg)
361 .border_1()
362 .border_color(border)
363 .flex()
364 .items_center()
365 .justify_center();
366
367 if checked && show_selected_icon {
368 indicator =
369 indicator.child(Icon::new(IconName::Check).size(px(12.0)).color(check_color));
370 }
371
372 indicator
373 }
374
375 fn render_option_content(&self, idx: usize, label: SharedString, checked: bool) -> AnyElement {
376 if let Some(renderer) = &self.option_renderer {
377 renderer(CheckboxOptionRenderContext {
378 index: idx,
379 label,
380 selected: checked,
381 disabled: self.disabled,
382 })
383 } else {
384 gpui::div().child(label).into_any_element()
385 }
386 }
387
388 fn render_styled_option(
389 &self,
390 idx: usize,
391 label: SharedString,
392 checked: bool,
393 style: CheckboxOptionStyle,
394 cx: &mut Context<Self>,
395 ) -> impl IntoElement {
396 let theme = cx.global::<Config>().theme.clone();
397 let disabled = self.disabled;
398 let selected_bg = style
399 .selected_bg
400 .unwrap_or(theme.primary.base.opacity(0.12));
401 let bg = if checked {
402 selected_bg
403 } else {
404 style.bg.unwrap_or(theme.neutral.card)
405 };
406 let hover_bg = style.hover_bg.unwrap_or(theme.neutral.hover);
407 let border = if checked {
408 style.selected_border_color.unwrap_or(theme.primary.base)
409 } else {
410 style.border_color.unwrap_or(theme.neutral.border)
411 };
412 let text_color = if disabled {
413 theme.neutral.text_disabled
414 } else if checked {
415 style.selected_text_color.unwrap_or(theme.primary.base)
416 } else {
417 style.text_color.unwrap_or(theme.neutral.text_1)
418 };
419 let show_indicator = style.show_indicator.unwrap_or(true);
420 let show_selected_icon = style.show_selected_icon.unwrap_or(true);
421
422 let mut item = gpui::div()
423 .flex()
424 .flex_row()
425 .items_center()
426 .gap(style.gap.unwrap_or(px(8.0)))
427 .px(style.padding_x.unwrap_or(px(12.0)))
428 .py(style.padding_y.unwrap_or(px(8.0)))
429 .rounded(style.radius.unwrap_or(px(theme.radius.md)))
430 .border_1()
431 .border_color(border)
432 .bg(bg)
433 .text_size(self.size.text_size(&theme))
434 .text_color(text_color);
435
436 if !disabled {
437 item = item.cursor_pointer().hover(move |s| {
438 if checked {
439 s.cursor_pointer()
440 } else {
441 s.cursor_pointer().bg(hover_bg)
442 }
443 });
444 item = item.on_mouse_up(
445 MouseButton::Left,
446 cx.listener(
447 move |this: &mut Self,
448 _: &MouseUpEvent,
449 _: &mut Window,
450 cx: &mut Context<Self>| {
451 this.toggle_idx(idx, cx);
452 },
453 ),
454 );
455 } else {
456 item = item.cursor_not_allowed();
457 }
458
459 if show_indicator {
460 let indicator_bg = if checked {
461 theme.primary.base
462 } else {
463 rgba(0, 0, 0, 0.0)
464 };
465 item = item.child(self.render_indicator(
466 checked,
467 border,
468 indicator_bg,
469 rgba(255, 255, 255, 1.0),
470 show_selected_icon,
471 ));
472 }
473
474 item.child(self.render_option_content(idx, label, checked))
475 }
476
477 fn render_button_group(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
478 let theme = cx.global::<Config>().theme.clone();
479 let radius = px(theme.radius.md);
480 let height = self.size.height();
481 let padding_x = self.size.padding_x();
482 let text_size = self.size.text_size(&theme);
483
484 let mut group = gpui::div()
485 .flex()
486 .items_center()
487 .rounded(radius)
488 .border_1()
489 .border_color(theme.neutral.border)
490 .overflow_hidden()
491 .when(self.stretch, |s| s.w_full())
492 .when(!self.stretch, |s| s.items_start());
493
494 if !self.disabled {
495 group = group.track_focus(&self.focus_handle);
496 }
497
498 for (idx, label) in self.options.iter().enumerate() {
499 let checked = self.selected.contains(&idx);
500 let is_first = idx == 0;
501 let label = label.clone();
502 let style = self.option_style.clone().unwrap_or_default();
503 let bg = if checked {
504 style.selected_bg.unwrap_or(theme.primary.base)
505 } else {
506 style.bg.unwrap_or(theme.neutral.card)
507 };
508 let text_color = if self.disabled {
509 theme.neutral.text_disabled
510 } else if checked {
511 style
512 .selected_text_color
513 .unwrap_or_else(|| rgba(255, 255, 255, 1.0))
514 } else {
515 style.text_color.unwrap_or(theme.neutral.text_1)
516 };
517 let mut item = gpui::div()
518 .h(height)
519 .px(style.padding_x.unwrap_or(padding_x))
520 .flex()
521 .items_center()
522 .justify_center()
523 .when(self.stretch, |s| s.flex_1())
524 .gap(style.gap.unwrap_or(px(8.0)))
525 .bg(bg)
526 .text_size(text_size)
527 .text_color(text_color);
528
529 if checked && style.show_selected_icon.unwrap_or(true) {
530 item = item.child(Icon::new(IconName::Check).size(px(12.0)).color(text_color));
531 }
532 item = item.child(self.render_option_content(idx, label, checked));
533
534 if !is_first {
535 item = item
536 .border_l_1()
537 .border_color(style.border_color.unwrap_or(theme.neutral.border));
538 }
539 if !self.disabled {
540 let hover_bg = style.hover_bg.unwrap_or(theme.neutral.hover);
541 item = item.cursor_pointer().hover(move |s| {
542 if checked {
543 s.cursor_pointer()
544 } else {
545 s.cursor_pointer().bg(hover_bg)
546 }
547 });
548 item = item.on_mouse_up(
549 MouseButton::Left,
550 cx.listener(
551 move |this: &mut Self,
552 _: &MouseUpEvent,
553 _: &mut Window,
554 cx: &mut Context<Self>| {
555 this.toggle_idx(idx, cx);
556 },
557 ),
558 );
559 } else {
560 item = item.cursor_not_allowed();
561 }
562 group = group.child(item);
563 }
564
565 group
566 }
567}
568
569impl Focusable for CheckboxGroup {
570 fn focus_handle(&self, _cx: &App) -> FocusHandle {
571 self.focus_handle.clone()
572 }
573}
574
575impl Render for CheckboxGroup {
576 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
577 if self.layout == CheckboxGroupLayout::Button {
578 return self.render_button_group(cx).into_any_element();
579 }
580
581 let style = self.option_style.clone();
582 let mut col = gpui::div()
583 .flex()
584 .when(self.layout == CheckboxGroupLayout::Vertical, |s| {
585 s.flex_col().gap_2()
586 })
587 .when(self.layout == CheckboxGroupLayout::Horizontal, |s| {
588 s.flex_row().gap_4().items_center()
589 });
590
591 if !self.disabled {
592 col = col.track_focus(&self.focus_handle);
593 }
594
595 if let Some(style) = style {
596 for (idx, label) in self.options.iter().enumerate() {
597 let checked = self.selected.contains(&idx);
598 col = col.child(self.render_styled_option(
599 idx,
600 label.clone(),
601 checked,
602 style.clone(),
603 cx,
604 ));
605 }
606 } else if self.option_renderer.is_some() {
607 for (idx, label) in self.options.iter().enumerate() {
608 let checked = self.selected.contains(&idx);
609 col = col.child(self.render_styled_option(
610 idx,
611 label.clone(),
612 checked,
613 CheckboxOptionStyle::default(),
614 cx,
615 ));
616 }
617 } else {
618 for cb_entity in &self.checkboxes {
619 col = col.child(cb_entity.clone());
620 }
621 }
622
623 col.into_any_element()
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn checkbox_option_style_supports_layout_and_selected_style() {
633 let style = CheckboxOptionStyle::new()
634 .selected_bg(gpui::blue())
635 .selected_text_color(gpui::white())
636 .padding(px(14.0), px(10.0))
637 .radius(px(12.0))
638 .show_indicator(false);
639
640 assert_eq!(style.selected_bg, Some(gpui::blue()));
641 assert_eq!(style.padding_x, Some(px(14.0)));
642 assert_eq!(style.show_indicator, Some(false));
643 }
644
645 #[test]
646 fn checkbox_group_accepts_custom_option_renderer() {
647 let source = include_str!("checkbox_group.rs");
648 assert!(source.contains("pub struct CheckboxOptionRenderContext"));
649 assert!(source.contains("pub fn option_renderer"));
650 }
651}