Skip to main content

zeus_widgets/
label.rs

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