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 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 ButtonVariant::Outline => (None, palette.foreground, palette.input),
306 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 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 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 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}