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, RichText, 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#[derive(Clone)]
13enum LabelText {
14   Widget(WidgetText),
15   /// One wrapping galley; each [`RichText`] keeps its own style.
16   Sections(Vec<RichText>),
17}
18
19/// Themed text label with an optional trailing (or leading) image.
20///
21/// Interactive by default: hover/click paints a background from
22/// [`LabelVisuals`]. Set [`Label::interactive`] to `false` for static text.
23/// Used as the selected-item face of [`ComboBox`](crate::widgets::ComboBox).
24///
25/// ```
26/// # use egui::__run_test_ui;
27/// # use egui_elements::widgets::Label;
28/// # __run_test_ui(|ui| {
29/// ui.add(Label::new("Hello", None));
30/// # });
31/// ```
32#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
33#[derive(Clone)]
34pub struct Label {
35   text: LabelText,
36   visuals: Option<LabelVisuals>,
37   pub(crate) image: Option<Image<'static>>,
38   spacing: f32,
39   expansion: Option<f32>,
40   pub(crate) sense: Option<Sense>,
41   wrap_mode: Option<TextWrapMode>,
42   selectable: Option<bool>,
43   text_first: bool,
44   selected: bool,
45   interactive: bool,
46   fill_width: bool,
47   font_size: Option<f32>,
48}
49impl Label {
50   /// Create a new `Label` with text and an optional image.
51   /// By default the image is shown after the text
52   pub fn new(text: impl Into<WidgetText>, image: Option<Image<'static>>) -> Self {
53      Self {
54         text: LabelText::Widget(text.into()),
55         visuals: None,
56         image,
57         spacing: 6.0,
58         expansion: None,
59         sense: None,
60         wrap_mode: None,
61         selectable: None,
62         text_first: true,
63         selected: false,
64         interactive: false,
65         fill_width: false,
66         font_size: None,
67      }
68   }
69
70   /// Mixed-style wrapping paragraph from several [`RichText`] spans.
71   ///
72   /// Unlike [`super::MultiLabel`], the parts share **one** galley, so the
73   /// sentence wraps across style changes. [`RichText::strong`] is a stronger
74   /// color, not a heavier font weight.
75   ///
76   /// Plain strings need [`.into()`](Into::into) when mixed with [`RichText`]:
77   ///
78   /// ```
79   /// # use egui::{RichText, __run_test_ui};
80   /// # use egui_elements::widgets::Label;
81   /// # __run_test_ui(|ui| {
82   /// ui.add(
83   ///    Label::sections(
84   ///       [
85   ///          "Download icons from ".into(),
86   ///          RichText::new("app.com").strong(),
87   ///          " instead of a placeholder.".into(),
88   ///       ],
89   ///       None,
90   ///    )
91   ///    .wrap()
92   ///    .fill_width(true)
93   ///    .interactive(false),
94   /// );
95   /// # });
96   /// ```
97   pub fn sections(
98      parts: impl IntoIterator<Item = impl Into<RichText>>,
99      image: Option<Image<'static>>,
100   ) -> Self {
101      let mut label = Self::new("", image);
102      label.text = LabelText::Sections(parts.into_iter().map(Into::into).collect());
103      label
104   }
105
106   /// Font size for every section.
107   ///
108   /// Overrides per-span sizes from [`RichText::size`].
109   pub fn size(mut self, size: f32) -> Self {
110      self.font_size = Some(size);
111      self
112   }
113
114   /// Set the visuals of the label
115   pub fn visuals(mut self, visuals: LabelVisuals) -> Self {
116      self.visuals = Some(visuals);
117      self
118   }
119
120   /// Set the space between the text and the image.
121   pub fn spacing(mut self, spacing: f32) -> Self {
122      self.spacing = spacing;
123      self
124   }
125
126   /// Expand the bg color on hover
127   pub fn expand(mut self, expansion: Option<f32>) -> Self {
128      self.expansion = expansion;
129      self
130   }
131
132   /// Make the label respond to clicks and/or drags.
133   /// This will also turn the `selectable` to false
134   pub fn sense(mut self, sense: Sense) -> Self {
135      self.sense = Some(sense);
136      self.selectable = Some(false);
137      self
138   }
139
140   /// Make the label interactive
141   ///
142   /// True by default, If false on hover/click there will be no bg color
143   pub fn interactive(mut self, interactive: bool) -> Self {
144      self.interactive = interactive;
145      self
146   }
147
148   /// Set the wrap mode for the text. Defaults to [`Ui::wrap_mode`].
149   pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
150      self.wrap_mode = Some(wrap_mode);
151      self
152   }
153
154   /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
155   pub fn wrap(mut self) -> Self {
156      self.wrap_mode = Some(TextWrapMode::Wrap);
157      self
158   }
159
160   /// Set whether the text can be selected with the mouse.
161   pub fn selectable(mut self, selectable: bool) -> Self {
162      self.selectable = Some(selectable);
163      self
164   }
165
166   /// Show the image first and then the text
167   pub fn image_on_left(mut self) -> Self {
168      self.text_first = false;
169      self
170   }
171
172   /// Paint the selected fill from [`LabelVisuals`] even when not hovered.
173   pub fn selected(mut self, selected: bool) -> Self {
174      self.selected = selected;
175      self
176   }
177
178   /// Stretch the label to the available width (useful inside combo popups).
179   pub fn fill_width(mut self, fill: bool) -> Self {
180      self.fill_width = fill;
181      self
182   }
183
184   /// On-screen image size, honoring [`Image::fit_to_exact_size`].
185   ///
186   /// [`Image::size`] is the *texture* size (e.g. 32×32). Using that for wrap
187   /// width makes compact labels wrap even when the image is fitted to 24×24.
188   fn image_display_size(image: &Image<'_>, ui: &Ui) -> Vec2 {
189      image.calc_size(ui.available_size(), image.size())
190   }
191
192   /// Calculate the size needed by the widget.
193   ///
194   /// `available_width` is the width available *for the text part* after accounting for image/spacing.
195   pub fn galley_and_size(
196      &self,
197      ui: &Ui,
198      available_width_for_text: f32,
199   ) -> (Arc<egui::Galley>, Vec2) {
200      let layout_job = self.prepare_layout_job(ui, available_width_for_text);
201      let galley = ui.fonts_mut(|fonts| fonts.layout_job(layout_job));
202      let text_size = galley.size();
203
204      let image_size = self
205         .image
206         .as_ref()
207         .map(|image| Self::image_display_size(image, ui))
208         .unwrap_or(Vec2::ZERO);
209
210      let total_width = text_size.x
211         + if self.image.is_some() {
212            self.spacing + image_size.x
213         } else {
214            0.0
215         };
216
217      let total_height = text_size.y.max(image_size.y);
218      (galley, Vec2::new(total_width, total_height))
219   }
220
221   fn prepare_layout_job(&self, ui: &Ui, wrap_width: f32) -> LayoutJob {
222      let wrap_mode = self.wrap_mode.unwrap_or_else(|| ui.wrap_mode());
223      let mut layout_job = match &self.text {
224         LabelText::Widget(text) => {
225            let layout_job = text.clone().into_layout_job(
226               ui.style(),
227               FontSelection::Default,
228               ui.text_valign(),
229            );
230            (*layout_job).clone()
231         }
232         LabelText::Sections(parts) => {
233            let mut job = LayoutJob::default();
234            for part in parts {
235               part.clone().append_to(
236                  &mut job,
237                  ui.style(),
238                  FontSelection::Default,
239                  ui.text_valign(),
240               );
241            }
242            job
243         }
244      };
245
246      if let Some(size) = self.font_size {
247         for section in &mut layout_job.sections {
248            section.format.font_id.size = size;
249         }
250      }
251
252      match wrap_mode {
253         TextWrapMode::Extend => {
254            layout_job.wrap.max_width = f32::INFINITY;
255         }
256         TextWrapMode::Wrap => {
257            layout_job.wrap.max_width = wrap_width;
258         }
259         TextWrapMode::Truncate => {
260            layout_job.wrap.max_width = wrap_width;
261            layout_job.wrap.max_rows = 1;
262            layout_job.wrap.break_anywhere = true;
263         }
264      }
265
266      layout_job.halign = Align::LEFT;
267      layout_job
268   }
269   pub(crate) fn paint_content_within_rect(
270      &self,
271      ui: &mut Ui,
272      rect: Rect,
273      button_visuals: &WidgetVisuals,
274   ) {
275      // Estimate available width for text layout within the provided rect
276      let available_width_for_text = if let Some(image) = &self.image {
277         (rect.width() - Self::image_display_size(image, ui).x - self.spacing).max(0.0)
278      } else {
279         rect.width()
280      };
281
282      // Calculate galley based on available width
283      let (galley, _) = self.galley_and_size(ui, available_width_for_text);
284      if ui.is_rect_visible(rect) {
285         let (text_pos, image_rect_opt) = layout_content_within_rect(
286            ui,
287            rect,
288            &galley,
289            &self.image,
290            self.spacing,
291            self.text_first,
292         );
293
294         let text_color = self
295            .visuals
296            .or_else(|| Theme::label_visuals_from_ctx(ui.ctx()))
297            .as_ref()
298            .map(|v| v.text)
299            .unwrap_or(button_visuals.text_color());
300         ui.painter().add(TextShape::new(
301            text_pos,
302            galley.clone(),
303            text_color,
304         ));
305
306         if let Some(image_rect) = image_rect_opt {
307            if let Some(image) = &self.image {
308               image.paint_at(ui, image_rect);
309            }
310         }
311      }
312   }
313}
314
315fn layout_content_within_rect(
316   ui: &Ui,
317   rect: Rect,
318   galley: &egui::Galley,
319   image: &Option<Image<'static>>,
320   spacing: f32,
321   text_first: bool,
322) -> (Pos2, Option<Rect>) {
323   let text_size = galley.size();
324   let image_size = image
325      .as_ref()
326      .map(|image| Label::image_display_size(image, ui))
327      .unwrap_or(Vec2::ZERO);
328
329   let total_content_height = text_size.y.max(image_size.y);
330   let top_y = ui
331      .layout()
332      .align_size_within_rect(Vec2::new(0.0, total_content_height), rect)
333      .min
334      .y;
335
336   let (text_start_x, image_final_rect) = if text_first {
337      // Text first, image second
338      let text_start_x = rect.left();
339      let image_start_x = text_start_x + text_size.x + spacing;
340      let image_final_rect = image.as_ref().map(|_| {
341         let image_pos = Pos2::new(
342            image_start_x,
343            top_y + (total_content_height - image_size.y) * 0.5,
344         );
345         Rect::from_min_size(image_pos, image_size)
346      });
347      (text_start_x, image_final_rect)
348   } else {
349      // Image first, text second
350      let image_start_x = rect.left();
351      let text_start_x = image_start_x + image_size.x + spacing;
352      let image_final_rect = image.as_ref().map(|_| {
353         let image_pos = Pos2::new(
354            image_start_x,
355            top_y + (total_content_height - image_size.y) * 0.5,
356         );
357         Rect::from_min_size(image_pos, image_size)
358      });
359      (text_start_x, image_final_rect)
360   };
361
362   let text_pos = Pos2::new(
363      text_start_x,
364      top_y + (total_content_height - text_size.y) * 0.5,
365   );
366   (text_pos, image_final_rect)
367}
368
369impl Widget for Label {
370   fn ui(self, ui: &mut Ui) -> Response {
371      // Calculate Size (Content Only)
372      let image_size = self
373         .image
374         .as_ref()
375         .map(|image| Self::image_display_size(image, ui))
376         .unwrap_or(Vec2::ZERO);
377
378      let effective_wrap_mode = self.wrap_mode.unwrap_or_else(|| ui.wrap_mode());
379
380      let available_width_for_text = match effective_wrap_mode {
381         TextWrapMode::Extend => f32::INFINITY,
382         _ => {
383            // Wrap or Truncate
384            if self.image.is_some() {
385               (ui.available_width() - self.spacing - image_size.x).max(10.0)
386            } else {
387               ui.available_width()
388            }
389         }
390      };
391
392      let (galley, content_size) = self.galley_and_size(ui, available_width_for_text);
393      let desired_size = if self.fill_width {
394         Vec2::new(ui.available_width(), content_size.y)
395      } else {
396         content_size
397      };
398
399      // Allocate Space (Content Size Only)
400      let sense = self.sense.unwrap_or(if self.interactive {
401         Sense::hover()
402      } else {
403         Sense::empty()
404      });
405      let (rect, response) = ui.allocate_exact_size(desired_size, sense);
406
407      // Paint
408      if ui.is_rect_visible(rect) {
409         let visuals = ui.style().interact_selectable(&response, self.selected);
410         let label_visuals = self.visuals.or_else(|| Theme::label_visuals_from_ctx(ui.ctx()));
411
412         let fill = if self.selected {
413            label_visuals.as_ref().map(|v| v.bg_selected).unwrap_or(visuals.bg_fill)
414         } else if response.hovered() || response.has_focus() {
415            match self.interactive {
416               true => label_visuals.as_ref().map(|v| v.bg_hover).unwrap_or(visuals.weak_bg_fill),
417               false => Color32::TRANSPARENT,
418            }
419         } else {
420            label_visuals.as_ref().map(|v| v.bg).unwrap_or(Color32::TRANSPARENT)
421         };
422
423         let stroke = if self.selected {
424            label_visuals.as_ref().map(|v| v.border_hover).unwrap_or(visuals.bg_stroke)
425         } else if response.hovered() || response.has_focus() {
426            match self.interactive {
427               true => label_visuals.as_ref().map(|v| v.border_hover).unwrap_or(visuals.bg_stroke),
428               false => Stroke::new(0.0, Color32::TRANSPARENT),
429            }
430         } else {
431            label_visuals.as_ref().map(|v| v.border).unwrap_or(Stroke::NONE)
432         };
433
434         let expansion = self.expansion.unwrap_or(visuals.expansion);
435         let corner =
436            label_visuals.as_ref().map(|v| v.corner_radius).unwrap_or(visuals.corner_radius);
437         let background_rect = rect.expand(expansion);
438
439         ui.painter().add(RectShape::new(
440            background_rect,
441            corner,
442            fill,
443            stroke,
444            StrokeKind::Inside,
445         ));
446
447         // Layout and Paint Content
448         let (text_pos, image_rect_opt) = layout_content_within_rect(
449            ui,
450            rect,
451            &galley,
452            &self.image,
453            self.spacing,
454            self.text_first,
455         );
456
457         let text_color = label_visuals.as_ref().map(|v| v.text).unwrap_or(visuals.text_color());
458
459         ui.painter().add(TextShape::new(
460            text_pos,
461            galley.clone(),
462            text_color,
463         ));
464
465         if let Some(image_rect) = image_rect_opt {
466            if let Some(image) = self.image {
467               image.paint_at(ui, image_rect);
468            }
469         }
470      }
471      response
472   }
473}
474
475#[cfg(test)]
476mod tests {
477   use super::*;
478
479   #[test]
480   fn sections_keep_distinct_formats_and_wrap() {
481      egui::__run_test_ui(|ui| {
482         let label = Label::sections(
483            [
484               "Download icons from ".into(),
485               RichText::new("app.com").strong(),
486               " so unknown tokens show an image instead of a placeholder.".into(),
487            ],
488            None,
489         )
490         .size(18.0)
491         .wrap();
492
493         let job = label.prepare_layout_job(ui, 180.0);
494         assert!(
495            job.sections.len() >= 2,
496            "expected mixed styles, got {} section(s)",
497            job.sections.len()
498         );
499         assert!(job.text.contains("app.com"));
500         assert!(
501            job.sections.iter().all(|s| s.format.font_id.size == 18.0),
502            "Label::size should apply to every section"
503         );
504         let strong_color = ui.style().visuals.strong_text_color();
505         assert!(
506            job.sections.iter().any(|s| s.format.color == strong_color),
507            "strong span should keep the strong text color"
508         );
509         assert_eq!(job.wrap.max_width, 180.0);
510      });
511   }
512
513   #[test]
514   fn italics_and_underline_survive() {
515      egui::__run_test_ui(|ui| {
516         let label = Label::sections(
517            [
518               RichText::new("plain "),
519               RichText::new("italics").italics(),
520               RichText::new(" ").underline(),
521               RichText::new("underlined").underline(),
522            ],
523            None,
524         );
525         let job = label.prepare_layout_job(ui, 400.0);
526         assert!(job.sections.iter().any(|s| s.format.italics));
527         assert!(job.sections.iter().any(|s| s.format.underline != Stroke::NONE));
528      });
529   }
530}