toggle: Add Toggle and ToggleGroup. (#691)

- Improve `Icon` to inherit parent element font size by default.

<img width="777" alt="image"
src="https://github.com/user-attachments/assets/1a0ee94e-46ef-4b02-9538-09880586d3f2"
/>
<img width="777" alt="image"
src="https://github.com/user-attachments/assets/4ecb016f-2e7c-4266-9a25-eae2566b2844"
/>
This commit is contained in:
Jason Lee 2025-03-06 19:10:28 +08:00 committed by GitHub
parent d009b37bf1
commit c9c6449d24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 612 additions and 14 deletions

View file

@ -37,6 +37,7 @@ UI components for building fantastic desktop application by using [GPUI](https:/
- Breadcrumb
- Badge
- TextView (Markdown, Simple HTML) to native rendering, syntax highlighting.
- Toggle, ToggleGroup
## Showcase

View file

@ -0,0 +1,35 @@
use gpui::*;
use story::{Assets, ButtonStory};
pub struct Example {
root: Entity<ButtonStory>,
}
impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let root = ButtonStory::view(window, cx);
Self { root }
}
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().p_4().size_full().child(self.root.clone())
}
}
fn main() {
let app = Application::new().with_assets(Assets);
app.run(move |cx| {
story::init(cx);
cx.activate(true);
story::create_new_window("Button Example", Example::view, cx);
});
}

View file

@ -0,0 +1,35 @@
use gpui::*;
use story::{Assets, ToggleStory};
pub struct Example {
root: Entity<ToggleStory>,
}
impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let root = ToggleStory::view(window, cx);
Self { root }
}
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().p_4().size_full().child(self.root.clone())
}
}
fn main() {
let app = Application::new().with_assets(Assets);
app.run(move |cx| {
story::init(cx);
cx.activate(true);
story::create_new_window("Toggle Example", Example::view, cx);
});
}

View file

