pix_engine/gui/widgets/
text.rs

1//! Text widget rendering methods.
2//!
3//! Provided [`PixState`] methods:
4//!
5//! - [`PixState::text`]
6//! - [`PixState::text_transformed`]
7//! - [`PixState::bullet`]
8//! - [`PixState::collapsing_tree`]
9//! - [`PixState::collapsing_header`]
10//!
11//! # Example
12//!
13//! ```
14//! # use pix_engine::prelude::*;
15//! # struct App { text_field: String, text_area: String};
16//! # impl PixEngine for App {
17//! fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
18//!     s.text("Text")?;
19//!     s.angle_mode(AngleMode::Degrees);
20//!     let angle = 10.0;
21//!     let center = point!(10, 10);
22//!     s.text_transformed("Text", angle, center, Flipped::Horizontal)?;
23//!     s.bullet("Bulleted text")?;
24//!     Ok(())
25//! }
26//! # }
27//! ```
28
29use crate::{gui::Direction, ops::clamp_size, prelude::*, renderer::Rendering};
30
31impl PixState {
32    /// Return the dimensions of given text for drawing to the current canvas.
33    ///
34    /// # Errors
35    ///
36    /// If the renderer fails to load the current font, then an error is returned.
37    ///
38    /// # Example
39    ///
40    /// ```
41    /// # use pix_engine::prelude::*;
42    /// # struct App;
43    /// # impl PixEngine for App {
44    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
45    ///     let text = "Some text";
46    ///     let (w, h) = s.size_of(text)?;
47    ///     // Draw a box behind the text
48    ///     s.rect(rect![s.cursor_pos() - 10, w as i32 + 20, h as i32 + 20]);
49    ///     s.text(text)?;
50    ///     Ok(())
51    /// }
52    /// # }
53    /// ```
54    #[inline]
55    pub fn size_of<S: AsRef<str>>(&self, text: S) -> PixResult<(u32, u32)> {
56        self.renderer
57            .size_of(text.as_ref(), self.settings.wrap_width)
58    }
59
60    /// Draw body text to the current canvas.
61    ///
62    /// Returns the rendered `(width, height)` of the text, including any newlines or text
63    /// wrapping.
64    ///
65    /// # Errors
66    ///
67    /// If the renderer fails to draw to the current render target, then an error is returned.
68    ///
69    /// # Example
70    ///
71    /// ```
72    /// # use pix_engine::prelude::*;
73    /// # struct App { text_field: String, text_area: String};
74    /// # impl PixEngine for App {
75    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
76    ///     s.text("Text")?;
77    ///     Ok(())
78    /// }
79    /// # }
80    /// ```
81    pub fn text<S>(&mut self, text: S) -> PixResult<(u32, u32)>
82    where
83        S: AsRef<str>,
84    {
85        self.text_transformed(text, None, None, None)
86    }
87
88    /// Draw heading text to the current canvas.
89    ///
90    /// Returns the rendered `(width, height)` of the text, including any newlines or text
91    /// wrapping.
92    ///
93    /// # Errors
94    ///
95    /// If the renderer fails to draw to the current render target, then an error is returned.
96    ///
97    /// # Example
98    ///
99    /// ```
100    /// # use pix_engine::prelude::*;
101    /// # struct App { text_field: String, text_area: String};
102    /// # impl PixEngine for App {
103    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
104    ///     s.heading("Heading")?;
105    ///     Ok(())
106    /// }
107    /// # }
108    /// ```
109    pub fn heading<S>(&mut self, text: S) -> PixResult<(u32, u32)>
110    where
111        S: AsRef<str>,
112    {
113        let s = self;
114        s.push();
115        s.renderer.font_family(&s.theme.fonts.heading)?;
116        s.renderer.font_size(s.theme.font_size + 6)?;
117        s.renderer.font_style(s.theme.styles.heading);
118        let size = s.text_transformed(text, None, None, None);
119        s.pop();
120        size
121    }
122
123    /// Draw monospace text to the current canvas.
124    ///
125    /// Returns the rendered `(width, height)` of the text, including any newlines or text
126    /// wrapping.
127    ///
128    /// # Errors
129    ///
130    /// If the renderer fails to draw to the current render target, then an error is returned.
131    ///
132    /// # Example
133    ///
134    /// ```
135    /// # use pix_engine::prelude::*;
136    /// # struct App { text_field: String, text_area: String};
137    /// # impl PixEngine for App {
138    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
139    ///     s.monospace("Monospace")?;
140    ///     Ok(())
141    /// }
142    /// # }
143    /// ```
144    pub fn monospace<S>(&mut self, text: S) -> PixResult<(u32, u32)>
145    where
146        S: AsRef<str>,
147    {
148        let s = self;
149        s.push();
150        s.renderer.font_family(&s.theme.fonts.monospace)?;
151        s.renderer.font_size(s.theme.font_size + 2)?;
152        s.renderer.font_style(s.theme.styles.monospace);
153        let size = s.text_transformed(text, None, None, None);
154        s.pop();
155        size
156    }
157
158    /// Draw transformed text to the current canvas, optionally rotated about a `center` by `angle`
159    /// or `flipped`. `angle` can be in radians or degrees depending on [`AngleMode`].
160    ///
161    /// Returns the rendered `(width, height)` of the text, including any newlines or text
162    /// wrapping.
163    ///
164    /// # Errors
165    ///
166    /// If the renderer fails to draw to the current render target, then an error is returned.
167    ///
168    /// # Example
169    ///
170    /// ```
171    /// # use pix_engine::prelude::*;
172    /// # struct App { text_field: String, text_area: String};
173    /// # impl PixEngine for App {
174    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
175    ///     s.angle_mode(AngleMode::Degrees);
176    ///     let angle = 10.0;
177    ///     let center = point!(10, 10);
178    ///     s.text_transformed("Transformed text", angle, center, Flipped::Horizontal)?;
179    ///     Ok(())
180    /// }
181    /// # }
182    /// ```
183    pub fn text_transformed<S, A, C, F>(
184        &mut self,
185        text: S,
186        angle: A,
187        center: C,
188        flipped: F,
189    ) -> PixResult<(u32, u32)>
190    where
191        S: AsRef<str>,
192        A: Into<Option<f64>>,
193        C: Into<Option<Point<i32>>>,
194        F: Into<Option<Flipped>>,
195    {
196        let text = text.as_ref();
197        let angle = angle.into();
198        let center = center.into();
199        let flipped = flipped.into();
200
201        let s = &self.settings;
202        let fill = s.fill.unwrap_or(Color::TRANSPARENT);
203
204        let rect = {
205            let stroke_size = match (s.stroke, s.stroke_weight) {
206                (Some(stroke), weight) if weight > 0 => {
207                    Some(self.render_text(text, stroke, weight, angle, center, flipped)?)
208                }
209                _ => None,
210            };
211            let text_size = self.render_text(text, fill, 0, angle, center, flipped)?;
212            stroke_size.unwrap_or(text_size)
213        };
214        // EXPL: Add some bottom/right padding
215        let rect = rect.offset_size([3, 3]);
216        self.advance_cursor(rect.size());
217
218        Ok((rect.width() as u32, rect.height() as u32))
219    }
220
221    /// Draw bulleted text to the current canvas.
222    ///
223    /// Returns the rendered `(width, height)` of the text, including any newlines or text
224    /// wrapping.
225    ///
226    /// # Errors
227    ///
228    /// If the renderer fails to draw to the current render target, then an error is returned.
229    ///
230    /// # Example
231    ///
232    /// ```
233    /// # use pix_engine::prelude::*;
234    /// # struct App { text_field: String, text_area: String};
235    /// # impl PixEngine for App {
236    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
237    ///     s.bullet("Bulleted text")?;
238    ///     Ok(())
239    /// }
240    /// # }
241    /// ```
242    pub fn bullet<S>(&mut self, text: S) -> PixResult<(u32, u32)>
243    where
244        S: AsRef<str>,
245    {
246        let s = self;
247        let ipad = s.theme.spacing.item_pad;
248        let font_size = clamp_size(s.theme.font_size);
249        let pos = s.cursor_pos();
250
251        let r = font_size / 5;
252
253        s.push();
254        s.ellipse_mode(EllipseMode::Corner);
255        s.circle([pos.x() + ipad.x(), pos.y() + font_size / 2, r])?;
256        s.pop();
257
258        s.set_cursor_pos([pos.x() + ipad.x() + 2 * r + 2 * ipad.x(), pos.y()]);
259        let (w, h) = s.text_transformed(text, 0.0, None, None)?;
260
261        Ok((w + r as u32, h))
262    }
263
264    /// Draw a text menu to the current canvas which returns true when clicked.
265    ///
266    /// # Errors
267    ///
268    /// If the renderer fails to draw to the current render target, then an error is returned.
269    pub fn menu<S>(&mut self, text: S) -> PixResult<bool>
270    where
271        S: AsRef<str>,
272    {
273        let text = text.as_ref();
274
275        let s = self;
276        let id = s.ui.get_id(&text);
277        let text = s.ui.get_label(text);
278        let pos = s.cursor_pos();
279        let fpad = s.theme.spacing.frame_pad;
280
281        // Calculate hover size
282        let (width, height) = s.text_size(text)?;
283        let width = s.ui.next_width.take().unwrap_or(width + 2 * fpad.x());
284
285        let hover = rect![pos, width, height + 2 * fpad.y()];
286        let hovered = s.focused() && s.ui.try_hover(id, &hover);
287        let focused = s.focused() && s.ui.try_focus(id);
288        let active = s.ui.is_active(id);
289
290        s.push();
291        s.ui.push_cursor();
292
293        // Hover/Focused Rect
294        let [stroke, bg, fg] = if hovered {
295            s.widget_colors(id, ColorType::Secondary)
296        } else {
297            s.widget_colors(id, ColorType::Background)
298        };
299
300        if active || focused {
301            s.stroke(stroke);
302        } else {
303            s.stroke(None);
304        }
305        if hovered {
306            s.frame_cursor(&Cursor::hand())?;
307            s.fill(bg);
308        } else {
309            s.fill(None);
310        }
311        s.rect(hover)?;
312
313        // Text
314        s.stroke(None);
315        s.fill(fg);
316        s.set_cursor_pos([hover.x() + fpad.x(), hover.y() + fpad.y()]);
317        s.text_transformed(text, 0.0, None, None)?;
318
319        s.ui.pop_cursor();
320        s.pop();
321
322        // Process input
323        s.ui.handle_focus(id);
324        s.advance_cursor(hover.size());
325        Ok(!s.ui.disabled && s.ui.was_clicked(id))
326    }
327
328    /// Draw a collapsing text tree to the current canvas which returns true when the bullet is not
329    /// collapsed.
330    ///
331    /// # Errors
332    ///
333    /// If the renderer fails to draw to the current render target, then an error is returned.
334    pub fn collapsing_tree<S, F>(&mut self, text: S, f: F) -> PixResult<bool>
335    where
336        S: AsRef<str>,
337        F: FnOnce(&mut PixState) -> PixResult<()>,
338    {
339        let text = text.as_ref();
340
341        let s = self;
342        let id = s.ui.get_id(&text);
343        let text = s.ui.get_label(text);
344        let font_size = clamp_size(s.theme.font_size);
345        let pos = s.cursor_pos();
346        let fpad = s.theme.spacing.frame_pad;
347        let ipad = s.theme.spacing.item_pad;
348        let expanded = s.ui.expanded(id);
349        let arrow_width = font_size / 2;
350
351        // Calculate hover size
352        let (width, height) = s.text_size(text)?;
353        let column_offset = s.ui.column_offset();
354        let width =
355            s.ui.next_width
356                .take()
357                .unwrap_or_else(|| s.ui_width().unwrap_or(width));
358
359        let hover = rect![pos, width - column_offset, height + 2 * fpad.y()];
360        let hovered = s.focused() && s.ui.try_hover(id, &hover);
361        let focused = s.focused() && s.ui.try_focus(id);
362        let active = s.ui.is_active(id);
363
364        s.push();
365
366        // Hover/Focused Rect
367        let [stroke, bg, fg] = if hovered {
368            s.widget_colors(id, ColorType::Secondary)
369        } else {
370            s.widget_colors(id, ColorType::Background)
371        };
372
373        if active || focused {
374            s.stroke(stroke);
375        } else {
376            s.stroke(None);
377        }
378        if hovered {
379            s.frame_cursor(&Cursor::hand())?;
380            s.fill(bg);
381        } else {
382            s.fill(None);
383        }
384        s.rect(hover)?;
385
386        // Arrow
387        s.stroke(None);
388        s.fill(fg);
389        if expanded {
390            s.arrow(hover.top_left() + fpad, Direction::Down, 1.0)?;
391        } else {
392            s.arrow(hover.top_left() + fpad, Direction::Right, 1.0)?;
393        }
394
395        // Text
396        let bullet_offset = arrow_width + 3 * ipad.x();
397        s.set_cursor_pos([hover.x() + bullet_offset, hover.y() + fpad.y()]);
398        s.text_transformed(text, 0.0, None, None)?;
399
400        s.pop();
401
402        // Process input
403        if (hovered && s.ui.was_clicked(id)) || (focused && s.ui.key_entered() == Some(Key::Return))
404        {
405            s.ui.set_expanded(id, !expanded);
406        }
407        s.ui.handle_focus(id);
408
409        s.advance_cursor([hover.width(), ipad.y() / 2]);
410
411        if expanded {
412            let (indent_width, _) = s.text_size("    ")?;
413            s.ui.set_column_offset(indent_width);
414            f(s)?;
415            s.ui.reset_column_offset();
416        }
417
418        Ok(expanded)
419    }
420
421    /// Draw a collapsing header to the current canvas which returns true when the tree is not
422    /// collapsed.
423    ///
424    /// # Errors
425    ///
426    /// If the renderer fails to draw to the current render target, then an error is returned.
427    pub fn collapsing_header<S, F>(&mut self, text: S, f: F) -> PixResult<bool>
428    where
429        S: AsRef<str>,
430        F: FnOnce(&mut PixState) -> PixResult<()>,
431    {
432        let text = text.as_ref();
433
434        let s = self;
435        let id = s.ui.get_id(&text);
436        let text = s.ui.get_label(text);
437        let font_size = clamp_size(s.theme.font_size);
438        let pos = s.cursor_pos();
439        let fpad = s.theme.spacing.frame_pad;
440        let ipad = s.theme.spacing.item_pad;
441        let expanded = s.ui.expanded(id);
442        let arrow_width = font_size / 2;
443
444        // Calculate hover size
445        let (width, height) = s.text_size(text)?;
446        let column_offset = s.ui.column_offset();
447        let width =
448            s.ui.next_width
449                .take()
450                .unwrap_or_else(|| s.ui_width().unwrap_or(width));
451
452        let hover = rect![pos, width - column_offset, height + 2 * fpad.y()];
453        let hovered = s.focused() && s.ui.try_hover(id, &hover);
454        let focused = s.focused() && s.ui.try_focus(id);
455        let active = s.ui.is_active(id);
456
457        s.push();
458
459        let [stroke, bg, fg] = s.widget_colors(id, ColorType::Secondary);
460        if active || focused {
461            s.stroke(stroke);
462        } else {
463            s.stroke(None);
464        }
465        if hovered {
466            s.frame_cursor(&Cursor::hand())?;
467        }
468        s.fill(bg);
469        s.rect(hover)?;
470
471        // Arrow
472        s.stroke(None);
473        s.fill(fg);
474        if expanded {
475            s.arrow(hover.top_left() + fpad, Direction::Down, 1.0)?;
476        } else {
477            s.arrow(hover.top_left() + fpad, Direction::Right, 1.0)?;
478        }
479
480        // Text
481        let bullet_offset = arrow_width + 3 * ipad.x();
482        s.set_cursor_pos([hover.x() + bullet_offset, hover.y() + fpad.y()]);
483        s.text_transformed(text, 0.0, None, None)?;
484
485        s.pop();
486
487        // Process input
488        if (hovered && s.ui.was_clicked(id)) || (focused && s.ui.key_entered() == Some(Key::Return))
489        {
490            s.ui.set_expanded(id, !expanded);
491        }
492        s.ui.handle_focus(id);
493
494        s.advance_cursor([hover.width(), ipad.y() / 2]);
495
496        if expanded {
497            f(s)?;
498        }
499
500        Ok(expanded)
501    }
502}
503
504impl PixState {
505    #[inline]
506    fn render_text(
507        &mut self,
508        text: &str,
509        color: Color,
510        outline: u16,
511        angle: Option<f64>,
512        center: Option<Point<i32>>,
513        flipped: Option<Flipped>,
514    ) -> PixResult<Rect<i32>> {
515        let s = &self.settings;
516        let wrap_width = s.wrap_width;
517        let angle_mode = s.angle_mode;
518        let colors = self.theme.colors;
519        let ipad = self.theme.spacing.item_pad;
520
521        let mut pos = self.cursor_pos();
522        if s.rect_mode == RectMode::Center {
523            let (width, height) = self.size_of(text)?;
524            pos.offset([-(clamp_size(width) / 2), -(clamp_size(height) / 2)]);
525        };
526        if outline == 0 && s.stroke_weight > 0 {
527            pos += i32::from(s.stroke_weight);
528        }
529
530        self.push();
531
532        let color = if self.ui.disabled {
533            color.blended(colors.background, 0.38)
534        } else {
535            color
536        };
537        let wrap_width = if wrap_width.is_none() && text.contains('\n') {
538            text.lines()
539                .map(|line| {
540                    let (line_width, _) = self.renderer.size_of(line, None).unwrap_or_default();
541                    line_width
542                })
543                .max()
544                .map(|width| width + (pos.x() + ipad.x()) as u32)
545        } else {
546            wrap_width
547        };
548        let rect = if matches!(angle, Some(angle) if angle != 0.0) {
549            let angle = if angle_mode == AngleMode::Radians {
550                angle.map(f64::to_degrees)
551            } else {
552                angle
553            };
554            let (width, height) = self.renderer.size_of(text, wrap_width)?;
555            let rect = rect![0, 0, clamp_size(width), clamp_size(height)];
556            let rect = angle.map_or(rect, |angle| rect.rotated(angle.to_radians(), center));
557            self.renderer.text(
558                (pos - rect.top_left()).into(),
559                text,
560                wrap_width,
561                angle,
562                center,
563                flipped,
564                Some(color),
565                outline,
566            )?;
567            rect![pos, rect.width() + rect.left(), rect.height() + rect.top()]
568        } else {
569            let (width, height) = self.renderer.text(
570                pos,
571                text,
572                wrap_width,
573                None,
574                center,
575                flipped,
576                Some(color),
577                outline,
578            )?;
579            rect![pos, clamp_size(width), clamp_size(height)]
580        };
581
582        self.pop();
583        Ok(rect)
584    }
585}