Skip to main content

iced_shadcn/
button.rs

1use iced::alignment::{Horizontal, Vertical};
2use iced::border::Border;
3use iced::widget::text::IntoFragment;
4use iced::widget::{
5    button as button_widget, button as iced_button, container, stack, text as iced_text,
6};
7use iced::{Background, Color, Element, Length, Shadow};
8
9use crate::spinner::{Spinner, SpinnerSize, spinner};
10use crate::theme::Theme;
11use crate::tokens::{
12    AccentColor, accent_color, accent_foreground, accent_soft, accent_soft_foreground, accent_text,
13    is_dark,
14};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
17pub enum ButtonVariant {
18    #[default]
19    Default,
20    Destructive,
21    Outline,
22    Secondary,
23    Ghost,
24    Link,
25
26    // Legacy mapping
27    Classic,
28    Solid,
29    Soft,
30    Surface,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
34pub enum ButtonSize {
35    Size1,
36    #[default]
37    Size2,
38    Size3,
39    Size4,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
43pub enum ButtonRadius {
44    None,
45    Small,
46    #[default]
47    Medium,
48    Large,
49    Full,
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
53pub enum ButtonJustify {
54    #[default]
55    Center,
56    Start,
57    Between,
58}
59
60#[derive(Clone, Copy, Debug)]
61pub struct ButtonProps {
62    pub variant: ButtonVariant,
63    pub size: ButtonSize,
64    pub color: AccentColor,
65    pub radius: Option<ButtonRadius>,
66    pub justify: ButtonJustify,
67    pub high_contrast: bool,
68    pub loading: bool,
69    pub disabled: bool,
70}
71
72impl Default for ButtonProps {
73    fn default() -> Self {
74        Self {
75            variant: ButtonVariant::Default,
76            size: ButtonSize::Size2,
77            color: AccentColor::Gray,
78            radius: None,
79            justify: ButtonJustify::Center,
80            high_contrast: false,
81            loading: false,
82            disabled: false,
83        }
84    }
85}
86
87impl ButtonProps {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    pub fn variant(mut self, variant: ButtonVariant) -> Self {
93        self.variant = variant;
94        self
95    }
96
97    pub fn size(mut self, size: ButtonSize) -> Self {
98        self.size = size;
99        self
100    }
101
102    pub fn color(mut self, color: AccentColor) -> Self {
103        self.color = color;
104        self
105    }
106
107    pub fn radius(mut self, radius: ButtonRadius) -> Self {
108        self.radius = Some(radius);
109        self
110    }
111
112    pub fn justify(mut self, justify: ButtonJustify) -> Self {
113        self.justify = justify;
114        self
115    }
116
117    pub fn high_contrast(mut self, high_contrast: bool) -> Self {
118        self.high_contrast = high_contrast;
119        self
120    }
121
122    pub fn loading(mut self, loading: bool) -> Self {
123        self.loading = loading;
124        self
125    }
126
127    pub fn disabled(mut self, disabled: bool) -> Self {
128        self.disabled = disabled;
129        self
130    }
131}
132
133impl ButtonSize {
134    fn padding(self) -> [f32; 2] {
135        match self {
136            ButtonSize::Size1 => [6.0, 12.0],
137            ButtonSize::Size2 => [8.0, 16.0],
138            ButtonSize::Size3 => [10.0, 24.0],
139            ButtonSize::Size4 => [12.0, 28.0],
140        }
141    }
142
143    fn height(self) -> f32 {
144        match self {
145            ButtonSize::Size1 => 32.0,
146            ButtonSize::Size2 => 36.0,
147            ButtonSize::Size3 => 40.0,
148            ButtonSize::Size4 => 48.0,
149        }
150    }
151
152    fn text_size(self) -> u32 {
153        match self {
154            ButtonSize::Size1 => 14,
155            ButtonSize::Size2 => 14,
156            ButtonSize::Size3 => 14,
157            ButtonSize::Size4 => 16,
158        }
159    }
160}
161
162pub fn button<'a, Message: Clone + 'a>(
163    label: impl IntoFragment<'a>,
164    on_press: Option<Message>,
165    props: ButtonProps,
166    theme: &Theme,
167) -> button_widget::Button<'a, Message> {
168    let content = iced_text(label).size(props.size.text_size());
169    button_content(content, on_press, props, theme)
170}
171
172pub fn button_content<'a, Message: Clone + 'a>(
173    content: impl Into<Element<'a, Message>>,
174    on_press: Option<Message>,
175    props: ButtonProps,
176    theme: &Theme,
177) -> button_widget::Button<'a, Message> {
178    button_content_aligned(content, on_press, props, theme, false)
179}
180
181fn button_content_aligned<'a, Message: Clone + 'a>(
182    content: impl Into<Element<'a, Message>>,
183    on_press: Option<Message>,
184    props: ButtonProps,
185    theme: &Theme,
186    center_x: bool,
187) -> button_widget::Button<'a, Message> {
188    let content: Element<'a, Message> = if props.loading {
189        loading_overlay(content.into(), props, theme)
190    } else {
191        content.into()
192    };
193    let mut wrapper = container(content)
194        .height(Length::Fixed(props.size.height()))
195        .align_y(Vertical::Center);
196    if center_x {
197        wrapper = wrapper.width(Length::Fill).align_x(Horizontal::Center);
198    }
199    let content: Element<'a, Message> = wrapper.into();
200
201    let mut widget = iced_button(content)
202        .padding(props.size.padding())
203        .height(Length::Fixed(props.size.height()));
204
205    let disabled = props.disabled || props.loading || on_press.is_none();
206    if let Some(message) = on_press
207        && !disabled
208    {
209        widget = widget.on_press(message);
210    }
211
212    let theme = theme.clone();
213    widget.style(move |_iced_theme, status| button_style(&theme, props, status))
214}
215
216pub fn icon_button<'a, Message: Clone + 'a>(
217    content: impl Into<Element<'a, Message>>,
218    on_press: Option<Message>,
219    props: ButtonProps,
220    theme: &Theme,
221) -> button_widget::Button<'a, Message> {
222    let size = props.size.height();
223    button_content_aligned(content, on_press, props, theme, true)
224        .padding(0)
225        .width(Length::Fixed(size))
226        .height(Length::Fixed(size))
227}
228
229fn loading_overlay<'a, Message: Clone + 'a>(
230    content: Element<'a, Message>,
231    props: ButtonProps,
232    theme: &Theme,
233) -> Element<'a, Message> {
234    let spinner_size = match props.size {
235        ButtonSize::Size1 => SpinnerSize::Size1,
236        ButtonSize::Size2 => SpinnerSize::Size2,
237        ButtonSize::Size3 | ButtonSize::Size4 => SpinnerSize::Size3,
238    };
239    let spinner_color = accent_text(&theme.palette, props.color);
240    let spinner = spinner(Spinner::new(theme).size(spinner_size).color(spinner_color));
241    let spinner_layer = container(spinner)
242        .width(Length::Fill)
243        .height(Length::Fill)
244        .center_x(Length::Fill)
245        .center_y(Length::Fill);
246    stack![container(content), spinner_layer]
247        .width(Length::Fill)
248        .height(Length::Fill)
249        .into()
250}
251
252fn button_radius(theme: &Theme, props: ButtonProps) -> f32 {
253    match props.radius {
254        Some(ButtonRadius::None) => 0.0,
255        Some(ButtonRadius::Small) => theme.radius.sm,
256        Some(ButtonRadius::Medium) => theme.radius.md,
257        Some(ButtonRadius::Large) => theme.radius.lg,
258        Some(ButtonRadius::Full) => 9999.0,
259        None => theme.radius.sm,
260    }
261}
262
263use crate::tokens::mix;
264
265fn apply_opacity(mut color: Color, opacity: f32) -> Color {
266    color.a *= opacity;
267    color
268}
269
270pub(crate) fn button_style(
271    theme: &Theme,
272    props: ButtonProps,
273    status: button_widget::Status,
274) -> button_widget::Style {
275    let palette = theme.palette;
276    let radius = button_radius(theme, props);
277
278    let accent = accent_color(&palette, props.color);
279    let accent_fg = accent_foreground(&palette, props.color);
280    let accent_txt = accent_text(&palette, props.color);
281    let soft_bg = accent_soft(&palette, props.color);
282    let soft_fg = accent_soft_foreground(&palette, props.color);
283
284    let (mut background, mut text_color, mut border_color) = match props.variant {
285        ButtonVariant::Default | ButtonVariant::Classic | ButtonVariant::Solid => {
286            (Some(Background::Color(accent)), accent_fg, accent)
287        }
288        ButtonVariant::Secondary => {
289            let color = palette.secondary;
290            let fg = palette.secondary_foreground;
291            (Some(Background::Color(color)), fg, color)
292        }
293        ButtonVariant::Destructive => {
294            let color = palette.destructive;
295            let fg = palette.destructive_foreground;
296            (Some(Background::Color(color)), fg, color)
297        }
298        ButtonVariant::Soft => (Some(Background::Color(soft_bg)), soft_fg, soft_bg),
299        ButtonVariant::Surface => (
300            Some(Background::Color(palette.background)),
301            accent_txt,
302            palette.border,
303        ),
304        // shadcn-svelte: outline defaults to foreground text on background with input border.
305        ButtonVariant::Outline => (None, palette.foreground, palette.input),
306        // shadcn-svelte: ghost defaults to foreground text and transparent background.
307        ButtonVariant::Ghost => (None, palette.foreground, Color::TRANSPARENT),
308        ButtonVariant::Link => (None, accent, Color::TRANSPARENT),
309    };
310
311    if props.high_contrast {
312        text_color = palette.foreground;
313    }
314
315    match status {
316        button_widget::Status::Hovered => {
317            background = match props.variant {
318                ButtonVariant::Default | ButtonVariant::Classic | ButtonVariant::Solid => {
319                    Some(Background::Color(mix(accent, palette.background, 0.1)))
320                }
321                ButtonVariant::Secondary => {
322                    if is_dark(&palette) {
323                        Some(Background::Color(mix(
324                            palette.secondary,
325                            palette.background,
326                            0.2,
327                        )))
328                    } else {
329                        // In light theme, mix with foreground to darken it noticeably
330                        Some(Background::Color(mix(
331                            palette.secondary,
332                            palette.foreground,
333                            0.1,
334                        )))
335                    }
336                }
337                ButtonVariant::Destructive => Some(Background::Color(mix(
338                    palette.destructive,
339                    palette.background,
340                    0.2,
341                ))),
342                ButtonVariant::Soft | ButtonVariant::Surface => {
343                    if is_dark(&palette) {
344                        Some(Background::Color(palette.muted))
345                    } else {
346                        Some(Background::Color(apply_opacity(palette.foreground, 0.10)))
347                    }
348                }
349                // shadcn-svelte: outline/ghost hover -> accent background + accent foreground.
350                ButtonVariant::Outline | ButtonVariant::Ghost => {
351                    if is_dark(&palette) {
352                        text_color = palette.accent_foreground;
353                        Some(Background::Color(palette.accent))
354                    } else {
355                        text_color = palette.foreground;
356                        Some(Background::Color(apply_opacity(palette.foreground, 0.10)))
357                    }
358                }
359                ButtonVariant::Link => {
360                    text_color = mix(text_color, palette.foreground, 0.2);
361                    None
362                }
363            };
364        }
365        button_widget::Status::Pressed => {
366            background = match props.variant {
367                ButtonVariant::Default | ButtonVariant::Classic | ButtonVariant::Solid => {
368                    Some(Background::Color(mix(accent, palette.background, 0.2)))
369                }
370                ButtonVariant::Secondary => {
371                    if is_dark(&palette) {
372                        Some(Background::Color(mix(
373                            palette.secondary,
374                            palette.background,
375                            0.4,
376                        )))
377                    } else {
378                        // In light theme, mix with foreground more to darken it strongly
379                        Some(Background::Color(mix(
380                            palette.secondary,
381                            palette.foreground,
382                            0.25,
383                        )))
384                    }
385                }
386                ButtonVariant::Destructive => Some(Background::Color(mix(
387                    palette.destructive,
388                    palette.background,
389                    0.3,
390                ))),
391                ButtonVariant::Soft
392                | ButtonVariant::Surface
393                | ButtonVariant::Outline
394                | ButtonVariant::Ghost => {
395                    if is_dark(&palette) {
396                        Some(Background::Color(palette.muted))
397                    } else {
398                        Some(Background::Color(apply_opacity(palette.foreground, 0.16)))
399                    }
400                }
401                ButtonVariant::Link => None,
402            };
403        }
404        button_widget::Status::Disabled => {
405            text_color = palette.muted_foreground;
406            background = Some(Background::Color(palette.muted));
407            border_color = palette.border;
408        }
409        button_widget::Status::Active => {}
410    }
411
412    button_widget::Style {
413        background,
414        text_color,
415        border: Border {
416            radius: radius.into(),
417            width: if matches!(props.variant, ButtonVariant::Outline) {
418                1.0
419            } else {
420                0.0
421            },
422            color: border_color,
423        },
424        shadow: Shadow::default(),
425        snap: true,
426    }
427}