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}