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