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