Skip to main content

egui_elements/widgets/
label.rs

1use crate::theme::Theme;
2use crate::visuals::LabelVisuals;
3use egui::{
4   Align, Color32, FontSelection, Image, Pos2, Rect, Response, Sense, Stroke, StrokeKind,
5   TextWrapMode, Ui, Vec2, Widget, WidgetText,
6   epaint::{RectShape, TextShape},
7   style::WidgetVisuals,
8   text::LayoutJob,
9};
10use std::sync::Arc;
11
12/// Themed text label with an optional trailing (or leading) image.
13///
14/// Interactive by default: hover/click paints a background from
15/// [`LabelVisuals`]. Set [`Label::interactive`] to `false` for static text.
16/// Used as the selected-item face of [`ComboBox`](crate::widgets::ComboBox).
17///
18/// ```
19/// # use egui::__run_test_ui;
20/// # use egui_elements::widgets::Label;
21/// # __run_test_ui(|ui| {
22/// ui.add(Label::new("Hello", None));
23/// # });
24/// ```
25#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
26#[derive(Clone)]
27pub struct Label {
28   text: WidgetText,
29   visuals: Option<LabelVisuals>,
30   pub(crate) image: Option<Image<'static>>,
31   spacing: f32,
32   expansion: Option<f32>,
33   pub(crate) sense: Option<Sense>,
34   wrap_mode: Option<TextWrapMode>,
35   selectable: Option<bool>,
36   text_first: bool,
37   selected: bool,
38   interactive: bool,
39   fill_width: bool,
40}
41impl Label {
42   /// Create a new `Label` with text and an optional image.
43   /// By default the image is shown after the text
44   pub fn new(text: impl Into<WidgetText>, image: Option<Image<'static>>) -> Self {
45      Self {
46         text: text.into(),
47         visuals: None,
48         image,
49         spacing: 6.0,
50         expansion: None,
51         sense: None,
52         wrap_mode: None,
53         selectable: None,
54         text_first: true,
55         selected: false,
56         interactive: false,
57         fill_width: false,
58      }
59   }
60
61   /// Set the visuals of the label
62   pub fn visuals(mut self, visuals: LabelVisuals) -> Self {
63      self.visuals = Some(visuals);
64      self
65   }
66
67   /// Set the space between the text and the image.
68   pub fn spacing(mut self, spacing: f32) -> Self {
69      self.spacing = spacing;
70      self
71   }
72
73   /// Expand the bg color on hover
74   pub fn expand(mut self, expansion: Option<f32>) -> Self {
75      self.expansion = expansion;
76      self
77   }
78
79   /// Make the label respond to clicks and/or drags.
80   /// This will also turn the `selectable` to false
81   pub fn sense(mut self, sense: Sense) -> Self {
82      self.sense = Some(sense);
83      self.selectable = Some(false);
84      self
85   }
86
87   /// Make the label interactive
88   ///
89   /// True by default, If false on hover/click there will be no bg color
90   pub fn interactive(mut self, interactive: bool) -> Self {
91      self.interactive = interactive;
92      self
93   }
94
95   /// Set the wrap mode for the text. Defaults to [`Ui::wrap_mode`].
96   pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
97      self.wrap_mode = Some(wrap_mode);
98      self
99   }
100
101   /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
102   pub fn wrap(mut self) -> Self {
103      self.wrap_mode = Some(TextWrapMode::Wrap);
104      self
105   }
106
107   /// Set whether the text can be selected with the mouse.
108   pub fn selectable(mut self, selectable: bool) -> Self {
109      self.selectable = Some(selectable);
110      self
111   }
112
113   /// Show the image first and then the text
114   pub fn image_on_left(mut self) -> Self {
115      self.text_first = false;
116      self
117   }
118
119   /// Paint the selected fill from [`LabelVisuals`] even when not hovered.
120   pub fn selected(mut self, selected: bool) -> Self {
121      self.selected = selected;
122      self
123   }
124
125   /// Stretch the label to the available width (useful inside combo popups).
126   pub fn fill_width(mut self, fill: bool) -> Self {
127      self.fill_width = fill;
128      self
129   }
130
131   /// On-screen image size, honoring [`Image::fit_to_exact_size`].
132   ///
133   /// [`Image::size`] is the *texture* size (e.g. 32×32). Using that for wrap
134   /// width makes compact labels wrap even when the image is fitted to 24×24.
135   fn image_display_size(image: &Image<'_>, ui: &Ui) -> Vec2 {
136      image.calc_size(ui.available_size(), image.size())
137   }
138
139   /// Calculate the size needed by the widget.
140   ///
141   /// `available_width` is the width available *for the text part* after accounting for image/spacing.
142   pub fn galley_and_size(
143      &self,
144      ui: &Ui,
145      available_width_for_text: f32,
146   ) -> (Arc<egui::Galley>, Vec2) {
147      let layout_job = self.prepare_layout_job(ui, available_width_for_text);
148      let galley = ui.fonts_mut(|fonts| fonts.layout_job(layout_job));
149      let text_size = galley.size();
150
151      let image_size = self
152         .image
153         .as_ref()
154         .map(|image| Self::image_display_size(image, ui))
155         .unwrap_or(Vec2::ZERO);
156
157      let total_width = text_size.x
158         + if self.image.is_some() {
159            self.spacing + image_size.x
160         } else {
161            0.0
162         };
163
164      let total_height = text_size.y.max(image_size.y);
165      (galley, Vec2::new(total_width, total_height))
166   }
167
168   fn prepare_layout_job(&self, ui: &Ui, wrap_width: f32) -> LayoutJob {
169      let wrap_mode = self.wrap_mode.unwrap_or_else(|| ui.wrap_mode());
170      let layout_job = self.text.clone().into_layout_job(
171         ui.style(),
172         FontSelection::Default,
173         ui.text_valign(),
174      );
175
176      // remove the Arc
177      let mut layout_job: LayoutJob = (*layout_job).clone();
178      match wrap_mode {
179         TextWrapMode::Extend => {
180            layout_job.wrap.max_width = f32::INFINITY;
181         }
182         TextWrapMode::Wrap => {
183            layout_job.wrap.max_width = wrap_width;
184         }
185         TextWrapMode::Truncate => {
186            layout_job.wrap.max_width = wrap_width;
187            layout_job.wrap.max_rows = 1;
188            layout_job.wrap.break_anywhere = true;
189         }
190      }
191
192      layout_job.halign = Align::LEFT;
193      layout_job
194   }
195   pub(crate) fn paint_content_within_rect(
196      &self,
197      ui: &mut Ui,
198      rect: Rect,
199      button_visuals: &WidgetVisuals,
200   ) {
201      // Estimate available width for text layout within the provided rect
202      let available_width_for_text = if let Some(image) = &self.image {
203         (rect.width() - Self::image_display_size(image, ui).x - self.spacing).max(0.0)
204      } else {
205         rect.width()
206      };
207
208      // Calculate galley based on available width
209      let (galley, _) = self.galley_and_size(ui, available_width_for_text);
210      if ui.is_rect_visible(rect) {
211         let (text_pos, image_rect_opt) = layout_content_within_rect(
212            ui,
213            rect,
214            &galley,
215            &self.image,
216            self.spacing,
217            self.text_first,
218         );
219
220         let text_color = self
221            .visuals
222            .or_else(|| Theme::label_visuals_from_ctx(ui.ctx()))
223            .as_ref()
224            .map(|v| v.text)
225            .unwrap_or(button_visuals.text_color());
226         ui.painter().add(TextShape::new(
227            text_pos,
228            galley.clone(),
229            text_color,
230         ));
231
232         if let Some(image_rect) = image_rect_opt {
233            if let Some(image) = &self.image {
234               image.paint_at(ui, image_rect);
235            }
236         }
237      }
238   }
239}
240
241fn layout_content_within_rect(
242   ui: &Ui,
243   rect: Rect,
244   galley: &egui::Galley,
245   image: &Option<Image<'static>>,
246   spacing: f32,
247   text_first: bool,
248) -> (Pos2, Option<Rect>) {
249   let text_size = galley.size();
250   let image_size = image
251      .as_ref()
252      .map(|image| Label::image_display_size(image, ui))
253      .unwrap_or(Vec2::ZERO);
254
255   let total_content_height = text_size.y.max(image_size.y);
256   let top_y = ui
257      .layout()
258      .align_size_within_rect(Vec2::new(0.0, total_content_height), rect)
259      .min
260      .y;
261
262   let (text_start_x, image_final_rect) = if text_first {
263      // Text first, image second
264      let text_start_x = rect.left();
265      let image_start_x = text_start_x + text_size.x + spacing;
266      let image_final_rect = image.as_ref().map(|_| {
267         let image_pos = Pos2::new(
268            image_start_x,
269            top_y + (total_content_height - image_size.y) * 0.5,
270         );
271         Rect::from_min_size(image_pos, image_size)
272      });
273      (text_start_x, image_final_rect)
274   } else {
275      // Image first, text second
276      let image_start_x = rect.left();
277      let text_start_x = image_start_x + image_size.x + spacing;
278      let image_final_rect = image.as_ref().map(|_| {
279         let image_pos = Pos2::new(
280            image_start_x,
281            top_y + (total_content_height - image_size.y) * 0.5,
282         );
283         Rect::from_min_size(image_pos, image_size)
284      });
285      (text_start_x, image_final_rect)
286   };
287
288   let text_pos = Pos2::new(
289      text_start_x,
290      top_y + (total_content_height - text_size.y) * 0.5,
291   );
292   (text_pos, image_final_rect)
293}
294
295impl Widget for Label {
296   fn ui(self, ui: &mut Ui) -> Response {
297      // Calculate Size (Content Only)
298      let image_size = self
299         .image
300         .as_ref()
301         .map(|image| Self::image_display_size(image, ui))
302         .unwrap_or(Vec2::ZERO);
303
304      let effective_wrap_mode = self.wrap_mode.unwrap_or_else(|| ui.wrap_mode());
305
306      let available_width_for_text = match effective_wrap_mode {
307         TextWrapMode::Extend => f32::INFINITY,
308         _ => {
309            // Wrap or Truncate
310            if self.image.is_some() {
311               (ui.available_width() - self.spacing - image_size.x).max(10.0)
312            } else {
313               ui.available_width()
314            }
315         }
316      };
317
318      let (galley, content_size) = self.galley_and_size(ui, available_width_for_text);
319      let desired_size = if self.fill_width {
320         Vec2::new(ui.available_width(), content_size.y)
321      } else {
322         content_size
323      };
324
325      // Allocate Space (Content Size Only)
326      let sense = self.sense.unwrap_or(if self.interactive {
327         Sense::hover()
328      } else {
329         Sense::empty()
330      });
331      let (rect, response) = ui.allocate_exact_size(desired_size, sense);
332
333      // Paint
334      if ui.is_rect_visible(rect) {
335         let visuals = ui.style().interact_selectable(&response, self.selected);
336         let label_visuals = self.visuals.or_else(|| Theme::label_visuals_from_ctx(ui.ctx()));
337
338         let fill = if self.selected {
339            label_visuals.as_ref().map(|v| v.bg_selected).unwrap_or(visuals.bg_fill)
340         } else if response.hovered() || response.has_focus() {
341            match self.interactive {
342               true => label_visuals.as_ref().map(|v| v.bg_hover).unwrap_or(visuals.weak_bg_fill),
343               false => Color32::TRANSPARENT,
344            }
345         } else {
346            label_visuals.as_ref().map(|v| v.bg).unwrap_or(Color32::TRANSPARENT)
347         };
348
349         let stroke = if self.selected {
350            label_visuals.as_ref().map(|v| v.border_hover).unwrap_or(visuals.bg_stroke)
351         } else if response.hovered() || response.has_focus() {
352            match self.interactive {
353               true => label_visuals.as_ref().map(|v| v.border_hover).unwrap_or(visuals.bg_stroke),
354               false => Stroke::new(0.0, Color32::TRANSPARENT),
355            }
356         } else {
357            label_visuals.as_ref().map(|v| v.border).unwrap_or(Stroke::NONE)
358         };
359
360         let expansion = self.expansion.unwrap_or(visuals.expansion);
361         let corner =
362            label_visuals.as_ref().map(|v| v.corner_radius).unwrap_or(visuals.corner_radius);
363         let background_rect = rect.expand(expansion);
364
365         ui.painter().add(RectShape::new(
366            background_rect,
367            corner,
368            fill,
369            stroke,
370            StrokeKind::Inside,
371         ));
372
373         // Layout and Paint Content
374         let (text_pos, image_rect_opt) = layout_content_within_rect(
375            ui,
376            rect,
377            &galley,
378            &self.image,
379            self.spacing,
380            self.text_first,
381         );
382
383         let text_color = label_visuals.as_ref().map(|v| v.text).unwrap_or(visuals.text_color());
384
385         ui.painter().add(TextShape::new(
386            text_pos,
387            galley.clone(),
388            text_color,
389         ));
390
391         if let Some(image_rect) = image_rect_opt {
392            if let Some(image) = self.image {
393               image.paint_at(ui, image_rect);
394            }
395         }
396      }
397      response
398   }
399}