use std::rc::Rc;
use gpui::{
App, FocusHandle, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::FluentBuilder,
};
use gpui_kit_assets::Icon;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Space};
use crate::controls::button::{Button, ButtonJoin, ButtonVariant};
use crate::foundation::direction::{ActiveDirection, DirectionalExt};
use crate::foundation::{Disableable, Ident, Selectable, Sizable, StyledExt};
type PressHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct Toggle {
ident: Ident,
label: Option<SharedString>,
name: Option<SharedString>,
glyph: Option<Icon>,
icon_only: bool,
pressed: bool,
disabled: bool,
size: ControlSize,
variant: ButtonVariant,
join: ButtonJoin,
semantic_parent: Option<SharedString>,
focus_handle: Option<FocusHandle>,
on_press: Option<PressHandler>,
}
impl std::fmt::Debug for Toggle {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Toggle")
.field("ident", &self.ident)
.field("label", &self.label)
.field("pressed", &self.pressed)
.field("disabled", &self.disabled)
.field("has_handler", &self.on_press.is_some())
.finish()
}
}
impl Toggle {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
label: None,
name: None,
glyph: None,
icon_only: false,
pressed: false,
disabled: false,
size: ControlSize::Md,
variant: ButtonVariant::Ghost,
join: ButtonJoin::Alone,
semantic_parent: None,
focus_handle: None,
on_press: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn accessible_name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn icon(mut self, glyph: Icon) -> Self {
self.glyph = Some(glyph);
self
}
pub fn icon_only(mut self, glyph: Icon, name: impl Into<SharedString>) -> Self {
self.glyph = Some(glyph);
self.label = None;
self.icon_only = true;
self.name = Some(name.into());
self
}
pub fn pressed(mut self, pressed: bool) -> Self {
self.pressed = pressed;
self
}
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn secondary(self) -> Self {
self.variant(ButtonVariant::Secondary)
}
pub fn ghost(self) -> Self {
self.variant(ButtonVariant::Ghost)
}
pub fn join(mut self, join: ButtonJoin) -> Self {
self.join = join;
self
}
pub fn semantic_parent(mut self, parent: impl Into<SharedString>) -> Self {
self.semantic_parent = Some(parent.into());
self
}
pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
self.focus_handle = Some(handle.clone());
self
}
pub fn on_press(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
fn actionable(&self) -> bool {
!self.disabled && self.on_press.is_some()
}
}
impl Disableable for Toggle {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for Toggle {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for Toggle {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let next = !self.pressed;
let actionable = self.actionable();
Button::new(self.ident.clone())
.variant(self.variant)
.control_size(self.size)
.join(self.join)
.disabled(self.disabled)
.selected(self.pressed)
.checked_state(self.pressed)
.when_some(self.label.clone(), |button, label| button.label(label))
.when_some(self.name.clone(), |button, name| {
button.accessible_name(name)
})
.when_some(self.glyph, |button, glyph| {
match (self.icon_only, self.name.clone()) {
(true, Some(name)) => button.icon_only(glyph, name),
_ => button.icon(glyph),
}
})
.when_some(self.semantic_parent.clone(), |button, parent| {
button.semantic_parent(parent)
})
.when_some(self.focus_handle.as_ref(), |button, handle| {
button.track_focus(handle)
})
.when_some(
actionable.then(|| self.on_press.clone()).flatten(),
|button, handler| button.on_click(move |window, cx| handler(next, window, cx)),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToggleSelection {
AtMostOne,
#[default]
Any,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToggleItem {
id: SharedString,
label: SharedString,
name: Option<SharedString>,
icon: Option<Icon>,
icon_only: bool,
disabled: bool,
}
impl ToggleItem {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
name: None,
icon: None,
icon_only: false,
disabled: false,
}
}
pub fn glyph(id: impl Into<SharedString>, glyph: Icon, name: impl Into<SharedString>) -> Self {
let name = name.into();
Self {
icon: Some(glyph),
icon_only: true,
name: Some(name.clone()),
..Self::new(id, name)
}
}
pub fn icon(mut self, glyph: Icon) -> Self {
self.icon = Some(glyph);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn label(&self) -> &SharedString {
&self.label
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
type ChangeHandler = Rc<dyn Fn(Vec<SharedString>, SharedString, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct ToggleGroup {
ident: Ident,
label: Option<SharedString>,
items: Vec<ToggleItem>,
pressed: Vec<SharedString>,
selection: ToggleSelection,
size: ControlSize,
variant: ButtonVariant,
disabled: bool,
on_change: Option<ChangeHandler>,
}
impl std::fmt::Debug for ToggleGroup {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ToggleGroup")
.field("ident", &self.ident)
.field("items", &self.items.len())
.field("pressed", &self.pressed)
.field("selection", &self.selection)
.field("disabled", &self.disabled)
.field("has_handler", &self.on_change.is_some())
.finish()
}
}
impl ToggleGroup {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
label: None,
items: Vec::new(),
pressed: Vec::new(),
selection: ToggleSelection::default(),
size: ControlSize::Md,
variant: ButtonVariant::Secondary,
disabled: false,
on_change: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn items(mut self, items: impl IntoIterator<Item = ToggleItem>) -> Self {
self.items = items.into_iter().collect();
self
}
pub fn selection(mut self, selection: ToggleSelection) -> Self {
self.selection = selection;
self
}
pub fn pressed(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
self.pressed = ids.into_iter().collect();
self
}
pub fn pressed_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
self.pressed = ids
.iter()
.map(|id| SharedString::from(id.as_ref().to_string()))
.collect();
self
}
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<SharedString>, SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl Disableable for ToggleGroup {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for ToggleGroup {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
fn next_set(
items: &[ToggleItem],
pressed: &[SharedString],
id: &SharedString,
selection: ToggleSelection,
) -> Vec<SharedString> {
let is_in = pressed.contains(id);
match (selection, is_in) {
(ToggleSelection::AtMostOne, true) => Vec::new(),
(ToggleSelection::AtMostOne, false) => vec![id.clone()],
(ToggleSelection::Any, _) => items
.iter()
.map(|item| &item.id)
.filter(|other| {
if *other == id {
!is_in
} else {
pressed.contains(other)
}
})
.cloned()
.collect(),
}
}
impl RenderOnce for ToggleGroup {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let parent = self.ident.semantic_id();
let last = self.items.len().saturating_sub(1);
let actionable = !self.disabled && self.on_change.is_some();
let toggles = self
.items
.iter()
.enumerate()
.map(|(index, item)| {
let join = match (index, last) {
(_, 0) => ButtonJoin::Alone,
(0, _) => ButtonJoin::Leading,
(index, last) if index == last => ButtonJoin::Trailing,
_ => ButtonJoin::Middle,
};
let refused = self.disabled || item.disabled;
let id = item.id.clone();
let handler = actionable
.then(|| self.on_change.clone())
.flatten()
.filter(|_| !item.disabled);
let next = next_set(&self.items, &self.pressed, &item.id, self.selection);
Toggle::new(self.ident.child(item.id.as_ref()))
.variant(self.variant)
.control_size(self.size)
.join(join)
.semantic_parent(parent.clone())
.pressed(self.pressed.contains(&item.id))
.disabled(refused)
.when(!item.icon_only, |toggle| toggle.label(item.label.clone()))
.when_some(item.name.clone(), |toggle, name| {
toggle.accessible_name(name)
})
.when_some(item.icon, |toggle, glyph| {
match (item.icon_only, item.name.clone()) {
(true, Some(name)) => toggle.icon_only(glyph, name),
_ => toggle.icon(glyph),
}
})
.when_some(handler, |toggle, handler| {
toggle.on_press(move |_, window, cx| {
handler(next.clone(), id.clone(), window, cx)
})
})
})
.collect::<Vec<_>>();
div()
.row_reading(cx.layout_direction())
.flex_none()
.gap_token(&theme, Space::Xs)
.children(toggles)
.semantic_in(cx, {
let mut spec = NodeSpec::new(parent, Role::Toolbar).disabled(self.disabled);
if let Some(label) = self.label.clone() {
spec = spec.text(label);
}
spec
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<ToggleItem> {
vec![
ToggleItem::new("bold", "Bold"),
ToggleItem::new("italic", "Italic"),
ToggleItem::new("underline", "Underline").disabled(true),
]
}
fn ids(values: &[&str]) -> Vec<SharedString> {
values.iter().map(|id| SharedString::from(*id)).collect()
}
#[test]
fn several_may_be_in_at_once() {
let items = items();
let pressed = ids(&["bold"]);
assert_eq!(
next_set(&items, &pressed, &"italic".into(), ToggleSelection::Any),
ids(&["bold", "italic"])
);
assert_eq!(
next_set(&items, &pressed, &"bold".into(), ToggleSelection::Any),
Vec::<SharedString>::new()
);
}
#[test]
fn the_report_follows_the_groups_own_order() {
let items = items();
let pressed = ids(&["italic"]);
assert_eq!(
next_set(&items, &pressed, &"bold".into(), ToggleSelection::Any),
ids(&["bold", "italic"]),
"the order is the group's, not the order they went in"
);
}
#[test]
fn at_most_one_can_be_emptied_which_is_the_whole_difference() {
let items = items();
let pressed = ids(&["bold"]);
assert_eq!(
next_set(
&items,
&pressed,
&"italic".into(),
ToggleSelection::AtMostOne
),
ids(&["italic"])
);
assert_eq!(
next_set(&items, &pressed, &"bold".into(), ToggleSelection::AtMostOne),
Vec::<SharedString>::new(),
"a radio group has no move that gets here"
);
}
}