@ -19,6 +19,7 @@ mod table_story;
mod tabs_story;
mod text_story;
mod title_bar;
mod toggle_story;
mod tooltip_story;
mod webview_story;
@ -30,6 +31,7 @@ pub use calendar_story::CalendarStory;
pub use dropdown_story::DropdownStory;
pub use form_story::FormStory;
pub use tabs_story::TabsStory;
pub use toggle_story::ToggleStory;
use gpui::{
actions, div, impl_internal_actions, prelude::FluentBuilder as _, px, size, AnyElement,

View file

@ -0,0 +1,208 @@
use gpui::{
actions, App, AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement as _,
Render, Styled as _, Window,
};
use gpui_component::{
button::{Toggle, ToggleGroup, ToggleVariants},
h_flex, v_flex, IconName, Sizable,
};
actions!(button_story, [Disabled, Loading, Selected, Compact]);
pub struct ToggleStory {
focus_handle: gpui::FocusHandle,
single_toggle: usize,
checked: Vec<bool>,
}
impl ToggleStory {
pub fn view(_: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self {
focus_handle: cx.focus_handle(),
single_toggle: 0,
checked: vec![false; 20],
})
}
}
impl super::Story for ToggleStory {
fn title() -> &'static str {
"ToggleButton"
}
fn description() -> &'static str {
""
}
fn closable() -> bool {
false
}
fn new_view(window: &mut Window, cx: &mut App) -> Entity<impl Render + Focusable> {
Self::view(window, cx)
}
}
impl Focusable for ToggleStory {
fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ToggleStory {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_6()
.child(
h_flex()
.gap_2()
.child(
Toggle::label("Single Toggle Item 1")
.id("single-toggle-item-1")
.large()
.checked(self.single_toggle == 1)
.on_change(cx.listener(|view, checked, _, cx| {
if *checked {
view.single_toggle = 1;
}
cx.notify();
})),
)
.child(
Toggle::label("Single Toggle Item 2")
.id("single-toggle-item-2")
.large()
.checked(self.single_toggle == 2)
.on_change(cx.listener(|view, checked, _, cx| {
if *checked {
view.single_toggle = 2;
}
cx.notify();
})),
)
.child(
Toggle::icon(IconName::Eye)
.id("single-toggle-item-3")
.large()
.checked(self.single_toggle == 3)
.on_change(cx.listener(|view, checked, _, cx| {
if *checked {
view.single_toggle = 3;
}
cx.notify();
})),
),
)
.child(
h_flex()
.gap_5()
.child(
ToggleGroup::new("toggle-button-group1")
.child(Toggle::icon(IconName::Bell).checked(self.checked[0]))
.child(Toggle::icon(IconName::Bot).checked(self.checked[1]))
.child(Toggle::icon(IconName::Inbox).checked(self.checked[2]))
.child(Toggle::icon(IconName::Check).checked(self.checked[3]))
.child(Toggle::label("Other").checked(self.checked[4]))
.on_change(cx.listener(|view, checkeds: &Vec<bool>, _, cx| {
view.checked[0] = checkeds[0];
view.checked[1] = checkeds[1];
view.checked[2] = checkeds[2];
view.checked[3] = checkeds[3];
cx.notify();
})),
)
.child(
ToggleGroup::new("toggle-button-group1-sm")
.small()
.child(Toggle::icon(IconName::Bell).checked(self.checked[0]))
.child(Toggle::icon(IconName::Bot).checked(self.checked[1]))
.child(Toggle::icon(IconName::Inbox).checked(self.checked[2]))
.child(Toggle::icon(IconName::Check).checked(self.checked[3]))
.child(Toggle::label("Other").checked(self.checked[4]))
.on_change(cx.listener(|view, checkeds: &Vec<bool>, _, cx| {
view.checked[0] = checkeds[0];
view.checked[1] = checkeds[1];
view.checked[2] = checkeds[2];
view.checked[3] = checkeds[3];
view.checked[4] = checkeds[4];
cx.notify();
})),
)
.child(
ToggleGroup::new("toggle-button-group1-xs")
.xsmall()
.child(Toggle::icon(IconName::Bell).checked(self.checked[0]))
.child(Toggle::icon(IconName::Bot).checked(self.checked[1]))
.child(Toggle::icon(IconName::Inbox).checked(self.checked[2]))
.child(Toggle::icon(IconName::Check).checked(self.checked[3]))
.child(Toggle::label("Other").checked(self.checked[4]))
.on_change(cx.listener(|view, checkeds: &Vec<bool>, _, cx| {
view.checked[0] = checkeds[0];
view.checked[1] = checkeds[1];
view.checked[2] = checkeds[2];
view.checked[3] = checkeds[3];
view.checked[4] = checkeds[4];
cx.notify();
})),
),
)
.child(
h_flex()
.gap_5()
.child(
ToggleGroup::new("toggle-button-group2")
.outline()
.child(Toggle::icon(IconName::Bell).checked(self.checked[0]))
.child(Toggle::icon(IconName::Bot).checked(self.checked[1]))
.child(Toggle::icon(IconName::Inbox).checked(self.checked[2]))
.child(Toggle::icon(IconName::Check).checked(self.checked[3]))
.child(Toggle::label("Other").checked(self.checked[4]))
.on_change(cx.listener(|view, checkeds: &Vec<bool>, _, cx| {
view.checked[0] = checkeds[0];
view.checked[1] = checkeds[1];
view.checked[2] = checkeds[2];
view.checked[3] = checkeds[3];
view.checked[4] = checkeds[4];
cx.notify();
})),
)
.child(
ToggleGroup::new("toggle-button-group2-sm")
.outline()
.small()
.child(Toggle::icon(IconName::Bell).checked(self.checked[0]))
.child(Toggle::icon(IconName::Bot).checked(self.checked[1]))
.child(Toggle::icon(IconName::Inbox).checked(self.checked[2]))
.child(Toggle::icon(IconName::Check).checked(self.checked[3]))
.child(Toggle::label("Other").checked(self.checked[4]))
.on_change(cx.listener(|view, checkeds: &Vec<bool>, _, cx| {
view.checked[0] = checkeds[0];
view.checked[1] = checkeds[1];
view.checked[2] = checkeds[2];
view.checked[3] = checkeds[3];
view.checked[4] = checkeds[4];
cx.notify();
})),
)
.child(
ToggleGroup::new("toggle-button-group2-xs")
.outline()
.xsmall()
.child(Toggle::icon(IconName::Bell).checked(self.checked[0]))
.child(Toggle::icon(IconName::Bot).checked(self.checked[1]))
.child(Toggle::icon(IconName::Inbox).checked(self.checked[2]))
.child(Toggle::icon(IconName::Check).checked(self.checked[3]))
.child(Toggle::label("Other").checked(self.checked[4]))
.on_change(cx.listener(|view, checkeds: &Vec<bool>, _, cx| {
view.checked[0] = checkeds[0];
view.checked[1] = checkeds[1];
view.checked[2] = checkeds[2];
view.checked[3] = checkeds[3];
view.checked[4] = checkeds[4];
cx.notify();
})),
),
)
}
}

