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}