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