Add interactivity to `Checkbox` component (#3240)

Marshall Bowers created

This PR adds interactivity to the `Checkbox` component.

They can now be checked and unchecked by clicking them.

Release Notes:

- N/A

Change summary

crates/gpui2/src/element.rs            |  13 +++
crates/ui2/src/components/button.rs    |   2 
crates/ui2/src/components/checkbox.rs  | 113 +++++++++++++++------------
crates/ui2/src/prelude.rs              |  11 ++
crates/ui2/src/to_extract/workspace.rs |  23 +++++
5 files changed, 106 insertions(+), 56 deletions(-)

Detailed changes

crates/gpui2/src/element.rs 🔗

@@ -212,6 +212,19 @@ pub trait Component<V> {
     {
         self.map(|this| if condition { then(this) } else { this })
     }
+
+    fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
+    where
+        Self: Sized,
+    {
+        self.map(|this| {
+            if let Some(value) = option {
+                then(this, value)
+            } else {
+                this
+            }
+        })
+    }
 }
 
 impl<V> Component<V> for AnyElement<V> {

crates/ui2/src/components/button.rs 🔗

@@ -61,7 +61,7 @@ impl ButtonVariant {
     }
 }
 
-pub type ClickHandler<S> = Arc<dyn Fn(&mut S, &mut ViewContext<S>) + Send + Sync>;
+pub type ClickHandler<V> = Arc<dyn Fn(&mut V, &mut ViewContext<V>) + Send + Sync>;
 
 struct ButtonHandlers<V: 'static> {
     click: Option<ClickHandler<V>>,

crates/ui2/src/components/checkbox.rs 🔗

@@ -1,63 +1,58 @@
-///! # Checkbox
-///!
-///! Checkboxes are used for multiple choices, not for mutually exclusive choices.
-///! Each checkbox works independently from other checkboxes in the list,
-///! therefore checking an additional box does not affect any other selections.
+use std::sync::Arc;
+
 use gpui2::{
-    div, Component, ParentElement, SharedString, StatelessInteractive, Styled, ViewContext,
+    div, Component, ElementId, ParentElement, StatefulInteractive, StatelessInteractive, Styled,
+    ViewContext,
 };
 use theme2::ActiveTheme;
 
-use crate::{Icon, IconColor, IconElement, Selected};
+use crate::{Icon, IconColor, IconElement, Selection};
+
+pub type CheckHandler<V> = Arc<dyn Fn(Selection, &mut V, &mut ViewContext<V>) + Send + Sync>;
 
+/// # Checkbox
+///
+/// Checkboxes are used for multiple choices, not for mutually exclusive choices.
+/// Each checkbox works independently from other checkboxes in the list,
+/// therefore checking an additional box does not affect any other selections.
 #[derive(Component)]
-pub struct Checkbox {
-    id: SharedString,
-    checked: Selected,
+pub struct Checkbox<V: 'static> {
+    id: ElementId,
+    checked: Selection,
     disabled: bool,
+    on_click: Option<CheckHandler<V>>,
 }
 
-impl Checkbox {
-    pub fn new(id: impl Into<SharedString>) -> Self {
+impl<V: 'static> Checkbox<V> {
+    pub fn new(id: impl Into<ElementId>, checked: Selection) -> Self {
         Self {
             id: id.into(),
-            checked: Selected::Unselected,
+            checked,
             disabled: false,
+            on_click: None,
         }
     }
 
-    pub fn toggle(mut self) -> Self {
-        self.checked = match self.checked {
-            Selected::Selected => Selected::Unselected,
-            Selected::Unselected => Selected::Selected,
-            Selected::Indeterminate => Selected::Selected,
-        };
+    pub fn disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
         self
     }
 
-    pub fn set_indeterminate(mut self) -> Self {
-        self.checked = Selected::Indeterminate;
+    pub fn on_click(
+        mut self,
+        handler: impl 'static + Fn(Selection, &mut V, &mut ViewContext<V>) + Send + Sync,
+    ) -> Self {
+        self.on_click = Some(Arc::new(handler));
         self
     }
 
-    pub fn set_disabled(mut self, disabled: bool) -> Self {
-        self.disabled = disabled;
-        self
-    }
+    pub fn render(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
+        let group_id = format!("checkbox_group_{:?}", self.id);
 
-    pub fn render<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
-        let group_id = format!("checkbox_group_{}", self.id);
-
-        // The icon is different depending on the state of the checkbox.
-        //
-        // We need the match to return all the same type,
-        // so we wrap the eatch result in a div.
-        //
-        // We are still exploring the best way to handle this.
         let icon = match self.checked {
             // When selected, we show a checkmark.
-            Selected::Selected => {
-                div().child(
+            Selection::Selected => {
+                Some(
                     IconElement::new(Icon::Check)
                         .size(crate::IconSize::Small)
                         .color(
@@ -71,8 +66,8 @@ impl Checkbox {
                 )
             }
             // In an indeterminate state, we show a dash.
-            Selected::Indeterminate => {
-                div().child(
+            Selection::Indeterminate => {
+                Some(
                     IconElement::new(Icon::Dash)
                         .size(crate::IconSize::Small)
                         .color(
@@ -86,7 +81,7 @@ impl Checkbox {
                 )
             }
             // When unselected, we show nothing.
-            Selected::Unselected => div(),
+            Selection::Unselected => None,
         };
 
         // A checkbox could be in an indeterminate state,
@@ -98,7 +93,7 @@ impl Checkbox {
         // For the sake of styles we treat the indeterminate state as selected,
         // but it's icon will be different.
         let selected =
-            self.checked == Selected::Selected || self.checked == Selected::Indeterminate;
+            self.checked == Selection::Selected || self.checked == Selection::Indeterminate;
 
         // We could use something like this to make the checkbox background when selected:
         //
@@ -127,6 +122,7 @@ impl Checkbox {
         };
 
         div()
+            .id(self.id)
             // Rather than adding `px_1()` to add some space around the checkbox,
             // we use a larger parent element to create a slightly larger
             // click area for the checkbox.
@@ -161,7 +157,13 @@ impl Checkbox {
                             el.bg(cx.theme().colors().element_hover)
                         })
                     })
-                    .child(icon),
+                    .children(icon),
+            )
+            .when_some(
+                self.on_click.filter(|_| !self.disabled),
+                |this, on_click| {
+                    this.on_click(move |view, _, cx| on_click(self.checked.inverse(), view, cx))
+                },
             )
     }
 }
@@ -182,7 +184,7 @@ mod stories {
 
         fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
             Story::container(cx)
-                .child(Story::title_for::<_, Checkbox>(cx))
+                .child(Story::title_for::<_, Checkbox<Self>>(cx))
                 .child(Story::label(cx, "Default"))
                 .child(
                     h_stack()
@@ -191,9 +193,12 @@ mod stories {
                         .rounded_md()
                         .border()
                         .border_color(cx.theme().colors().border)
-                        .child(Checkbox::new("checkbox-enabled"))
-                        .child(Checkbox::new("checkbox-intermediate").set_indeterminate())
-                        .child(Checkbox::new("checkbox-selected").toggle()),
+                        .child(Checkbox::new("checkbox-enabled", Selection::Unselected))
+                        .child(Checkbox::new(
+                            "checkbox-intermediate",
+                            Selection::Indeterminate,
+                        ))
+                        .child(Checkbox::new("checkbox-selected", Selection::Selected)),
                 )
                 .child(Story::label(cx, "Disabled"))
                 .child(
@@ -203,16 +208,20 @@ mod stories {
                         .rounded_md()
                         .border()
                         .border_color(cx.theme().colors().border)
-                        .child(Checkbox::new("checkbox-disabled").set_disabled(true))
                         .child(
-                            Checkbox::new("checkbox-disabled-intermediate")
-                                .set_disabled(true)
-                                .set_indeterminate(),
+                            Checkbox::new("checkbox-disabled", Selection::Unselected)
+                                .disabled(true),
+                        )
+                        .child(
+                            Checkbox::new(
+                                "checkbox-disabled-intermediate",
+                                Selection::Indeterminate,
+                            )
+                            .disabled(true),
                         )
                         .child(
-                            Checkbox::new("checkbox-disabled-selected")
-                                .set_disabled(true)
-                                .toggle(),
+                            Checkbox::new("checkbox-disabled-selected", Selection::Selected)
+                                .disabled(true),
                         ),
                 )
         }

crates/ui2/src/prelude.rs 🔗

@@ -155,9 +155,18 @@ impl InteractionState {
 }
 
 #[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)]
-pub enum Selected {
+pub enum Selection {
     #[default]
     Unselected,
     Indeterminate,
     Selected,
 }
+
+impl Selection {
+    pub fn inverse(&self) -> Self {
+        match self {
+            Self::Unselected | Self::Indeterminate => Self::Selected,
+            Self::Selected => Self::Unselected,
+        }
+    }
+}

crates/ui2/src/to_extract/workspace.rs 🔗

@@ -7,8 +7,8 @@ use theme2::ThemeSettings;
 
 use crate::prelude::*;
 use crate::{
-    static_livestream, v_stack, AssistantPanel, Button, ChatMessage, ChatPanel, CollabPanel,
-    EditorPane, Label, LanguageSelector, NotificationsPanel, Pane, PaneGroup, Panel,
+    static_livestream, v_stack, AssistantPanel, Button, ChatMessage, ChatPanel, Checkbox,
+    CollabPanel, EditorPane, Label, LanguageSelector, NotificationsPanel, Pane, PaneGroup, Panel,
     PanelAllowedSides, PanelSide, ProjectPanel, SplitDirection, StatusBar, Terminal, TitleBar,
     Toast, ToastOrigin,
 };
@@ -42,6 +42,7 @@ pub struct Workspace {
     show_terminal: bool,
     show_debug: bool,
     show_language_selector: bool,
+    test_checkbox_selection: Selection,
     debug: Gpui2UiDebug,
 }
 
@@ -58,6 +59,7 @@ impl Workspace {
             show_language_selector: false,
             show_debug: false,
             show_notifications_panel: true,
+            test_checkbox_selection: Selection::Unselected,
             debug: Gpui2UiDebug::default(),
         }
     }
@@ -217,6 +219,23 @@ impl Render for Workspace {
             .text_color(cx.theme().colors().text)
             .bg(cx.theme().colors().background)
             .child(self.title_bar.clone())
+            .child(
+                div()
+                    .absolute()
+                    .top_12()
+                    .left_12()
+                    .z_index(99)
+                    .bg(cx.theme().colors().background)
+                    .child(
+                        Checkbox::new("test_checkbox", self.test_checkbox_selection).on_click(
+                            |selection, workspace: &mut Workspace, cx| {
+                                workspace.test_checkbox_selection = selection;
+
+                                cx.notify();
+                            },
+                        ),
+                    ),
+            )
             .child(
                 div()
                     .flex_1()