gpuikit 0.9.0

A UI toolkit for GPUI applications
Documentation
//! Toggle group component for gpuikit
//!
//! A toggle group allows selecting one or multiple options from a group of toggle buttons.

use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::control_sized::ControlSized;
use crate::traits::disableable::Disableable;
use crate::traits::orientable::{Orientable, Orientation};
use gpui::{
    Context, Div, ElementId, EventEmitter, FontWeight, Hsla, InteractiveElement, IntoElement,
    MouseButton, ParentElement, Render, SharedString, Stateful, StatefulInteractiveElement, Styled,
    Window, div, prelude::*,
};

/// Selection mode for the toggle group
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ToggleGroupMode {
    /// Only one item can be selected at a time (like radio buttons)
    #[default]
    Single,
    /// Multiple items can be selected simultaneously
    Multiple,
}

/// Event emitted when the toggle group selection changes
pub struct ToggleGroupChanged<T: Clone> {
    /// Currently selected values
    pub selected: Vec<T>,
}

/// A single toggle option in the group
#[derive(Clone)]
pub struct ToggleOption<T: Clone> {
    /// The value associated with this option
    pub value: T,
    /// Display label for the option
    pub label: SharedString,
    /// Whether this option is disabled
    pub disabled: bool,
}

impl<T: Clone> ToggleOption<T> {
    /// Create a new toggle option with a value and label
    pub fn new(value: T, label: impl Into<SharedString>) -> Self {
        Self {
            value,
            label: label.into(),
            disabled: false,
        }
    }

    /// Set whether this option is disabled
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
}

impl<T: Clone> Disableable for ToggleOption<T> {
    fn is_disabled(&self) -> bool {
        self.disabled
    }

    fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
}

/// A toggle group component for selecting one or multiple options
///
/// # Example
///
/// ```
/// # use gpui::{Context, IntoElement, Render, Window, prelude::*};
/// use gpuikit::elements::toggle_group::{ToggleGroupMode, toggle_group, toggle_option};
/// # struct D;
/// # impl Render for D { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
/// // Single-select mode (default)
/// let single = toggle_group(
///     "alignment",
///     vec![
///         toggle_option("left", "Left"),
///         toggle_option("center", "Center"),
///         toggle_option("right", "Right"),
///     ],
/// ).selected(vec!["center"]);
///
/// // Multi-select mode
/// let multi = toggle_group(
///     "features",
///     vec![
///         toggle_option("bold", "B"),
///         toggle_option("italic", "I"),
///         toggle_option("underline", "U"),
///     ],
/// ).mode(ToggleGroupMode::Multiple)
///  .selected(vec!["bold", "italic"]);
/// # cx.new(|_cx| single)
/// # }}
/// # let mut tcx = gpui::TestAppContext::single();
/// # tcx.update(gpuikit::init);
/// # let _ = tcx.add_window_view(|_, _| D);
/// ```
pub struct ToggleGroup<T: Clone + PartialEq + 'static> {
    id: ElementId,
    options: Vec<ToggleOption<T>>,
    selected: Vec<T>,
    mode: ToggleGroupMode,
    disabled: bool,
    orientation: Orientation,
    size: ControlSize,
}

impl<T: Clone + PartialEq + 'static> EventEmitter<ToggleGroupChanged<T>> for ToggleGroup<T> {}

impl<T: Clone + PartialEq + 'static> ToggleGroup<T> {
    /// Create a new toggle group with an ID and options
    pub fn new(id: impl Into<ElementId>, options: Vec<ToggleOption<T>>) -> Self {
        Self {
            id: id.into(),
            options,
            selected: Vec::new(),
            mode: ToggleGroupMode::default(),
            disabled: false,
            orientation: Orientation::Horizontal,
            size: ControlSize::default(),
        }
    }

    /// Set the selection mode (single or multiple)
    pub fn mode(mut self, mode: ToggleGroupMode) -> Self {
        self.mode = mode;
        self
    }

    /// Set the selected values
    pub fn selected(mut self, values: Vec<T>) -> Self {
        self.selected = values;
        self
    }

    /// Set a single selected value (convenience for single-select mode)
    pub fn selected_value(mut self, value: T) -> Self {
        self.selected = vec![value];
        self
    }

    /// Get the currently selected values
    pub fn get_selected(&self) -> &[T] {
        &self.selected
    }

    /// Check if a value is selected
    pub fn is_value_selected(&self, value: &T) -> bool {
        self.selected.contains(value)
    }

    /// Set the selected values programmatically
    pub fn set_selected(&mut self, values: Vec<T>, cx: &mut Context<Self>) {
        if self.selected != values {
            self.selected = values.clone();
            cx.emit(ToggleGroupChanged { selected: values });
            cx.notify();
        }
    }

    fn toggle_option(&mut self, index: usize, cx: &mut Context<Self>) {
        if let Some(option) = self.options.get(index) {
            if option.disabled || self.disabled {
                return;
            }

            let value = option.value.clone();
            let is_selected = self.selected.contains(&value);

            match self.mode {
                ToggleGroupMode::Single => {
                    // In single mode, always select the clicked option (unless it's already selected)
                    if !is_selected {
                        self.selected = vec![value.clone()];
                        cx.emit(ToggleGroupChanged {
                            selected: vec![value],
                        });
                        cx.notify();
                    }
                }
                ToggleGroupMode::Multiple => {
                    // In multiple mode, toggle the selection
                    if is_selected {
                        self.selected.retain(|v| v != &value);
                    } else {
                        self.selected.push(value);
                    }
                    cx.emit(ToggleGroupChanged {
                        selected: self.selected.clone(),
                    });
                    cx.notify();
                }
            }
        }
    }
}

