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 {
289 background: hover.then_some(tokens.accent),
290 foreground: tokens.foreground,
291 border: Some(tokens.border),
292 },
293 Variant::Ghost => Colours {
294 background: hover.then_some(tokens.accent),
295 foreground: tokens.foreground,
296 border: None,
297 },
298 Variant::Link => Colours {
299 background: None,
300 foreground: if hover {
301 mix(tokens.primary, tokens.foreground, 0.3)
302 } else {
303 tokens.primary
304 },
305 border: None,
306 },
307 };
308
309 if status != Status::Disabled {
310 return colours;
311 }
312 Colours {
313 background: colours.background.map(|c| fade(c, 0.5)),
314 foreground: fade(colours.foreground, 0.5),
315 border: colours.border.map(|c| fade(c, 0.5)),
316 }
317}
318
319pub fn style(tokens: &Tokens, variant: Variant, status: Status) -> widget::button::Style {
321 let colours = colours(tokens, variant, status);
322 widget::button::Style {
323 background: colours.background.map(Background::Color),
324 text_color: colours.foreground,
325 border: Border {
326 color: colours.border.unwrap_or(iced::Color::TRANSPARENT),
327 width: if colours.border.is_some() { 1.0 } else { 0.0 },
328 radius: radius::MD.into(),
329 },
330 shadow: Shadow::default(),
331 snap: true,
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::theme::{dark, light};
339
340 const STATES: [Status; 4] = [
341 Status::Active,
342 Status::Hovered,
343 Status::Pressed,
344 Status::Disabled,
345 ];
346
347 #[test]
348 fn default_builder_is_primary_medium_and_disabled() {
349 let b: Button<'_, ()> = button("Save");
350 assert_eq!(b.variant, Variant::Primary);
351 assert_eq!(b.size, Size::Md);
352 assert!(!b.is_enabled());
353 }
354
355 #[test]
356 fn links_have_no_side_padding_and_the_rest_keep_their_size() {
357 for size in Size::ALL {
358 assert_eq!(padding_x(size, Variant::Link), 0.0);
359 for variant in Variant::ALL.into_iter().filter(|v| *v != Variant::Link) {
360 assert_eq!(padding_x(size, variant), size.metrics().padding_x);
361 }
362 }
363 }
364
365 #[test]
366 fn on_press_enables_and_on_press_maybe_none_disables() {
367 assert!(button::<u8>("Go").on_press(1).is_enabled());
368 assert!(
369 !button::<u8>("Go")
370 .on_press(1)
371 .on_press_maybe(None)
372 .is_enabled()
373 );
374 }
375
376 #[test]
377 fn sizes_grow_monotonically() {
378 let heights = [Size::Sm, Size::Md, Size::Lg].map(|s| s.metrics().height);
379 assert!(heights.windows(2).all(|w| w[0] < w[1]));
380 }
381
382 #[test]
383 fn hover_changes_background_for_filled_variants() {
384 let tokens = Tokens::of(&light());
385 for variant in [
386 Variant::Primary,
387 Variant::Secondary,
388 Variant::Destructive,
389 Variant::Outline,
390 Variant::Ghost,
391 ] {
392 let active = colours(&tokens, variant, Status::Active);
393 let hovered = colours(&tokens, variant, Status::Hovered);
394 assert_ne!(active.background, hovered.background, "{variant:?}");
395 }
396 }
397
398 #[test]
399 fn pressed_is_stronger_than_hovered_for_primary() {
400 let tokens = Tokens::of(&light());
401 let hovered = colours(&tokens, Variant::Primary, Status::Hovered);
402 let pressed = colours(&tokens, Variant::Primary, Status::Pressed);
403 assert_ne!(hovered.background, pressed.background);
404 }
405
406 #[test]
407 fn disabled_halves_alpha_for_every_variant() {
408 for theme in [light(), dark()] {
409 let tokens = Tokens::of(&theme);
410 for variant in Variant::ALL {
411 let active = colours(&tokens, variant, Status::Active);
412 let disabled = colours(&tokens, variant, Status::Disabled);
413 assert!((disabled.foreground.a - active.foreground.a * 0.5).abs() < 1e-6);
414 }
415 }
416 }
417
418 #[test]
419 fn only_outline_has_a_border() {
420 let tokens = Tokens::of(&dark());
421 for variant in Variant::ALL {
422 for status in STATES {
423 let style = style(&tokens, variant, status);
424 assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
425 }
426 }
427 }
428
429 #[test]
430 fn outline_ghost_and_link_are_transparent_at_rest() {
431 for theme in [light(), dark()] {
432 let tokens = Tokens::of(&theme);
433 for variant in [Variant::Outline, Variant::Ghost, Variant::Link] {
434 for status in [Status::Active, Status::Disabled] {
435 let background = colours(&tokens, variant, status).background;
436 assert!(background.is_none(), "{variant:?} {status:?}");
437 }
438 }
439 }
440 }
441}