1use iced::widget::{self, button::Status, container, text};
9use iced::{Background, Border, Element, Length, Padding, Shadow};
10
11use crate::after_touch::after_touch;
12use crate::icon::{Glyph, opacity, themed};
13use crate::overlay::tooltip::{Position, tooltip};
14use crate::primitives::button::{self, Colours, Size};
15use crate::theme::{Tokens, fade, mix, radius};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22pub enum Variant {
23 Primary,
24 Secondary,
25 Destructive,
26 Outline,
27 #[default]
28 Ghost,
29}
30
31impl Variant {
32 pub const ALL: [Variant; 5] = [
33 Variant::Primary,
34 Variant::Secondary,
35 Variant::Destructive,
36 Variant::Outline,
37 Variant::Ghost,
38 ];
39
40 pub const fn button(self) -> button::Variant {
42 match self {
43 Variant::Primary => button::Variant::Primary,
44 Variant::Secondary => button::Variant::Secondary,
45 Variant::Destructive => button::Variant::Destructive,
46 Variant::Outline => button::Variant::Outline,
47 Variant::Ghost => button::Variant::Ghost,
48 }
49 }
50}
51
52impl From<Variant> for button::Variant {
53 fn from(variant: Variant) -> Self {
54 variant.button()
55 }
56}
57
58#[derive(Debug)]
62pub struct IconButton<'a, Message> {
63 glyph: Glyph,
64 label: Option<text::Fragment<'a>>,
65 tooltip: Option<Position>,
66 variant: Variant,
67 size: Size,
68 pressed: bool,
69 id: Option<widget::Id>,
70 on_press: Option<Message>,
71}
72
73pub fn icon_button<'a, Message>(glyph: Glyph) -> IconButton<'a, Message> {
75 IconButton {
76 glyph,
77 label: None,
78 tooltip: Some(Position::default()),
79 variant: Variant::Ghost,
80 size: Size::Md,
81 pressed: false,
82 id: None,
83 on_press: None,
84 }
85}
86
87impl<'a, Message> IconButton<'a, Message> {
88 pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
91 self.label = Some(label.into_fragment());
92 self
93 }
94
95 pub fn tooltip(mut self, position: Option<Position>) -> Self {
98 self.tooltip = position;
99 self
100 }
101
102 pub fn variant(mut self, variant: Variant) -> Self {
103 self.variant = variant;
104 self
105 }
106
107 pub fn size(mut self, size: Size) -> Self {
110 self.size = size;
111 self
112 }
113
114 pub fn pressed(mut self, pressed: bool) -> Self {
116 self.pressed = pressed;
117 self
118 }
119
120 pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
122 self.id = Some(id.into());
123 self
124 }
125
126 pub fn on_press(mut self, message: Message) -> Self {
127 self.on_press = Some(message);
128 self
129 }
130
131 pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
132 self.on_press = message;
133 self
134 }
135
136 pub fn is_enabled(&self) -> bool {
137 self.on_press.is_some()
138 }
139
140 pub fn is_pressed(&self) -> bool {
141 self.pressed
142 }
143
144 pub fn label_text(&self) -> Option<&str> {
146 self.label.as_deref()
147 }
148}
149
150impl<'a, Message: Clone + 'a> From<IconButton<'a, Message>> for Element<'a, Message> {
151 fn from(icon_button: IconButton<'a, Message>) -> Self {
152 let IconButton {
153 glyph,
154 label,
155 tooltip: position,
156 variant,
157 size,
158 pressed,
159 id,
160 on_press,
161 } = icon_button;
162 let metrics = size.metrics();
163 let enabled = on_press.is_some();
164 let resting = if enabled {
165 Status::Active
166 } else {
167 Status::Disabled
168 };
169
170 let glyph = themed(glyph, metrics.icon, opacity(enabled), move |theme| {
171 colours(&Tokens::of(theme), variant, pressed, resting).foreground
172 });
173
174 let button = widget::button(glyph)
175 .width(Length::Fixed(metrics.height))
176 .height(Length::Fixed(metrics.height))
177 .padding(Padding::from((metrics.height - metrics.icon) / 2.0))
178 .on_press_maybe(on_press)
179 .style(move |theme, status| style(&Tokens::of(theme), variant, pressed, status));
180
181 let mut element: Element<'a, Message> = match id {
182 Some(id) => container(after_touch(button)).id(id).into(),
183 None => after_touch(button),
184 };
185 if let (Some(label), Some(position)) = (label, position) {
186 element = tooltip(element, label).position(position).into();
187 }
188 element
189 }
190}
191
192pub fn colours(tokens: &Tokens, variant: Variant, pressed: bool, status: Status) -> Colours {
197 let variant = variant.button();
198 if !pressed {
199 return button::colours(tokens, variant, status);
200 }
201
202 let held = button::colours(tokens, variant, Status::Pressed);
203 match status {
204 Status::Active => held,
205 Status::Hovered | Status::Pressed => Colours {
206 background: held
207 .background
208 .map(|background| mix(background, tokens.foreground, 0.08)),
209 ..held
210 },
211 Status::Disabled => Colours {
212 background: held.background.map(|c| fade(c, 0.5)),
213 foreground: fade(held.foreground, 0.5),
214 border: held.border.map(|c| fade(c, 0.5)),
215 },
216 }
217}
218
219pub fn style(
221 tokens: &Tokens,
222 variant: Variant,
223 pressed: bool,
224 status: Status,
225) -> widget::button::Style {
226 let colours = colours(tokens, variant, pressed, status);
227 widget::button::Style {
228 background: colours.background.map(Background::Color),
229 text_color: colours.foreground,
230 border: Border {
231 color: colours.border.unwrap_or(iced::Color::TRANSPARENT),
232 width: if colours.border.is_some() { 1.0 } else { 0.0 },
233 radius: radius::MD.into(),
234 },
235 shadow: Shadow::default(),
236 snap: true,
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::theme::{dark, light};
244
245 const STATES: [Status; 4] = [
246 Status::Active,
247 Status::Hovered,
248 Status::Pressed,
249 Status::Disabled,
250 ];
251
252 #[test]
253 fn defaults_to_a_medium_ghost_button_that_is_off_and_disabled() {
254 let b: IconButton<'_, ()> = icon_button(crate::lucide!(Plus));
255 assert_eq!(b.variant, Variant::Ghost);
256 assert_eq!(b.size, Size::Md);
257 assert!(!b.is_pressed());
258 assert!(!b.is_enabled());
259 assert!(b.label_text().is_none());
260 assert_eq!(b.tooltip, Some(Position::Top));
261 }
262
263 #[test]
264 fn builder_sets_every_option() {
265 let b = icon_button::<u8>(crate::lucide!(Bold))
266 .label("Bold")
267 .tooltip(None)
268 .variant(Variant::Outline)
269 .size(Size::Sm)
270 .pressed(true)
271 .on_press(1);
272 assert_eq!(b.label_text(), Some("Bold"));
273 assert!(b.tooltip.is_none());
274 assert_eq!(b.variant, Variant::Outline);
275 assert_eq!(b.size, Size::Sm);
276 assert!(b.is_pressed());
277 assert!(b.is_enabled());
278 assert!(!b.on_press_maybe(None).is_enabled());
279 }
280
281 #[test]
282 fn unpressed_matches_the_button_colours() {
283 for theme in [light(), dark()] {
284 let tokens = Tokens::of(&theme);
285 for variant in Variant::ALL {
286 for status in STATES {
287 assert_eq!(
288 colours(&tokens, variant, false, status),
289 button::colours(&tokens, variant.into(), status),
290 "{variant:?} {status:?}"
291 );
292 }
293 }
294 }
295 }
296
297 #[test]
298 fn variants_map_to_every_button_variant_but_link() {
299 let mapped = Variant::ALL.map(Variant::button);
300 assert!(!mapped.contains(&button::Variant::Link));
301 for variant in button::Variant::ALL {
302 assert_eq!(
303 mapped.contains(&variant),
304 variant != button::Variant::Link,
305 "{variant:?}"
306 );
307 }
308 }
309
310 #[test]
311 fn pressed_ghost_and_outline_use_the_accent_at_rest() {
312 for theme in [light(), dark()] {
313 let tokens = Tokens::of(&theme);
314 for variant in [Variant::Ghost, Variant::Outline] {
315 let on = colours(&tokens, variant, true, Status::Active);
316 assert_eq!(on.background, Some(tokens.accent), "{variant:?}");
317 assert_ne!(
318 on.background,
319 colours(&tokens, variant, false, Status::Active).background
320 );
321 }
322 }
323 }
324
325 #[test]
326 fn pressed_differs_from_unpressed_at_rest_for_filled_variants() {
327 for theme in [light(), dark()] {
328 let tokens = Tokens::of(&theme);
329 for variant in [
330 Variant::Primary,
331 Variant::Secondary,
332 Variant::Destructive,
333 Variant::Outline,
334 Variant::Ghost,
335 ] {
336 assert_ne!(
337 colours(&tokens, variant, true, Status::Active).background,
338 colours(&tokens, variant, false, Status::Active).background,
339 "{variant:?}"
340 );
341 }
342 }
343 }
344
345 #[test]
346 fn hovering_a_pressed_button_darkens_it() {
347 for theme in [light(), dark()] {
348 let tokens = Tokens::of(&theme);
349 for variant in [Variant::Ghost, Variant::Outline, Variant::Primary] {
350 let rest = colours(&tokens, variant, true, Status::Active);
351 for status in [Status::Hovered, Status::Pressed] {
352 let active = colours(&tokens, variant, true, status);
353 assert_ne!(active.background, rest.background, "{variant:?} {status:?}");
354 assert_eq!(active.foreground, rest.foreground);
355 }
356 }
357 }
358 }
359
360 #[test]
361 fn disabled_pressed_keeps_the_on_look_at_half_alpha() {
362 for theme in [light(), dark()] {
363 let tokens = Tokens::of(&theme);
364 for variant in Variant::ALL {
365 let on = colours(&tokens, variant, true, Status::Active);
366 let disabled = colours(&tokens, variant, true, Status::Disabled);
367 assert!((disabled.foreground.a - on.foreground.a * 0.5).abs() < 1e-6);
368 assert_eq!(
369 disabled.background.map(|c| c.a),
370 on.background.map(|c| c.a * 0.5)
371 );
372 }
373 }
374 }
375
376 #[test]
377 fn only_outline_has_a_border_in_every_state() {
378 let tokens = Tokens::of(&light());
379 for variant in Variant::ALL {
380 for pressed in [false, true] {
381 for status in STATES {
382 let style = style(&tokens, variant, pressed, status);
383 assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
384 assert_eq!(style.border.radius, radius::MD.into());
385 }
386 }
387 }
388 }
389}