Skip to main content

egui_elements/widgets/
button.rs

1use egui::{
2   Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, Frame, Image, IntoAtoms,
3   NumExt as _, Response, Sense, Shadow, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo,
4   WidgetText, WidgetType,
5};
6
7use crate::theme::Theme;
8use crate::visuals::ButtonVisuals;
9use crate::widgets::badge::Badge;
10
11/// Clickable themed button, built on egui [`Atom`]s.
12///
13/// Reads [`ButtonVisuals`] from the installed [`Theme`] unless you pass
14/// [`.visuals(...)`](Self::visuals). Use [`Button::bg_color`] for a one-off
15/// idle fill (semantic actions). Chromatic fills should use `theme.colors.bg`
16/// as the text color so glyphs stay readable.
17///
18/// ```
19/// # use egui::__run_test_ui;
20/// # use egui_elements::widgets::Button;
21/// # __run_test_ui(|ui| {
22/// if ui.add(Button::new("Save")).clicked() {
23///     // …
24/// }
25/// # });
26/// ```
27#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
28pub struct Button<'a> {
29   layout: AtomLayout<'a>,
30   visuals: Option<ButtonVisuals>,
31   bg_color: Option<Color32>,
32   badge: Option<Badge>,
33   small: bool,
34   frame_when_inactive: bool,
35   min_size: Vec2,
36   selected: bool,
37   image_tint_follows_text_color: bool,
38   limit_image_size: bool,
39}
40
41impl<'a> Button<'a> {
42   /// Create a button from atoms (text, images, or a mix).
43   pub fn new(atoms: impl IntoAtoms<'a>) -> Self {
44      Self {
45         layout: AtomLayout::new(atoms.into_atoms())
46            .sense(Sense::click())
47            .fallback_font(TextStyle::Button),
48         visuals: None,
49         bg_color: None,
50         badge: None,
51         small: false,
52         frame_when_inactive: true,
53         min_size: Vec2::ZERO,
54         selected: false,
55         image_tint_follows_text_color: false,
56         limit_image_size: false,
57      }
58   }
59
60   /// Show a selectable button.
61   ///
62   /// Equivalent to:
63   /// ```rust
64   /// # use egui::{Button, IntoAtoms, __run_test_ui};
65   /// # __run_test_ui(|ui| {
66   /// let selected = true;
67   /// ui.add(Button::new("toggle me").selected(selected).frame_when_inactive(!selected).frame(true));
68   /// # });
69   /// ```
70   ///
71   /// See also:
72   ///   - [`Ui::selectable_value`]
73   ///   - [`Ui::selectable_label`]
74   pub fn selectable(selected: bool, atoms: impl IntoAtoms<'a>) -> Self {
75      Self::new(atoms).selected(selected).frame_when_inactive(selected)
76   }
77
78   /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
79   ///
80   /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
81   /// (using [`egui::AtomExt::atom_max_height_font_size`]).
82   pub fn image(image: impl Into<Image<'a>>) -> Self {
83      Self::opt_image_and_text(Some(image.into()), None)
84   }
85
86   /// Creates a button with an image to the left of the text.
87   ///
88   /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
89   /// (using [`egui::AtomExt::atom_max_height_font_size`]).
90   pub fn image_and_text(image: impl Into<Image<'a>>, text: impl Into<WidgetText>) -> Self {
91      Self::opt_image_and_text(Some(image.into()), Some(text.into()))
92   }
93
94   /// Create a button with an optional image and optional text.
95   ///
96   /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
97   /// (using [`egui::AtomExt::atom_max_height_font_size`]).
98   pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
99      let mut button = Self::new(());
100      if let Some(image) = image {
101         button.layout.push_right(image);
102      }
103      if let Some(text) = text {
104         button.layout.push_right(text);
105      }
106      button.limit_image_size = true;
107      button
108   }
109
110   /// Set the wrap mode for the text.
111   ///
112   /// By default, [`egui::Ui::wrap_mode`] will be used, which can be overridden with [`egui::Style::wrap_mode`].
113   ///
114   /// Note that any `\n` in the text will always produce a new line.
115   #[inline]
116   pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
117      self.layout = self.layout.wrap_mode(wrap_mode);
118      self
119   }
120
121   /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
122   #[inline]
123   pub fn wrap(self) -> Self {
124      self.wrap_mode(TextWrapMode::Wrap)
125   }
126
127   /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
128   #[inline]
129   pub fn truncate(self) -> Self {
130      self.wrap_mode(TextWrapMode::Truncate)
131   }
132
133   /// Make this a small button, suitable for embedding into text.
134   #[inline]
135   pub fn small(mut self) -> Self {
136      self.small = true;
137      self
138   }
139
140   /// If `false`, the button will not have a frame when inactive.
141   ///
142   /// Default: `true`.
143   ///
144   /// Note: When `ui.visuals().button_frame` is `false`, this setting
145   /// has no effect.
146   #[inline]
147   pub fn frame_when_inactive(mut self, frame_when_inactive: bool) -> Self {
148      self.frame_when_inactive = frame_when_inactive;
149      self
150   }
151
152   /// By default, buttons senses clicks.
153   /// Change this to a drag-button with `Sense::drag()`.
154   #[inline]
155   pub fn sense(mut self, sense: Sense) -> Self {
156      self.layout = self.layout.sense(sense);
157      self
158   }
159
160   /// Set the minimum size of the button.
161   #[inline]
162   pub fn min_size(mut self, min_size: Vec2) -> Self {
163      self.min_size = min_size;
164      self
165   }
166
167   /// If true, the tint of the image is multiplied by the widget text color.
168   ///
169   /// This makes sense for images that are white, that should have the same color as the text color.
170   /// This will also make the icon color depend on hover state.
171   ///
172   /// Default: `false`.
173   #[inline]
174   pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
175      self.image_tint_follows_text_color = image_tint_follows_text_color;
176      self
177   }
178
179   /// Show some text on the right side of the button, in weak color.
180   ///
181   /// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`).
182   ///
183   /// The text can be created with [`egui::Context::format_shortcut`].
184   ///
185   /// See also [`Self::right_text`].
186   #[inline]
187   pub fn shortcut_text(mut self, shortcut_text: impl Into<Atom<'a>>) -> Self {
188      let mut atom = shortcut_text.into();
189      atom.kind = match atom.kind {
190         AtomKind::Text(text) => AtomKind::Text(text.weak()),
191         other => other,
192      };
193      self.layout.push_right(Atom::grow());
194      self.layout.push_right(atom);
195      self
196   }
197
198   /// Show some text on the right side of the button.
199   #[inline]
200   pub fn right_text(mut self, right_text: impl Into<Atom<'a>>) -> Self {
201      self.layout.push_right(Atom::grow());
202      self.layout.push_right(right_text.into());
203      self
204   }
205
206   /// If `true`, mark this button as "selected".
207   #[inline]
208   pub fn selected(mut self, selected: bool) -> Self {
209      self.selected = selected;
210      self
211   }
212
213   /// Set the visuals of the button
214   #[inline]
215   pub fn visuals(mut self, visuals: ButtonVisuals) -> Self {
216      self.visuals = Some(visuals);
217      self
218   }
219
220   /// Set the background color of the button
221   #[inline]
222   pub fn bg_color(mut self, color: Color32) -> Self {
223      self.bg_color = Some(color);
224      self
225   }
226
227   /// Attach a notification [`Badge`] to a corner of this button.
228   ///
229   /// The badge is painted on top of the button and does not change its size or
230   /// layout. Use it to signal unseen content behind the button — e.g. a red
231   /// circle with the number of notifications.
232   ///
233   /// ```
234   /// # use egui::__run_test_ui;
235   /// # use egui::Color32;
236   /// # use egui_elements::widgets::{Badge, BadgeCorner, Button};
237   /// # __run_test_ui(|ui| {
238   /// ui.add(
239   ///    Button::new("Notifications").badge(
240   ///       Badge::new("3")
241   ///          .color(Color32::RED)
242   ///          .corner(BadgeCorner::TopRight),
243   ///    ),
244   /// );
245   /// # });
246   /// ```
247   #[inline]
248   pub fn badge(mut self, badge: Badge) -> Self {
249      self.badge = Some(badge);
250      self
251   }
252
253   /// Show the button and return a [`AtomLayoutResponse`] for painting custom contents.
254   pub fn atom_ui(self, ui: &mut Ui) -> AtomLayoutResponse {
255      let Button {
256         mut layout,
257         bg_color,
258         badge,
259         small,
260         visuals,
261         frame_when_inactive,
262         mut min_size,
263         selected,
264         image_tint_follows_text_color,
265         limit_image_size,
266      } = self;
267
268      let visuals = visuals.or_else(|| Theme::button_visuals_from_ctx(ui.ctx()));
269
270      if !small {
271         min_size.y = min_size.y.at_least(ui.spacing().interact_size.y);
272      }
273      if limit_image_size {
274         layout.map_atoms(|atom| {
275            if matches!(&atom.kind, AtomKind::Image(_)) {
276               atom.atom_max_height_font_size(ui)
277            } else {
278               atom
279            }
280         });
281      }
282
283      let text = layout.text().map(String::from);
284
285      let has_frame_margin = visuals.is_some() || ui.visuals().button_frame;
286      let mut button_padding = if has_frame_margin {
287         ui.spacing().button_padding
288      } else {
289         Vec2::ZERO
290      };
291
292      if small {
293         button_padding.y = 0.0;
294      }
295
296      let frame = Frame::new().inner_margin(button_padding);
297      let mut prepared = layout.frame(frame).min_size(min_size).allocate(ui);
298
299      let response = if ui.is_rect_visible(prepared.response.rect) {
300         let interact_visuals = ui.style().interact_selectable(&prepared.response, selected);
301
302         let is_active = prepared.response.is_pointer_button_down_on(); // Clicked
303         let is_hovered = prepared.response.hovered();
304
305         // Select custom visuals based on state, or fall back to defaults
306         let (fill, stroke, corner_radius) = if let Some(v) = &visuals {
307            if is_active {
308               (v.bg_click, v.border_click, v.corner_radius)
309            } else if is_hovered {
310               (v.bg_hover, v.border_hover, v.corner_radius)
311            } else {
312               let bg = bg_color.unwrap_or(v.bg);
313               (bg, v.border, v.corner_radius)
314            }
315         } else {
316            (
317               interact_visuals.weak_bg_fill,
318               interact_visuals.bg_stroke,
319               interact_visuals.corner_radius,
320            )
321         };
322
323         let fill = match selected {
324            false => fill,
325            true => {
326               visuals.as_ref().map(|v| v.bg_selected).unwrap_or(interact_visuals.weak_bg_fill)
327            }
328         };
329
330         let text_color = visuals.as_ref().map(|v| v.text).unwrap_or(interact_visuals.text_color());
331
332         let visible_frame = if frame_when_inactive {
333            has_frame_margin
334         } else {
335            has_frame_margin && (is_hovered || is_active || prepared.response.has_focus())
336         };
337
338         if image_tint_follows_text_color {
339            prepared.map_images(|image| image.tint(text_color));
340         }
341         prepared.fallback_text_color = text_color;
342
343         if visible_frame {
344            let shadow = visuals.as_ref().map(|v| v.shadow).unwrap_or(Shadow::NONE);
345            prepared.frame = prepared
346               .frame
347               .inner_margin(
348                  button_padding + Vec2::splat(interact_visuals.expansion)
349                     - Vec2::splat(stroke.width),
350               )
351               .outer_margin(-Vec2::splat(interact_visuals.expansion))
352               .fill(fill)
353               .stroke(stroke)
354               .corner_radius(corner_radius)
355               .shadow(shadow);
356         }
357
358         let painted = prepared.paint(ui);
359         if let Some(badge) = &badge {
360            badge.paint_at(ui, painted.response.rect);
361         }
362         painted
363      } else {
364         AtomLayoutResponse::empty(prepared.response)
365      };
366
367      response.response.widget_info(|| {
368         if let Some(text) = &text {
369            WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text)
370         } else {
371            WidgetInfo::new(WidgetType::Button)
372         }
373      });
374
375      response
376   }
377}
378
379impl Widget for Button<'_> {
380   fn ui(self, ui: &mut Ui) -> Response {
381      self.atom_ui(ui).response
382   }
383}