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   /// Calculate the size needed by the widget.
132   ///
133   /// `available_width` is the width available *for the text part* after accounting for image/spacing.
134   pub fn galley_and_size(
135      &self,
136      ui: &Ui,
137      available_width_for_text: f32,
138   ) -> (Arc<egui::Galley>, Vec2) {
139      let layout_job = self.prepare_layout_job(ui, available_width_for_text);
140      let galley = ui.fonts_mut(|fonts| fonts.layout_job(layout_job));
141      let text_size = galley.size();
142
143      let image_size = if let Some(image) = &self.image {
144         image.calc_size(ui.available_size(), image.size())
145      } else {
146         Vec2::ZERO
147      };
148
149      let total_width = text_size.x
150         + if self.image.is_some() {
151            self.spacing + image_size.x
152         } else {
153            0.0
154         };
155
156      let total_height = text_size.y.max(image_size.y);
157      (galley, Vec2::new(total_width, total_height))
158   }
159
160   fn prepare_layout_job(&self, ui: &Ui, wrap_width: f32) -> LayoutJob {
161      let wrap_mode = self.wrap_mode.unwrap_or_else(|| ui.wrap_mode());
162      let layout_job = self.text.clone().into_layout_job(
163         ui.style(),
164         FontSelection::Default,
165         ui.text_valign(),
166      );
167
168      // remove the Arc
169      let mut layout_job: LayoutJob = (*layout_job).clone();
170      match wrap_mode {
171         TextWrapMode::Extend => {
172            layout_job.wrap.max_width = f32::INFINITY;
173         }
174         TextWrapMode::Wrap => {
175            layout_job.wrap.max_width = wrap_width;
176         }
177         TextWrapMode::Truncate => {
178            layout_job.wrap.max_width = wrap_width;
179            layout_job.wrap.max_rows = 1;
180            layout_job.wrap.break_anywhere = true;
181         }
182      }
183
184      layout_job.halign = Align::LEFT;
185      layout_job
186   }
187   pub(crate) fn paint_content_within_rect(
188      &self,
189      ui: &mut Ui,
190      rect: Rect,
191      button_visuals: &WidgetVisuals,
192   ) {
193      // Estimate available width for text layout within the provided rect
194      let available_width_for_text = if self.image.is_some() {
195         (rect.width()
196            - self.image.as_ref().map_or(0.0, |img| img.size().map_or(0.0, |s| s.x))
197            - self.spacing)
198            .max(0.0)
199      } else {
200         rect.width()
201      };
202
203      // Calculate galley based on available width
204      let (galley, _) = self.galley_and_size(ui, available_width_for_text);
205      if ui.is_rect_visible(rect) {
206         let (text_pos, image_rect_opt) = layout_content_within_rect(
207            ui,
208            rect,
209            &galley,
210            &self.image,
211            self.spacing,
212            self.text_first,
213         );
214
215         let text_color = self
216            .visuals
217            .or_else(|| Theme::label_visuals_from_ctx(ui.ctx()))
218            .as_ref()
219            .map(|v| v.text)
220            .unwrap_or(button_visuals.text_color());
221         ui.painter().add(TextShape::new(
222            text_pos,
223            galley.clone(),
224            text_color,
225         ));
226
227         if let Some(image_rect) = image_rect_opt {
228            if let Some(image) = &self.image {
229               image.paint_at(ui, image_rect);
230            }
231         }
232      }
233   }
234}
235
236fn layout_content_within_rect(
237   ui: &Ui,
238   rect: Rect,
239   galley: &egui::Galley,
240   image: &Option<Image<'static>>,
241   spacing: f32,
242   text_first: bool,
243) -> (Pos2, Option<Rect>) {
244   let text_size = galley.size();
245   let image_size = if let Some(image) = image {
246      image.calc_size(ui.available_size(), image.size())
247   } else {
248      Vec2::ZERO
249   };
250
251   let total_content_height = text_size.y.max(image_size.y);
252   let top_y = ui
253      .layout()
254      .align_size_within_rect(Vec2::new(0.0, total_content_height), rect)
255      .min
256      .y;
257
258   let (text_start_x, image_final_rect) = if text_first {
259      // Text first, image second
260      let text_start_x = rect.left();
261      let image_start_x = text_start_x + text_size.x + spacing;
262      let image_final_rect = image.as_ref().map(|_| {
263         let image_pos = Pos2::new(
264            image_start_x,
265            top_y + (total_content_height - image_size.y) * 0.5,
266         );
267         Rect::from_min_size(image_pos, image_size)
268      });
269      (text_start_x, image_final_rect)
270   } else {
271      // Image first, text second
272      let image_start_x = rect.left();
273      let text_start_x = image_start_x + image_size.x + spacing;
274      let image_final_rect = image.as_ref().map(|_| {
275         let image_pos = Pos2::new(
276            image_start_x,
277            top_y + (total_content_height - image_size.y) * 0.5,
278         );
279         Rect::from_min_size(image_pos, image_size)
280      });
281      (text_start_x, image_final_rect)
282   };
283
284   let text_pos = Pos2::new(
285      text_start_x,
286      top_y + (total_content_height - text_size.y) * 0.5,
287   );
288   (text_pos, image_final_rect)
289}
290
291impl Widget for Label {
292   fn ui(self, ui: &mut Ui) -> Response {
293      // Calculate Size (Content Only)
294      let image_size = if let Some(image) = &self.image {
295         image.calc_size(ui.available_size(), image.size())
296      } else {
297         Vec2::ZERO
298      };
299
300      let effective_wrap_mode = self.wrap_mode.unwrap_or_else(|| ui.wrap_mode());
301
302      let available_width_for_text = match effective_wrap_mode {
303         TextWrapMode::Extend => f32::INFINITY,
304         _ => {
305            // Wrap or Truncate
306            if self.image.is_some() {
307               (ui.available_width() - self.spacing - image_size.x).max(10.0)
308            } else {
309               ui.available_width()
310            }
311         }
312      };
313
314      let (galley, content_size) = self.galley_and_size(ui, available_width_for_text);
315      let desired_size = if self.fill_width {
316         Vec2::new(ui.available_width(), content_size.y)
317      } else {
318         content_size
319      };
320
321      // Allocate Space (Content Size Only)
322      let sense = self.sense.unwrap_or(if self.interactive {
323         Sense::hover()
324      } else {
325         Sense::empty()
326      });
327      let (rect, response) = ui.allocate_exact_size(desired_size, sense);
328
329      // Paint
330      if ui.is_rect_visible(rect) {
331         let visuals = ui.style().interact_selectable(&response, self.selected);
332         let label_visuals = self.visuals.or_else(|| Theme::label_visuals_from_ctx(ui.ctx()));
333
334         let fill = if self.selected {
335            label_visuals.as_ref().map(|v| v.bg_selected).unwrap_or(visuals.bg_fill)
336         } else if response.hovered() || response.has_focus() {
337            match self.interactive {
338               true => label_visuals.as_ref().map(|v| v.bg_hover).unwrap_or(visuals.weak_bg_fill),
339               false => Color32::TRANSPARENT,
340            }
341         } else {
342            label_visuals.as_ref().map(|v| v.bg).unwrap_or(Color32::TRANSPARENT)
343         };
344
345         let stroke = if self.selected {
346            label_visuals.as_ref().map(|v| v.border_hover).unwrap_or(visuals.bg_stroke)
347         } else if response.hovered() || response.has_focus() {
348            match self.interactive {
349               true => label_visuals.as_ref().map(|v| v.border_hover).unwrap_or(visuals.bg_stroke),
350               false => Stroke::new(0.0, Color32::TRANSPARENT),
351            }
352         } else {
353            label_visuals.as_ref().map(|v| v.border).unwrap_or(Stroke::NONE)
354         };
355
356         let expansion = self.expansion.unwrap_or(visuals.expansion);
357         let corner =
358            label_visuals.as_ref().map(|v| v.corner_radius).unwrap_or(visuals.corner_radius);
359         let background_rect = rect.expand(expansion);
360
361         ui.painter().add(RectShape::new(
362            background_rect,
363            corner,
364            fill,
365            stroke,
366            StrokeKind::Inside,
367         ));
368
369         // Layout and Paint Content
370         let (text_pos, image_rect_opt) = layout_content_within_rect(
371            ui,
372            rect,
373            &galley,
374            &self.image,
375            self.spacing,
376            self.text_first,
377         );
378
379         let text_color = label_visuals.as_ref().map(|v| v.text).unwrap_or(visuals.text_color());
380
381         ui.painter().add(TextShape::new(
382            text_pos,
383            galley.clone(),
384            text_color,
385         ));
386
387         if let Some(image_rect) = image_rect_opt {
388            if let Some(image) = self.image {
389               image.paint_at(ui, image_rect);
390            }
391         }
392      }
393      response
394   }
395}