View file

@ -611,19 +611,16 @@ impl ButtonVariant {
fn selected(&self, cx: &mut App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().primary_active,
ButtonVariant::Secondary | ButtonVariant::Outline => cx.theme().secondary_active,
ButtonVariant::Ghost => {
if cx.theme().mode.is_dark() {
cx.theme().secondary.lighten(0.2).opacity(0.8)
} else {
cx.theme().secondary.darken(0.2).opacity(0.8)
}
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
cx.theme().secondary_active
}
ButtonVariant::Danger => cx.theme().danger_active,
ButtonVariant::Link => cx.theme().transparent,
ButtonVariant::Text => cx.theme().transparent,
ButtonVariant::Custom(colors) => colors.active,
};
}
.opacity(0.5);
let border = self.border_color(cx);
let fg = match self {
ButtonVariant::Link => cx.theme().link_active,

View file

@ -15,13 +15,13 @@ pub struct ButtonGroup {
pub base: Div,
id: ElementId,
children: Vec<Button>,
multiple: bool,
disabled: bool,
pub(super) multiple: bool,
pub(super) disabled: bool,
// The button props
compact: Option<bool>,
variant: Option<ButtonVariant>,
size: Option<Size>,
pub(super) compact: Option<bool>,
pub(super) variant: Option<ButtonVariant>,
pub(super) size: Option<Size>,
on_click: Option<Box<dyn Fn(&Vec<usize>, &mut Window, &mut App) + 'static>>,
}
@ -55,6 +55,12 @@ impl ButtonGroup {
self
}
/// Adds multiple buttons as children to the ButtonGroup.
pub fn children(mut self, children: impl IntoIterator<Item = Button>) -> Self {
self.children.extend(children);
self
}
/// With the multiple selection mode.
pub fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;

View file

@ -1,7 +1,9 @@
mod button;
mod button_group;
mod dropdown_button;
mod toggle;
pub use button::*;
pub use button_group::*;
pub use dropdown_button::*;
pub use toggle::*;

View file

@ -0,0 +1,308 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
div, prelude::FluentBuilder as _, AnyElement, App, Div, ElementId, InteractiveElement,
IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled as _,
Window,
};
use smallvec::{smallvec, SmallVec};
use crate::{h_flex, ActiveTheme, Disableable, Icon, Sizable, Size};
#[derive(Default, Copy, Debug, Clone, PartialEq, Eq, Hash)]
pub enum ToggleVariant {
#[default]
Ghost,
Outline,
}
pub trait ToggleVariants: Sized {
fn with_variant(self, variant: ToggleVariant) -> Self;
fn ghost(self) -> Self {
self.with_variant(ToggleVariant::Ghost)
}
fn outline(self) -> Self {
self.with_variant(ToggleVariant::Outline)
}
}
#[derive(IntoElement)]
pub struct Toggle {
base: Div,
checked: bool,
size: Size,
variant: ToggleVariant,
disabled: bool,
children: SmallVec<[AnyElement; 1]>,
}
#[derive(IntoElement)]
pub struct InteractiveToggle {
id: ElementId,
toggle: Toggle,
on_change: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
}
impl Toggle {
fn new() -> Self {
Self {
base: div(),
checked: false,
size: Size::default(),
variant: ToggleVariant::default(),
disabled: false,
children: smallvec![],
}
}
pub fn label(label: impl Into<SharedString>) -> Self {
Self::new().child(label.into())
}
pub fn icon(icon: impl Into<Icon>) -> Self {
Self::new().child(icon.into())
}
pub fn id(self, id: impl Into<ElementId>) -> InteractiveToggle {
InteractiveToggle {
id: id.into(),
toggle: self,
on_change: None,
}
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
}
impl ToggleVariants for Toggle {
fn with_variant(mut self, variant: ToggleVariant) -> Self {
self.variant = variant;
self
}
}
impl Disableable for Toggle {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for Toggle {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl ParentElement for Toggle {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Toggle {
fn render(self, _: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
let checked = self.checked;
let disabled = self.disabled;
let hoverable = !disabled && !checked;
self.base
.flex()
.flex_row()
.items_center()
.justify_center()
.cursor_pointer()
.map(|this| match self.size {
Size::XSmall => this.min_w_5().h_5().px_0p5().text_xs(),
Size::Small => this.min_w_6().h_6().px_1().text_sm(),
Size::Large => this.min_w_9().h_9().px_3().text_lg(),
_ => this.min_w_8().h_8().px_2(),
})
.rounded(cx.theme().radius)
.when(self.variant == ToggleVariant::Outline, |this| {
this.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.when(cx.theme().shadow, |this| this.shadow_sm())
})
.when(hoverable, |this| {
this.hover(|this| {
this.bg(cx.theme().accent)
.text_color(cx.theme().accent_foreground)
})
})
.when(checked, |this| {
this.bg(cx.theme().accent)
.text_color(cx.theme().accent_foreground)
})
.children(self.children)
}
}
impl InteractiveToggle {
/// Sets the callback to be invoked when the toggle is clicked.
///
/// The first argument is a boolean indicating whether the toggle is checked.
pub fn on_change(mut self, on_change: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Box::new(on_change));
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.toggle.checked = checked;
self
}
}
impl Sizable for InteractiveToggle {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.toggle = self.toggle.with_size(size);
self
}
}
impl ToggleVariants for InteractiveToggle {
fn with_variant(mut self, variant: ToggleVariant) -> Self {
self.toggle.variant = variant;
self
}
}
impl Disableable for InteractiveToggle {
fn disabled(mut self, disabled: bool) -> Self {
self.toggle.disabled = disabled;
self
}
}
impl ParentElement for InteractiveToggle {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.toggle.extend(elements);
}
}
impl RenderOnce for InteractiveToggle {
fn render(self, _: &mut Window, _: &mut App) -> impl gpui::IntoElement {
let checked = self.toggle.checked;
let disabled = self.toggle.disabled;
div()
.id(self.id)
.child(self.toggle)
.when(!disabled, |this| {
this.when_some(self.on_change, |this, on_change| {
this.on_click(move |_, window, cx| on_change(&!checked, window, cx))
})
})
}
}
#[derive(IntoElement)]
pub struct ToggleGroup {
id: ElementId,
size: Size,
variant: ToggleVariant,
disabled: bool,
items: Vec<Toggle>,
on_change: Option<Rc<dyn Fn(&Vec<bool>, &mut Window, &mut App) + 'static>>,
}
impl ToggleGroup {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
size: Size::default(),
variant: ToggleVariant::default(),
disabled: false,
items: Vec::new(),
on_change: None,
}
}
/// Add a child [`Toggle`] to the group.
pub fn child(mut self, toggle: impl Into<Toggle>) -> Self {
self.items.push(toggle.into());
self
}
/// Add multiple [`Toggle`]s to the group.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Toggle>>) -> Self {
self.items.extend(children.into_iter().map(Into::into));
self
}
/// Set the callback to be called when the toggle group changes.
///
/// The `&Vec<bool>` parameter represents the check state of each [`Toggle`] in the group.
pub fn on_change(
mut self,
on_change: impl Fn(&Vec<bool>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(on_change));
self
}
}
impl Sizable for ToggleGroup {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl ToggleVariants for ToggleGroup {
fn with_variant(mut self, variant: ToggleVariant) -> Self {
self.variant = variant;
self
}
}
impl Disableable for ToggleGroup {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl RenderOnce for ToggleGroup {
fn render(self, _: &mut Window, _: &mut App) -> impl gpui::IntoElement {
let disabled = self.disabled;
let checks = self
.items
.iter()
.map(|item| item.checked)
.collect::<Vec<bool>>();
let state = Rc::new(Cell::new(None));
h_flex()
.id(self.id)
.gap_1()
.children(self.items.into_iter().enumerate().map({
|(ix, item)| {
let state = state.clone();
item.disabled(disabled)
.id(ix)
.with_size(self.size)
.with_variant(self.variant)
.on_change(move |_, _, _| {
state.set(Some(ix));
})
}
}))
.when(!disabled, |this| {
this.when_some(self.on_change, |this, on_change| {
this.on_click(move |_, window, cx| {
if let Some(ix) = state.get() {
let mut checks = checks.clone();
checks[ix] = !checks[ix];
on_change(&checks, window, cx);
}
})
})
})
}
}

View file

@ -280,9 +280,11 @@ impl Sizable for Icon {
impl RenderOnce for Icon {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| window.text_style().color);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
self.base
.text_color(text_color)
.size(text_size)
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
@ -301,12 +303,14 @@ impl From<Icon> for AnyElement {
}
impl Render for Icon {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().foreground);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
svg()
.flex_none()
.text_color(text_color)
.size(text_size)
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),