From 37aa26d6554b2fdb3df003e3c96d2040ddf9c600 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Jul 2025 19:41:34 +0800 Subject: [PATCH] check, radio: Add fade in animation to Checkbox and Radio. (#1092) --- crates/ui/src/checkbox.rs | 107 +++++++++++++++++++++++++++----------- crates/ui/src/radio.rs | 55 ++++++++++++-------- 2 files changed, 109 insertions(+), 53 deletions(-) diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index 126df45d..6ffcbcd8 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -1,11 +1,13 @@ +use std::time::Duration; + use crate::{ text::Text, v_flex, ActiveTheme, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _, }; use gpui::{ - div, prelude::FluentBuilder as _, px, relative, rems, svg, AnyElement, App, Div, ElementId, - InteractiveElement, IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, - StyleRefinement, Styled, Window, + div, prelude::FluentBuilder as _, px, relative, rems, svg, Animation, AnimationExt, AnyElement, + App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + StatefulInteractiveElement, StyleRefinement, Styled, Window, }; /// A Checkbox element. @@ -100,26 +102,81 @@ impl Sizable for Checkbox { } } +pub(crate) fn checkbox_check_icon( + id: ElementId, + size: Size, + checked: bool, + disabled: bool, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + let toggle_state = window.use_keyed_state(id, cx, |_, _| checked); + let color = if disabled { + cx.theme().primary_foreground.opacity(0.5) + } else { + cx.theme().primary_foreground + }; + + svg() + .absolute() + .top_px() + .left_px() + .map(|this| match size { + Size::XSmall => this.size_2(), + Size::Small => this.size_2p5(), + Size::Medium => this.size_3(), + Size::Large => this.size_3p5(), + _ => this.size_3(), + }) + .text_color(color) + .map(|this| match checked { + true => this.path(IconName::Check.path()), + _ => this, + }) + .map(|this| { + if !disabled && checked != *toggle_state.read(cx) { + let duration = Duration::from_secs_f64(0.25); + cx.spawn({ + let toggle_state = toggle_state.clone(); + async move |cx| { + cx.background_executor().timer(duration).await; + _ = toggle_state.update(cx, |this, _| *this = checked); + } + }) + .detach(); + + this.with_animation( + ElementId::NamedInteger("toggle".into(), checked as u64), + Animation::new(Duration::from_secs_f64(0.25)), + move |this, delta| { + this.opacity(if checked { 1.0 * delta } else { 1.0 - delta }) + }, + ) + .into_any_element() + } else { + this.into_any_element() + } + }) +} + impl RenderOnce for Checkbox { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let border_color = if self.checked { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let checked = self.checked; + let border_color = if checked { cx.theme().primary } else { cx.theme().input }; - let (color, icon_color) = if self.disabled { - ( - border_color.opacity(0.5), - cx.theme().primary_foreground.opacity(0.5), - ) + let color = if self.disabled { + border_color.opacity(0.5) } else { - (border_color, cx.theme().primary_foreground) + border_color }; let radius = cx.theme().radius.min(px(4.)); div().child( self.base - .id(self.id) + .id(self.id.clone()) .h_flex() .gap_2() .items_start() @@ -155,24 +212,14 @@ impl RenderOnce for Checkbox { false => this.bg(cx.theme().background), _ => this.bg(color), }) - .child( - svg() - .absolute() - .top_px() - .left_px() - .map(|this| match self.size { - Size::XSmall => this.size_2(), - Size::Small => this.size_2p5(), - Size::Medium => this.size_3(), - Size::Large => this.size_3p5(), - _ => this.size_3(), - }) - .text_color(icon_color) - .map(|this| match self.checked { - true => this.path(IconName::Check.path()), - _ => this, - }), - ), + .child(checkbox_check_icon( + self.id, + self.size, + checked, + self.disabled, + window, + cx, + )), ) .when(self.label.is_some() || !self.children.is_empty(), |this| { this.child( diff --git a/crates/ui/src/radio.rs b/crates/ui/src/radio.rs index 78128b22..d77702da 100644 --- a/crates/ui/src/radio.rs +++ b/crates/ui/src/radio.rs @@ -1,8 +1,11 @@ use std::rc::Rc; -use crate::{h_flex, text::Text, v_flex, ActiveTheme, AxisExt, IconName, StyledExt}; +use crate::{ + checkbox::checkbox_check_icon, h_flex, text::Text, v_flex, ActiveTheme, AxisExt, Sizable, Size, + StyledExt, +}; use gpui::{ - div, prelude::FluentBuilder, relative, svg, AnyElement, App, Axis, Div, ElementId, + div, prelude::FluentBuilder, relative, rems, AnyElement, App, Axis, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, }; @@ -19,6 +22,7 @@ pub struct Radio { children: Vec, checked: bool, disabled: bool, + size: Size, on_click: Option>, } @@ -32,6 +36,7 @@ impl Radio { children: Vec::new(), checked: false, disabled: false, + size: Size::default(), on_click: None, } } @@ -57,6 +62,13 @@ impl Radio { } } +impl Sizable for Radio { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + impl Styled for Radio { fn style(&mut self) -> &mut gpui::StyleRefinement { &mut self.style @@ -76,13 +88,16 @@ impl ParentElement for Radio { } impl RenderOnce for Radio { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let (border_color, bg) = if self.checked { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let checked = self.checked; + let disabled = self.disabled; + + let (border_color, bg) = if checked { (cx.theme().primary, cx.theme().primary) } else { (cx.theme().input, cx.theme().input.opacity(0.3)) }; - let (border_color, bg) = if self.disabled { + let (border_color, bg) = if disabled { (border_color.opacity(0.5), bg.opacity(0.5)) } else { (border_color, bg) @@ -92,7 +107,7 @@ impl RenderOnce for Radio { div().child( self.base .h_flex() - .id(self.id) + .id(self.id.clone()) .gap_x_2() .text_color(cx.theme().foreground) .items_start() @@ -101,31 +116,25 @@ impl RenderOnce for Radio { .child( div() .relative() - .size_4() + .map(|this| match self.size { + Size::XSmall => this.size_3(), + Size::Small => this.size_3p5(), + Size::Medium => this.size_4(), + Size::Large => this.size(rems(1.125)), + _ => this.size_4(), + }) .flex_shrink_0() .rounded_full() .border_1() .border_color(border_color) - .when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs()) + .when(cx.theme().shadow && !disabled, |this| this.shadow_xs()) .map(|this| match self.checked { false => this.bg(cx.theme().background), _ => this.bg(bg), }) - .child( - svg() - .absolute() - .top_px() - .left_px() - .size_3() - .text_color(bg) - .when(self.checked, |this| { - this.text_color(cx.theme().primary_foreground) - }) - .map(|this| match self.checked { - true => this.path(IconName::Check.path()), - false => this, - }), - ), + .child(checkbox_check_icon( + self.id, self.size, checked, disabled, window, cx, + )), ) .child( v_flex()