1use iced::widget::{self, button::Status, row, text};
4use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
5
6use crate::icon::{Glyph, opacity, themed};
7use crate::natural::natural;
8use crate::theme::{Tokens, fade, mix, radius, text_size};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub enum Variant {
13 #[default]
14 Primary,
15 Secondary,
16 Destructive,
17 Outline,
18 Ghost,
19 Link,
20}
21
22impl Variant {
23 pub const ALL: [Variant; 6] = [
24 Variant::Primary,
25 Variant::Secondary,
26 Variant::Destructive,
27 Variant::Outline,
28 Variant::Ghost,
29 Variant::Link,
30 ];
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35pub enum Size {
36 Sm,
37 #[default]
38 Md,
39 Lg,
40 #[deprecated(note = "use icon_button")]
42 Icon,
43}
44
45impl Size {
46 pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
47
48 #[allow(deprecated)]
50 pub const fn metrics(self) -> Metrics {
51 match self {
52 Size::Sm => Metrics {
53 height: 32.0,
54 padding_x: 12.0,
55 text: text_size::SM,
56 icon: 14.0,
57 },
58 Size::Md => Metrics {
59 height: 36.0,
60 padding_x: 16.0,
61 text: text_size::SM,
62 icon: 16.0,
63 },
64 Size::Lg => Metrics {
65 height: 40.0,
66 padding_x: 24.0,
67 text: text_size::MD,
68 icon: 18.0,
69 },
70 Size::Icon => Metrics {
71 height: 36.0,
72 padding_x: 0.0,
73 text: text_size::SM,
74 icon: 16.0,
75 },
76 }
77 }
78}
79
80pub fn padding_x(size: Size, variant: Variant) -> f32 {
83 if variant == Variant::Link {
84 return 0.0;
85 }
86 size.metrics().padding_x
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct Metrics {
92 pub height: f32,
93 pub padding_x: f32,
94 pub text: f32,
95 pub icon: f32,
96}
97
98#[derive(Debug)]
102pub struct Button<'a, Message> {
103 label: Option<text::Fragment<'a>>,
104 leading: Option<Glyph>,
105 trailing: Option<Glyph>,
106 variant: Variant,
107 size: Size,
108 width: Length,
109 on_press: Option<Message>,
110}
111
112pub fn button<'a, Message>(label: impl text::IntoFragment<'a>) -> Button<'a, Message> {
114 Button {
115 label: Some(label.into_fragment()),
116 leading: None,
117 trailing: None,
118 variant: Variant::default(),
119 size: Size::default(),
120 width: Length::Shrink,
121 on_press: None,
122 }
123}
124
125#[cfg(feature = "icon-button")]
126pub use super::icon_button::icon_button;
127
128impl<'a, Message> Button<'a, Message> {
129 pub fn variant(mut self, variant: Variant) -> Self {
130 self.variant = variant;
131 self
132 }
133
134 pub fn size(mut self, size: Size) -> Self {
135 self.size = size;
136 self
137 }
138
139 pub fn icon(mut self, glyph: Glyph) -> Self {
141 self.leading = Some(glyph);
142 self
143 }
144
145 pub fn trailing_icon(mut self, glyph: Glyph) -> Self {
147 self.trailing = Some(glyph);
148 self
149 }
150
151 pub fn width(mut self, width: impl Into<Length>) -> Self {
152 self.width = width.into();
153 self
154 }
155
156 pub fn on_press(mut self, message: Message) -> Self {
157 self.on_press = Some(message);
158 self
159 }
160
161 pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
162 self.on_press = message;
163 self
164 }
165
166 pub fn is_enabled(&self) -> bool {
167 self.on_press.is_some()
168 }
169}
170
171impl<'a, Message: Clone + 'a> From<Button<'a, Message>> for Element<'a, Message> {
172 fn from(button: Button<'a, Message>) -> Self {
173 let metrics = button.size.metrics();
174 let variant = button.variant;
175 let enabled = button.is_enabled();
176
177 let glyph = move |glyph: Glyph| -> Element<'a, Message> {
178 themed(glyph, metrics.icon, opacity(enabled), move |theme| {
179 let tokens = Tokens::of(theme);
180 if variant == Variant::Link {
181 return tokens.foreground;
182 }
183 colours(&tokens, variant, status_for(enabled)).foreground
184 })
185 .into()
186 };
187
188 let mut content = row![].spacing(8).align_y(Alignment::Center);
189 if let Some(leading) = button.leading {
190 content = content.push(glyph(leading));
191 }
192 if let Some(label) = button.label {
193 content = content.push(
194 text(label)
195 .size(metrics.text)
196 .wrapping(text::Wrapping::None),
197 );
198 }
199 if let Some(trailing) = button.trailing {
200 content = content.push(glyph(trailing));
201 }
202
203 #[allow(deprecated)]
204 let (width, padding) = match button.size {
205 Size::Icon => (
206 Length::Fixed(metrics.height),
207 Padding::from((metrics.height - metrics.icon) / 2.0),
208 ),
209 _ => (
210 button.width,
211 Padding::from([0.0, padding_x(button.size, variant)]),
212 ),
213 };
214
215 natural(
216 widget::button(
217 widget::container(content)
218 .height(Length::Fill)
219 .align_y(Alignment::Center)
220 .align_x(Alignment::Center)
221 .width(Length::Fill),
222 )
223 .width(width)
224 .height(Length::Fixed(metrics.height))
225 .padding(padding)
226 .on_press_maybe(button.on_press)
227 .style(move |theme, status| style(&Tokens::of(theme), variant, status)),
228 )
229 }
230}
231
232fn status_for(enabled: bool) -> Status {
233 if enabled {
234 Status::Active
235 } else {
236 Status::Disabled
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq)]
242pub struct Colours {
243 pub background: Option<iced::Color>,
244 pub foreground: iced::Color,
245 pub border: Option<iced::Color>,
246}
247
248pub fn colours(tokens: &Tokens, variant: Variant, status: Status) -> Colours {
250 let hover = matches!(status, Status::Hovered | Status::Pressed);
251 let pressed = status == Status::Pressed;
252 let emphasise = |base: iced::Color| {
253 let amount = if pressed { 0.2 } else { 0.1 };
254 let target = if tokens.is_dark {
255 iced::Color::BLACK
256 } else {
257 iced::Color::WHITE
258 };
259 if hover {
260 mix(base, target, amount)
261 } else {
262 base
263 }
264 };
265
266 let colours = match variant {
267 Variant::Primary => Colours {
268 background: Some(emphasise(tokens.primary)),
269 foreground: tokens.primary_foreground,
270 border: None,
271 },
272 Variant::Secondary => Colours {
273 background: Some(if hover {
274 mix(tokens.secondary, tokens.border, 0.5)
275 } else {
276 tokens.secondary
277 }),
278 foreground: tokens.secondary_foreground,
279 border: None,
280 },
281 Variant::Destructive => Colours {
282 background: Some(emphasise(tokens.destructive)),
283 foreground: tokens.destructive_foreground,
284 border: None,
285 },
286 Variant::Outline => Colours {
287 background: Some(if hover {
288 tokens.accent
289 } else {
290 tokens.background
291 }),
292 foreground: tokens.foreground,
293 border: Some(tokens.border),
294 },
295 Variant::Ghost => Colours {
296 background: hover.then_some(tokens.accent),
297 foreground: tokens.foreground,
298 border: None,
299 },
300 Variant::Link => Colours {
301 background: None,
302 foreground: if hover {
303 mix(tokens.primary, tokens.foreground, 0.3)
304 } else {
305 tokens.primary
306 },
307 border: None,
308 },
309 };
310
311 if status != Status::Disabled {
312 return colours;
313 }
314 Colours {
315 background: colours.background.map(|c| fade(c, 0.5)),
316 foreground: fade(colours.foreground, 0.5),
317 border: colours.border.map(|c| fade(c, 0.5)),
318 }
319}
320
321pub fn style(tokens: &Tokens, variant: Variant, status: Status) -> widget::button::Style {
323 let colours = colours(tokens, variant, status);
324 widget::button::Style {
325 background: colours.background.map(Background::Color),
326 text_color: colours.foreground,
327 border: Border {
328 color: colours.border.unwrap_or(iced::Color::TRANSPARENT),
329 width: if colours.border.is_some() { 1.0 } else { 0.0 },
330 radius: radius::MD.into(),
331 },
332 shadow: Shadow::default(),
333 snap: true,
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::theme::{dark, light};
341
342 const STATES: [Status; 4] = [
343 Status::Active,
344 Status::Hovered,
345 Status::Pressed,
346 Status::Disabled,
347 ];
348
349 #[test]
350 fn default_builder_is_primary_medium_and_disabled() {
351 let b: Button<'_, ()> = button("Save");
352 assert_eq!(b.variant, Variant::Primary);
353 assert_eq!(b.size, Size::Md);
354 assert!(!b.is_enabled());
355 }
356
357 #[test]
358 fn links_have_no_side_padding_and_the_rest_keep_their_size() {
359 for size in Size::ALL {
360 assert_eq!(padding_x(size, Variant::Link), 0.0);
361 for variant in Variant::ALL.into_iter().filter(|v| *v != Variant::Link) {
362 assert_eq!(padding_x(size, variant), size.metrics().padding_x);
363 }
364 }
365 }
366
367 #[test]
368 fn on_press_enables_and_on_press_maybe_none_disables() {
369 assert!(button::<u8>("Go").on_press(1).is_enabled());
370 assert!(
371 !button::<u8>("Go")
372 .on_press(1)
373 .on_press_maybe(None)
374 .is_enabled()
375 );
376 }
377
378 #[test]
379 fn sizes_grow_monotonically() {
380 let heights = [Size::Sm, Size::Md, Size::Lg].map(|s| s.metrics().height);
381 assert!(heights.windows(2).all(|w| w[0] < w[1]));
382 }
383
384 #[test]
385 fn hover_changes_background_for_filled_variants() {
386 let tokens = Tokens::of(&light());
387 for variant in [
388 Variant::Primary,
389 Variant::Secondary,
390 Variant::Destructive,
391 Variant::Outline,
392 Variant::Ghost,
393 ] {
394 let active = colours(&tokens, variant, Status::Active);
395 let hovered = colours(&tokens, variant, Status::Hovered);
396 assert_ne!(active.background, hovered.background, "{variant:?}");
397 }
398 }
399
400 #[test]
401 fn pressed_is_stronger_than_hovered_for_primary() {
402 let tokens = Tokens::of(&light());
403 let hovered = colours(&tokens, Variant::Primary, Status::Hovered);
404 let pressed = colours(&tokens, Variant::Primary, Status::Pressed);
405 assert_ne!(hovered.background, pressed.background);
406 }
407
408 #[test]
409 fn disabled_halves_alpha_for_every_variant() {
410 for theme in [light(), dark()] {
411 let tokens = Tokens::of(&theme);
412 for variant in Variant::ALL {
413 let active = colours(&tokens, variant, Status::Active);
414 let disabled = colours(&tokens, variant, Status::Disabled);
415 assert!((disabled.foreground.a - active.foreground.a * 0.5).abs() < 1e-6);
416 }
417 }
418 }
419
420 #[test]
421 fn only_outline_has_a_border() {
422 let tokens = Tokens::of(&dark());
423 for variant in Variant::ALL {
424 for status in STATES {
425 let style = style(&tokens, variant, status);
426 assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
427 }
428 }
429 }
430
431 #[test]
432 fn ghost_and_link_are_transparent_at_rest() {
433 let tokens = Tokens::of(&light());
434 assert!(
435 colours(&tokens, Variant::Ghost, Status::Active)
436 .background
437 .is_none()
438 );
439 assert!(
440 colours(&tokens, Variant::Link, Status::Active)
441 .background
442 .is_none()
443 );
444 }
445}