use crate::control::{PressReleaseExt as _, QuietSurfaceExt as _, composed_button};
use crate::handlers::SharedHandler;
use crate::motion::disclosure_progress;
use crate::surface::{hint, selection_surface};
use crate::theme::SemanticStyledExt as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, accesskit, div, prelude::FluentBuilder as _,
};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, v_flex};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChoiceOption {
id: SharedString,
label: SharedString,
description: Option<SharedString>,
disabled: bool,
}
impl ChoiceOption {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
disabled: false,
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChoiceEvent {
Chosen {
group: SharedString,
option: SharedString,
},
}
#[derive(IntoElement)]
pub struct ChoiceGroup {
id: SharedString,
label: Option<SharedString>,
options: Vec<ChoiceOption>,
selected: Option<SharedString>,
on_event: Option<SharedHandler<ChoiceEvent>>,
style: StyleRefinement,
}
impl ChoiceGroup {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: Some(label.into()),
options: Vec::new(),
selected: None,
on_event: None,
style: StyleRefinement::default(),
}
}
pub fn unlabelled(id: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: None,
options: Vec::new(),
selected: None,
on_event: None,
style: StyleRefinement::default(),
}
}
pub fn options(mut self, options: impl IntoIterator<Item = ChoiceOption>) -> Self {
self.options = options.into_iter().collect();
self
}
pub fn selected(mut self, option: impl Into<SharedString>) -> Self {
self.selected = Some(option.into());
self
}
pub fn selection(mut self, option: Option<SharedString>) -> Self {
self.selected = option;
self
}
pub fn on_event(
mut self,
handler: impl Fn(&ChoiceEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_event = Some(std::rc::Rc::new(handler));
self
}
}
impl Styled for ChoiceGroup {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for ChoiceGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let tokens = cx.theme().semantic_tokens();
let root_id = ElementId::from(self.id.clone());
let group = self.id.clone();
let handler = self.on_event;
let selected = self.selected;
let label = self.label.clone();
let rows: Vec<AnyElement> = self
.options
.into_iter()
.enumerate()
.map(|(position, option)| {
let chosen = selected.as_ref() == Some(&option.id);
choice_row(
&root_id,
&group,
option,
chosen,
position,
handler.clone(),
window,
cx,
)
})
.collect();
let set_size = rows.len();
v_flex()
.id(root_id)
.gap(tokens.spacing.xs)
.role(Role::RadioGroup)
.when_some(label.clone(), |group, label| group.aria_label(label))
.aria_size_of_set(set_size)
.refine_style(&self.style)
.when_some(label, |group, label| {
group.child(
div()
.text_token(tokens.typography.sm)
.text_color(cx.theme().foreground)
.child(label),
)
})
.children(rows)
}
}
fn indicator_size(cx: &App) -> Pixels {
crate::sizing::slot_sm(cx)
}
fn control_seat(control: impl IntoElement, cx: &App) -> gpui::Div {
let tokens = cx.theme().semantic_tokens();
crate::surface::leading_control_slot(
tokens.typography.sm.line_height,
switch_track_width(indicator_size(cx)),
control,
)
}
fn switch_track_width(slot: Pixels) -> Pixels {
slot * 1.6
}
#[allow(clippy::too_many_arguments)]
fn choice_row(
root_id: &ElementId,
group: &SharedString,
option: ChoiceOption,
chosen: bool,
position: usize,
handler: Option<SharedHandler<ChoiceEvent>>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let tokens = cx.theme().semantic_tokens();
let row_id = ElementId::from((root_id.clone(), option.id.clone()));
let press_id = ElementId::from((row_id.clone(), "press"));
let debug_group = group.clone();
let debug_option = option.id.clone();
let event = ChoiceEvent::Chosen {
group: group.clone(),
option: option.id.clone(),
};
let fill = disclosure_progress(
gpui_base::motion::TransitionId::from(ElementId::from((row_id.clone(), "chosen"))),
chosen,
window,
cx,
);
let ring = indicator_size(cx);
let accessibility_label = option.label.clone();
let row = composed_button(row_id.clone(), accessibility_label)
.w_full()
.justify_start()
.items_start()
.gap(tokens.spacing.sm)
.px(tokens.spacing.sm)
.py(tokens.spacing.xs)
.role(Role::RadioButton)
.aria_toggled(if chosen {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
})
.aria_position_in_set(position + 1)
.debug_selector(move || format!("choice-{debug_group}-{debug_option}"))
.child(
control_seat(
div()
.flex()
.items_center()
.justify_center()
.size(ring)
.rounded_full()
.border_1()
.border_color(if chosen {
cx.theme().primary
} else {
cx.theme().border
})
.child(
div()
.size(ring * 0.5 * fill)
.rounded_full()
.bg(cx.theme().primary),
),
cx,
)
.debug_selector({
let (group, option) = (group.clone(), option.id.clone());
move || format!("choice-seat-{group}-{option}")
}),
)
.child(
v_flex()
.min_w_0()
.gap(tokens.spacing.xxs)
.child(
div()
.debug_selector({
let (group, option) = (group.clone(), option.id.clone());
move || format!("choice-label-{group}-{option}")
})
.text_token(tokens.typography.sm)
.text_color(cx.theme().foreground)
.child(option.label.clone()),
)
.when_some(option.description.clone(), |body, description| {
body.child(hint(description, cx))
}),
);
let row = selection_surface(row, chosen, tokens.radius.lg, tokens.spacing.xs, None, cx)
.border_1()
.border_color(cx.theme().transparent)
.focus_visible(|style| style.border_color(cx.theme().ring))
.press_release(press_id, tokens.radius.md, window, cx);
if option.disabled {
return row.disabled(true).into_any_element();
}
match handler {
Some(handler) => row
.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
.into_any_element(),
None => row.into_any_element(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToggleShape {
#[default]
Check,
Switch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToggleEvent {
Toggled {
id: SharedString,
on: bool,
},
}
#[derive(IntoElement)]
pub struct Toggle {
id: SharedString,
label: SharedString,
description: Option<SharedString>,
shape: ToggleShape,
on: bool,
disabled: bool,
on_event: Option<SharedHandler<ToggleEvent>>,
style: StyleRefinement,
}
impl Toggle {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
shape: ToggleShape::default(),
on: false,
disabled: false,
on_event: None,
style: StyleRefinement::default(),
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn shape(mut self, shape: ToggleShape) -> Self {
self.shape = shape;
self
}
pub fn on(mut self, on: bool) -> Self {
self.on = on;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_event(
mut self,
handler: impl Fn(&ToggleEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_event = Some(std::rc::Rc::new(handler));
self
}
}
impl Styled for Toggle {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Toggle {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
toggle_button(self, window, cx)
}
}
fn toggle_button(toggle: Toggle, window: &mut Window, cx: &mut App) -> gpui_base::Button {
let tokens = cx.theme().semantic_tokens();
let row_id = ElementId::from(toggle.id.clone());
let press_id = ElementId::from((row_id.clone(), "press"));
let debug_id = toggle.id.clone();
let event = ToggleEvent::Toggled {
id: toggle.id.clone(),
on: !toggle.on,
};
let travel = disclosure_progress(
gpui_base::motion::TransitionId::from(ElementId::from((row_id.clone(), "on"))),
toggle.on,
window,
cx,
);
let slot = indicator_size(cx);
let indicator = match toggle.shape {
ToggleShape::Check => check_indicator(slot, travel, cx),
ToggleShape::Switch => switch_indicator(slot, travel, cx),
};
let role = match toggle.shape {
ToggleShape::Check => Role::CheckBox,
ToggleShape::Switch => Role::Switch,
};
let row = composed_button(row_id.clone(), toggle.label.clone())
.w_full()
.justify_start()
.items_start()
.gap(tokens.spacing.sm)
.px(tokens.spacing.sm)
.py(tokens.spacing.xs)
.role(role)
.aria_toggled(if toggle.on {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
})
.debug_selector(move || format!("toggle-{debug_id}"))
.refine_style(&toggle.style)
.child(control_seat(indicator, cx))
.child(
v_flex()
.min_w_0()
.gap(tokens.spacing.xxs)
.child(
div()
.text_token(tokens.typography.sm)
.text_color(cx.theme().foreground)
.child(toggle.label.clone()),
)
.when_some(toggle.description.clone(), |body, description| {
body.child(hint(description, cx))
}),
)
.quiet_press_surface(press_id, tokens.radius.md, window, cx);
let handler = toggle.on_event.filter(|_| !toggle.disabled);
row.when(toggle.disabled, |row| row.disabled(true))
.when_some(handler, |row, handler| {
row.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
})
}
fn check_indicator(slot: Pixels, travel: f32, cx: &App) -> AnyElement {
let tokens = cx.theme().semantic_tokens();
div()
.flex()
.items_center()
.justify_center()
.size(slot)
.rounded(tokens.radius.sm)
.border_1()
.border_color(if travel > 0.5 {
cx.theme().primary
} else {
cx.theme().border
})
.bg(cx.theme().primary.opacity(travel))
.child(
Icon::new(IconName::Check)
.xsmall()
.text_color(cx.theme().primary_foreground.opacity(travel)),
)
.into_any_element()
}
fn switch_indicator(slot: Pixels, travel: f32, cx: &App) -> AnyElement {
let tokens = cx.theme().semantic_tokens();
let track_width = switch_track_width(slot);
let track_height = slot * 0.75;
let inset = tokens.spacing.xxs;
let thumb = track_height - inset * 2.0;
div()
.relative()
.flex()
.items_center()
.w(track_width)
.h(track_height)
.rounded_full()
.bg(cx.theme().border.opacity(1.0 - travel))
.child(
div()
.absolute()
.inset_0()
.rounded_full()
.bg(cx.theme().primary.opacity(travel)),
)
.child(
div()
.absolute()
.ml(inset + (track_width - thumb - inset * 2.0) * travel)
.size(thumb)
.rounded_full()
.bg(cx.theme().background),
)
.into_any_element()
}