Skip to main content

liora_components/
checkbox_group.rs

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            // Subscribe to each checkbox's change
206            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}