impl<T: Clone + PartialEq + 'static> Render for ToggleGroup<T> {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = cx.theme();
        let metrics = theme.control(self.size);
        let group_disabled = self.disabled;
        let selected = self.selected.clone();
        let orientation = self.orientation;
        let num_options = self.options.len();

        // The group is the control, and the options fill it. The old shape put
        // 2px of padding inside the border and let each option's own padding
        // decide the height, which is the emergent-size failure
        // `crate::theme::control` exists to end: a segmented control matched a
        // `Button` on no rung. A horizontal group is the rung exactly; a
        // vertical one is a rung per option, which is the only reading of a
        // stack of them that keeps each option the height of a button.
        let container = if orientation == Orientation::Vertical {
            div().flex().flex_col()
        } else {
            div().flex().flex_row().h(metrics.height)
        };

        container
            .id(self.id.clone())
            .bg(theme.surface_secondary())
            .border_1()
            .border_color(theme.border())
            .rounded(metrics.radius)
            .children(
                self.options
                    .iter()
                    .enumerate()
                    .map(|(index, option)| {
                        let is_selected = selected.contains(&option.value);
                        let is_disabled = group_disabled || option.disabled;
                        let label = option.label.clone();
                        let is_first = index == 0;
                        let is_last = index == num_options - 1;

                        let bg: Option<Hsla> = if is_disabled {
                            Some(theme.surface_tertiary())
                        } else if is_selected {
                            Some(theme.surface())
                        } else {
                            None
                        };

                        let text_color = if is_disabled {
                            theme.fg_disabled()
                        } else if is_selected {
                            theme.fg()
                        } else {
                            theme.fg_muted()
                        };

                        div()
                            .id(ElementId::NamedInteger(
                                "toggle-option".into(),
                                index as u64,
                            ))
                            .flex()
                            .items_center()
                            .justify_center()
                            // A vertical group has no outer height to divide,
                            // so each option declares the rung itself: a stack
                            // of options is a stack of rungs, and the group is
                            // taller than the sum only by its own border.
                            .when(orientation == Orientation::Vertical, |this| {
                                this.h(metrics.height)
                            })
                            .px(metrics.padding_x)
                            .text_size(metrics.text_size)
                            .line_height(metrics.line_height)
                            .font_weight(FontWeight::MEDIUM)
                            .when_some(bg, |this, bg| this.bg(bg))
                            .text_color(text_color)
                            .when(is_selected && !is_disabled, |this: Stateful<Div>| {
                                this.shadow_sm()
                            })
                            // The group's radius, less its own border, is what
                            // an option's outer corner has to be to sit inside
                            // the border rather than cut across it.
                            .map(|this| {
                                let radius = metrics.inner_radius();
                                match orientation {
                                    Orientation::Horizontal => this
                                        .when(is_first, |t| t.rounded_l(radius))
                                        .when(is_last, |t| t.rounded_r(radius))
                                        .when(!is_first && !is_last, |t| t.rounded_none()),
                                    Orientation::Vertical => this
                                        .when(is_first, |t| t.rounded_t(radius))
                                        .when(is_last, |t| t.rounded_b(radius))
                                        .when(!is_first && !is_last, |t| t.rounded_none()),
                                }
                            })
                            .when(!is_disabled, |this| {
                                this.cursor_pointer()
                                    .hover(|style| {
                                        if is_selected {
                                            style
                                        } else {
                                            style.bg(theme.surface_tertiary())
                                        }
                                    })
                                    .on_mouse_down(MouseButton::Left, |_, window, _| {
                                        window.prevent_default()
                                    })
                                    .on_click(cx.listener(move |this, _, _, cx| {
                                        this.toggle_option(index, cx);
                                    }))
                            })
                            .when(is_disabled, |this| this.cursor_not_allowed().opacity(0.65))
                            .child(label)
                    })
                    .collect::<Vec<_>>(),
            )
    }
}

/// Convenience function to create a toggle group
pub fn toggle_group<T: Clone + PartialEq + 'static>(
    id: impl Into<ElementId>,
    options: Vec<ToggleOption<T>>,
) -> ToggleGroup<T> {
    ToggleGroup::new(id, options)
}

/// Convenience function to create a toggle option
pub fn toggle_option<T: Clone>(value: T, label: impl Into<SharedString>) -> ToggleOption<T> {
    ToggleOption::new(value, label)
}

impl<T: Clone + PartialEq + 'static> Disableable for ToggleGroup<T> {
    fn is_disabled(&self) -> bool {
        self.disabled
    }

    fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
}

impl<T: Clone + PartialEq + 'static> Orientable for ToggleGroup<T> {
    fn orientation(mut self, orientation: Orientation) -> Self {
        self.orientation = orientation;
        self
    }
}

impl<T: Clone + PartialEq + 'static> ControlSized for ToggleGroup<T> {
    fn control_size(mut self, size: ControlSize) -> Self {
        self.size = size;
        self
    }
}