Skip to main content

concinnity_asset/
text_label.rs

1// Screen-space UI text-label schema.
2
3use crate::{AssetId, FontHandle, SpriteFit, de_opt_asset_ref, de_opt_font_handle};
4use alloc::string::String;
5
6/// Horizontal alignment of a [TextLabel](#textlabel) relative to its `x`.
7///
8/// `Center` and `Right` measure the rendered text with the real font metrics
9/// each frame, so a label stays visually centered (or right-aligned) at any
10/// scale without the author estimating glyph widths.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
12#[serde(rename_all = "lowercase")]
13pub enum TextAlign {
14    /// `x` is the left edge of the text (the default).
15    #[default]
16    Left,
17    /// `x` is the horizontal center of the text.
18    Center,
19    /// `x` is the right edge of the text.
20    Right,
21}
22
23/// Screen-space text drawn as a UI overlay on top of the 3D scene each frame.
24///
25/// Text is laid out using the referenced [Font](#font). A label naming none
26/// draws with the engine's built-in face at 24px, which costs the build nothing
27/// (the face ships inside the binary) and renders the same whether the world was
28/// compiled or assembled in code. The `content` field can be updated every frame
29/// (e.g. by an [FpsCounter](#fpscounter)).
30///
31/// A `\n` in `content` starts a new line. When `background` has an alpha > 0, a
32/// box is filled behind the glyphs, extended outward by `padding` pixels,
33/// useful for HUD chips.
34///
35/// ```rust
36/// # use concinnity_asset::TextLabel;
37/// TextLabel {
38///     content: "FPS: --".into(),
39///     x: 10.0,
40///     y: 10.0,
41///     color: [1.0, 1.0, 1.0],
42///     scale: 1.0,
43///     ..Default::default()
44/// };
45/// ```
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47#[serde(default)]
48pub struct TextLabel {
49    /// Asset identity; injected via `inject_name`. Not part of `args`.
50    #[serde(skip)]
51    pub asset_id: AssetId,
52    /// The [Font](#font) asset to use for rendering. Unset draws with the
53    /// engine's built-in face at its native 24px.
54    #[serde(deserialize_with = "de_opt_font_handle")]
55    pub font: Option<FontHandle>,
56    /// Text to display. Can be updated each frame.
57    pub content: String,
58    /// Horizontal position in pixels from the left edge of the window.
59    pub x: f32,
60    /// Vertical position in pixels from the top edge of the window.
61    pub y: f32,
62    /// Linear-space RGB text colour.
63    pub color: [f32; 3],
64    /// Uniform scale applied on top of the font's `size_px` (24 for the
65    /// built-in face). 1.0 = native size. Ignored when `centered` is set, which
66    /// sizes the text to the viewport instead.
67    pub scale: f32,
68    /// When true, fit the label to the viewport and center it there each frame,
69    /// so `x`, `y`, `align` and `scale` are all ignored.
70    pub centered: bool,
71    /// Horizontal alignment relative to `x` (measured with the real font
72    /// metrics). Ignored when `centered` is set.
73    pub align: TextAlign,
74    /// How a screen-owned label maps from the reference canvas to the window when
75    /// their aspect ratios differ (matches [Sprite](#sprite)'s `fit`). `Bottom`
76    /// keeps a label flush with a bottom-anchored sprite it labels.
77    pub fit: SpriteFit,
78    /// RGBA fill of a box drawn behind the text. An alpha of 0 (the default)
79    /// draws no box; any alpha > 0 draws the box at that opacity.
80    pub background: [f32; 4],
81    /// Pixels the background box extends past the text on every side. Only
82    /// meaningful when `background` is visible.
83    pub padding: f32,
84    /// Width in the label's own pixels that text wraps within. `0` (the
85    /// default) never wraps, so the text runs as far as it needs to. Any
86    /// greater value breaks the content into lines at word boundaries, using
87    /// the real font metrics, splitting a word only when it cannot fit a line
88    /// on its own. Authored newlines are kept as breaks either way. Ignored
89    /// when `centered` is set, since a centered label is sized to the viewport
90    /// rather than to a container.
91    pub wrap_width: f32,
92    /// Most lines the label draws. `0` (the default) draws every line. When the
93    /// text needs more than this, the last drawn line ends in an ellipsis, so
94    /// text bounded by `wrap_width` is bounded in both directions and can never
95    /// spill out of the box that holds it.
96    pub max_lines: u32,
97    /// When false, the label is hidden.
98    pub visible: bool,
99    /// [Screen](#screen) this label belongs to. Resolved automatically from
100    /// the naming convention (`<screen>_*`); you don't set this directly.
101    /// `None` means the label is always visible.
102    #[serde(default, deserialize_with = "de_opt_asset_ref")]
103    pub screen: Option<AssetId>,
104}
105
106impl Default for TextLabel {
107    fn default() -> Self {
108        Self {
109            asset_id: AssetId::default(),
110            font: None,
111            content: String::new(),
112            x: 10.0,
113            y: 10.0,
114            color: [1.0, 1.0, 1.0],
115            scale: 1.0,
116            centered: false,
117            align: TextAlign::Left,
118            fit: SpriteFit::Fit,
119            background: [0.0, 0.0, 0.0, 0.0],
120            padding: 0.0,
121            wrap_width: 0.0,
122            max_lines: 0,
123            visible: true,
124            screen: None,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn a_blank_label_draws_white_left_aligned_text_with_no_background() {
135        let l = TextLabel::default();
136        assert!(l.content.is_empty());
137        assert_eq!((l.x, l.y), (10.0, 10.0));
138        assert_eq!(l.color, [1.0, 1.0, 1.0]);
139        assert_eq!(l.scale, 1.0);
140        assert!(!l.centered);
141        assert_eq!(l.align, TextAlign::Left);
142        assert_eq!(l.fit, SpriteFit::Fit);
143        // A fully transparent background is what suppresses the chip box.
144        assert_eq!(l.background, [0.0, 0.0, 0.0, 0.0]);
145        assert_eq!(l.padding, 0.0);
146        // Zero means unbounded: no wrapping and no line cap.
147        assert_eq!(l.wrap_width, 0.0);
148        assert_eq!(l.max_lines, 0);
149        assert!(l.visible);
150        assert!(l.font.is_none());
151        assert!(l.screen.is_none());
152        assert_eq!(TextAlign::default(), TextAlign::Left);
153    }
154
155    #[test]
156    fn alignment_names_parse_in_lowercase() {
157        let a = |s: &str| serde_json::from_str::<TextAlign>(s).unwrap();
158        assert_eq!(a(r#""left""#), TextAlign::Left);
159        assert_eq!(a(r#""center""#), TextAlign::Center);
160        assert_eq!(a(r#""right""#), TextAlign::Right);
161        assert_eq!(
162            serde_json::to_string(&TextAlign::Center).unwrap(),
163            r#""center""#
164        );
165    }
166
167    #[test]
168    fn a_wrapped_chip_parses_and_round_trips_through_postcard() {
169        crate::test_support::install_resolvers();
170        let l: TextLabel = serde_json::from_str(
171            r#"{"font":"body","content":"Hello there","x":20,"y":40,"color":[1,0.9,0.5],
172                "scale":1.25,"centered":true,"align":"right","fit":"cover",
173                "background":[0,0,0,0.6],"padding":6,"wrap_width":320,"max_lines":3,
174                "visible":false,"screen":"menu"}"#,
175        )
176        .unwrap();
177        assert_eq!(l.font, Some(FontHandle(4)));
178        assert_eq!(l.screen, Some(AssetId(4)));
179        assert_eq!(l.align, TextAlign::Right);
180        assert!(l.centered);
181        assert!(!l.visible);
182
183        let bytes = postcard::to_allocvec(&l).unwrap();
184        let back: TextLabel = postcard::from_bytes(&bytes).unwrap();
185        assert_eq!(back.content, "Hello there");
186        assert_eq!(back.color, [1.0, 0.9, 0.5]);
187        assert_eq!(back.scale, 1.25);
188        assert_eq!(back.fit, SpriteFit::Cover);
189        assert_eq!(back.background, [0.0, 0.0, 0.0, 0.6]);
190        assert_eq!(back.padding, 6.0);
191        assert_eq!(back.wrap_width, 320.0);
192        assert_eq!(back.max_lines, 3);
193        assert_eq!(back.asset_id, AssetId::default());
194    }
195}