egui_charts/ui_kit/buttons/
button.rs1use crate::localization as l10n;
7use crate::theme::ThemeExt;
8use egui::{Color32, Rect, Response, Sense, TextStyle, Ui, Vec2, Widget, pos2};
9
10use super::{ButtonSize, ButtonVariant, finish};
11use crate::icons::Icon;
12use crate::tokens::DESIGN_TOKENS;
13
14pub struct Button<'a> {
43 text: Option<String>,
44 icon: Option<&'a Icon>,
45 variant: ButtonVariant,
46 size: ButtonSize,
47 tooltip: Option<String>,
48 enabled: bool,
49 selected: Option<bool>,
50 icon_scale: Option<f32>,
51 text_color: Option<Color32>,
52}
53
54impl<'a> Button<'a> {
55 pub fn new(text: impl Into<String>) -> Self {
57 Self {
58 text: Some(text.into()),
59 icon: None,
60 variant: ButtonVariant::Ghost,
61 size: ButtonSize::MD,
62 tooltip: None,
63 enabled: true,
64 selected: None,
65 icon_scale: None,
66 text_color: None,
67 }
68 }
69
70 pub fn icon(icon: &'a Icon) -> Self {
72 Self {
73 text: None,
74 icon: Some(icon),
75 variant: ButtonVariant::Ghost,
76 size: ButtonSize::MD,
77 tooltip: None,
78 enabled: true,
79 selected: None,
80 icon_scale: None,
81 text_color: None,
82 }
83 }
84
85 pub fn icon_and_text(icon: &'a Icon, text: impl Into<String>) -> Self {
87 Self {
88 text: Some(text.into()),
89 icon: Some(icon),
90 variant: ButtonVariant::Ghost,
91 size: ButtonSize::MD,
92 tooltip: None,
93 enabled: true,
94 selected: None,
95 icon_scale: None,
96 text_color: None,
97 }
98 }
99
100 #[must_use]
102 pub fn variant(mut self, variant: ButtonVariant) -> Self {
103 self.variant = variant;
104 self
105 }
106
107 #[must_use]
109 pub fn primary(mut self) -> Self {
110 self.variant = ButtonVariant::Primary;
111 self
112 }
113
114 #[must_use]
116 pub fn secondary(mut self) -> Self {
117 self.variant = ButtonVariant::Secondary;
118 self
119 }
120
121 #[must_use]
123 pub fn ghost(mut self) -> Self {
124 self.variant = ButtonVariant::Ghost;
125 self
126 }
127
128 #[must_use]
130 pub fn outlined(mut self) -> Self {
131 self.variant = ButtonVariant::Outlined;
132 self
133 }
134
135 #[must_use]
137 pub fn danger(mut self) -> Self {
138 self.variant = ButtonVariant::Danger;
139 self
140 }
141
142 #[must_use]
144 pub fn text_color(mut self, color: Color32) -> Self {
145 self.text_color = Some(color);
146 self
147 }
148
149 #[must_use]
151 pub fn size(mut self, size: ButtonSize) -> Self {
152 self.size = size;
153 self
154 }
155
156 #[must_use]
158 pub fn small(mut self) -> Self {
159 self.size = ButtonSize::SM;
160 self
161 }
162
163 #[must_use]
165 pub fn large(mut self) -> Self {
166 self.size = ButtonSize::LG;
167 self
168 }
169
170 #[must_use]
172 pub fn tooltip(mut self, text: impl Into<String>) -> Self {
173 self.tooltip = Some(text.into());
174 self
175 }
176
177 #[must_use]
179 pub fn enabled(mut self, enabled: bool) -> Self {
180 self.enabled = enabled;
181 self
182 }
183
184 #[must_use]
186 pub fn selected(mut self, selected: bool) -> Self {
187 self.selected = Some(selected);
188 self
189 }
190
191 #[must_use]
195 pub fn icon_scale(mut self, scale: f32) -> Self {
196 self.icon_scale = Some(scale.clamp(0.1, 1.0));
197 self
198 }
199
200 pub fn show(self, ui: &mut Ui) -> Response {
202 ui.add(self)
203 }
204}
205
206impl<'a> Widget for Button<'a> {
207 fn ui(self, ui: &mut Ui) -> Response {
208 if !self.enabled {
209 return ui.add_enabled(
211 false,
212 Self {
213 enabled: true,
214 ..self
215 },
216 );
217 }
218
219 let theme = ui.active_theme();
220 let height = self.size.pixels();
221 let icon_size = self.size.icon_pixels(self.icon_scale);
222 let galley = self.text.as_ref().map(|text| {
223 let font = TextStyle::Button.resolve(ui.style());
224 ui.painter().layout_no_wrap(
225 l10n::text(ui.ctx(), text.clone()),
226 font,
227 Color32::PLACEHOLDER,
228 )
229 });
230
231 let (padding, gap) = match (self.icon.is_some(), galley.is_some()) {
233 (true, true) => (theme.spacing.xl, theme.spacing.sm),
234 (false, true) => (theme.spacing.xl, 0.0),
235 _ => (0.0, 0.0),
236 };
237 let content_width = self.icon.map_or(0.0, |_| icon_size)
238 + gap
239 + galley.as_ref().map_or(0.0, |galley| galley.size().x);
240 let width = (content_width + 2.0 * padding).max(height);
241
242 let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click());
243 crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.sm);
244
245 if ui.is_rect_visible(rect) {
246 let hovered = response.hovered();
247 let pressed = response.is_pointer_button_down_on();
248 let variant = if self.selected == Some(true) {
249 ButtonVariant::Primary
250 } else {
251 self.variant
252 };
253 let fg = if variant.is_inverse() {
254 variant.fg_color(ui)
255 } else {
256 ui.style().interact(&response).fg_stroke.color
257 };
258 let rounding = theme.radius.sm;
259
260 ui.painter()
261 .rect_filled(rect, rounding, variant.bg_color(ui, hovered, pressed));
262 if let Some(border) = variant.border_color(ui) {
263 ui.painter().rect_stroke(
264 rect,
265 rounding,
266 egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, border),
267 egui::epaint::StrokeKind::Inside,
268 );
269 }
270
271 let mut x = rect.center().x - content_width / 2.0;
273 if let Some(icon) = self.icon {
274 let icon_rect = Rect::from_min_size(
275 pos2(x, rect.center().y - icon_size / 2.0),
276 Vec2::splat(icon_size),
277 );
278 icon.as_image_tinted(Vec2::splat(icon_size), fg)
279 .paint_at(ui, icon_rect);
280 x += icon_size + gap;
281 }
282 if let Some(galley) = galley {
283 let pos = pos2(x, rect.center().y - galley.size().y / 2.0);
284 ui.painter()
285 .galley(pos, galley, self.text_color.unwrap_or(fg));
286 }
287 }
288
289 let label = self.text.as_deref().or(self.tooltip.as_deref());
290 finish(
291 response,
292 self.selected,
293 label.unwrap_or_default(),
294 self.tooltip.as_deref(),
295 )
296 }
297}
298
299pub trait ButtonExt {
301 fn primary_button(self) -> Button<'static>;
303
304 fn secondary_button(self) -> Button<'static>;
306
307 fn outlined_button(self) -> Button<'static>;
309
310 fn danger_button(self) -> Button<'static>;
312}
313
314impl ButtonExt for &str {
315 fn primary_button(self) -> Button<'static> {
316 Button::new(self).primary()
317 }
318
319 fn secondary_button(self) -> Button<'static> {
320 Button::new(self).secondary()
321 }
322
323 fn outlined_button(self) -> Button<'static> {
324 Button::new(self).outlined()
325 }
326
327 fn danger_button(self) -> Button<'static> {
328 Button::new(self).danger()
329 }
330}
331
332impl ButtonExt for String {
333 fn primary_button(self) -> Button<'static> {
334 Button::new(self).primary()
335 }
336
337 fn secondary_button(self) -> Button<'static> {
338 Button::new(self).secondary()
339 }
340
341 fn outlined_button(self) -> Button<'static> {
342 Button::new(self).outlined()
343 }
344
345 fn danger_button(self) -> Button<'static> {
346 Button::new(self).danger()
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn width_is_the_laid_out_label_plus_padding() {
356 let label = "Größe ändern";
358 let ctx = egui::Context::default();
359 ctx.run_ui(Default::default(), |ui| {
360 egui::CentralPanel::default().show(ui, |ui| {
361 let font = egui::TextStyle::Button.resolve(ui.style());
362 let text_width = ui
363 .painter()
364 .layout_no_wrap(label.to_owned(), font, egui::Color32::PLACEHOLDER)
365 .size()
366 .x;
367 let width = Button::new(label).show(ui).rect.width();
368 assert_eq!(width, text_width + 2.0 * ctx.active_theme().spacing.xl);
369 });
370 })
371 .drop_without_applying_deltas();
372 }
373
374 #[test]
375 fn focus_ring_follows_the_button_corners_and_stays_within_two_pixels() {
376 let mut custom = crate::theme::Theme::light();
377 custom.radius.sm = 9.0;
378 for (_, theme) in crate::theme::presets()
379 .into_iter()
380 .chain([("custom", custom)])
381 {
382 let ctx = egui::Context::default();
383 crate::theme::apply_to_egui(&ctx, &theme);
384 let mut id = egui::Id::NULL;
385 ctx.run_ui(Default::default(), |ui| {
386 id = Button::new("Focus").show(ui).id;
387 })
388 .drop_without_applying_deltas();
389 ctx.memory_mut(|m| m.request_focus(id));
390 let mut rect = egui::Rect::NOTHING;
391 let output = ctx.run_ui(Default::default(), |ui| {
392 rect = Button::new("Focus").show(ui).rect;
393 });
394 let rings: Vec<_> = output
395 .shapes
396 .iter()
397 .filter_map(|s| match &s.shape {
398 egui::Shape::Rect(r) if r.stroke.width == DESIGN_TOKENS.stroke.thick => {
399 Some(r.clone())
400 }
401 _ => None,
402 })
403 .collect();
404 output.drop_without_applying_deltas();
405 assert_eq!(rings.len(), 1);
406 assert_eq!(rings[0].rect, rect.expand(2.0));
407 assert_eq!(rings[0].stroke_kind, egui::epaint::StrokeKind::Inside);
408 assert_eq!(
409 rings[0].corner_radius,
410 egui::CornerRadius::from(ctx.active_theme().radius.sm + 2.0)
411 );
412 }
413 }
414}