Skip to main content

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