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 Sections(Vec<RichText>),
17}
18
19#[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 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 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 pub fn size(mut self, size: f32) -> Self {
110 self.font_size = Some(size);
111 self
112 }
113
114 pub fn visuals(mut self, visuals: LabelVisuals) -> Self {
116 self.visuals = Some(visuals);
117 self
118 }
119
120 pub fn spacing(mut self, spacing: f32) -> Self {
122 self.spacing = spacing;
123 self
124 }
125
126 pub fn expand(mut self, expansion: Option<f32>) -> Self {
128 self.expansion = expansion;
129 self
130 }
131
132 pub fn sense(mut self, sense: Sense) -> Self {
135 self.sense = Some(sense);
136 self.selectable = Some(false);
137 self
138 }
139
140 pub fn interactive(mut self, interactive: bool) -> Self {
144 self.interactive = interactive;
145 self
146 }
147
148 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
150 self.wrap_mode = Some(wrap_mode);
151 self
152 }
153
154 pub fn wrap(mut self) -> Self {
156 self.wrap_mode = Some(TextWrapMode::Wrap);
157 self
158 }
159
160 pub fn selectable(mut self, selectable: bool) -> Self {
162 self.selectable = Some(selectable);
163 self
164 }
165
166 pub fn image_on_left(mut self) -> Self {
168 self.text_first = false;
169 self
170 }
171
172 pub fn selected(mut self, selected: bool) -> Self {
174 self.selected = selected;
175 self
176 }
177
178 pub fn fill_width(mut self, fill: bool) -> Self {
180 self.fill_width = fill;
181 self
182 }
183
184 fn image_display_size(image: &Image<'_>, ui: &Ui) -> Vec2 {
189 image.calc_size(ui.available_size(), image.size())
190 }
191
192 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 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 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 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 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 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 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 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 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 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}