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