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