Skip to main content

concinnity_core/components/
text_label.rs

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