mittens_engine/engine/ecs/component/text.rs
1use crate::engine::ecs::ComponentId;
2use crate::engine::ecs::component::Component;
3
4/// Text component.
5///
6/// On registration, `TextSystem` expands this into per-glyph component trees.
7#[derive(Debug, Clone)]
8pub struct TextComponent {
9 pub text: String,
10
11 /// Effective visual scale applied to spawned glyph quads.
12 ///
13 /// This affects glyph rendering only; layout still measures text in glyph
14 /// columns. `Style.font_size` may temporarily override this value during
15 /// layout, while `authored_font_size` preserves the authored/default size.
16 pub font_size: f32,
17
18 /// Author-provided font size. Preserved when layout applies or removes a
19 /// style-driven override so the effective `font_size` can be restored.
20 pub authored_font_size: f32,
21
22 /// Wrap after this many characters. This is the **effective** value used by
23 /// `TextSystem` for glyph layout. The layout pass may narrow it to fit the
24 /// containing block, but it never exceeds [`Self::authored_wrap_at`].
25 pub wrap_at: usize,
26
27 /// Author-provided wrap cap. Captured at construction (and on `decode`)
28 /// and never modified by the layout pass. Layout uses this as the upper
29 /// bound when re-deriving `wrap_at` from the current container width —
30 /// so when the container grows again, `wrap_at` can be widened back up
31 /// to (but not past) `authored_wrap_at`.
32 ///
33 /// **Invariant**: any future "set the wrap cap" mutation path (an MMS
34 /// `wrap_at(N)` setter, a Rust `set_wrap` helper, etc.) MUST update
35 /// both `wrap_at` and `authored_wrap_at`. Updating only `wrap_at` will
36 /// be silently undone by the next layout pass.
37 pub authored_wrap_at: usize,
38
39 /// If true, wrap only at whitespace boundaries (avoid breaking words).
40 /// When false, wraps strictly by character count.
41 ///
42 /// The layout pass may override this when the containing styled TC has
43 /// a `word_wrap` style — see [`Self::authored_word_wrap`] for the
44 /// authored/default value.
45 pub word_wrap: bool,
46
47 /// Author-provided word-wrap mode. Preserved when layout applies or removes
48 /// a style-driven override so the effective `word_wrap` can be restored.
49 pub authored_word_wrap: bool,
50
51 /// Tokens after which wrapping is allowed when `word_wrap == true`.
52 ///
53 /// This always includes whitespace tokens (space + tab) by default.
54 /// The layout pass may override this when the containing styled TC has
55 /// a `word_wrap_tokens` style — see [`Self::authored_word_wrap_tokens`].
56 pub word_wrap_tokens: Vec<String>,
57
58 /// Author-provided word-wrap tokens. Preserved alongside
59 /// [`Self::authored_word_wrap`] for the same reason.
60 pub authored_word_wrap_tokens: Vec<String>,
61
62 built: bool,
63 component: Option<ComponentId>,
64}
65
66impl TextComponent {
67 pub const DEFAULT_FONT_SIZE: f32 = 1.0;
68 /// Default authored wrap cap: `0` = no author cap. Layout still wraps to
69 /// fit the containing block; this default just means the author didn't
70 /// impose an additional column limit. To set an explicit cap, use
71 /// [`with_wrap`](Self::with_wrap) / [`with_word_wrap`](Self::with_word_wrap).
72 pub const DEFAULT_WRAP_AT: usize = 0;
73 pub const DEFAULT_WORD_WRAP_TOKENS: [&'static str; 2] = [" ", "\t"];
74
75 pub fn new(text: impl Into<String>) -> Self {
76 Self {
77 text: text.into(),
78 font_size: Self::DEFAULT_FONT_SIZE,
79 authored_font_size: Self::DEFAULT_FONT_SIZE,
80 wrap_at: Self::DEFAULT_WRAP_AT,
81 authored_wrap_at: Self::DEFAULT_WRAP_AT,
82 // Default to CSS `overflow-wrap: normal` semantics — only break at
83 // whitespace/token boundaries. `with_wrap` (which takes an explicit
84 // column cap) keeps the legacy hard-wrap behavior because callers
85 // using it generally want strict column control.
86 word_wrap: true,
87 authored_word_wrap: true,
88 word_wrap_tokens: Self::DEFAULT_WORD_WRAP_TOKENS
89 .iter()
90 .map(|s| s.to_string())
91 .collect(),
92 authored_word_wrap_tokens: Self::DEFAULT_WORD_WRAP_TOKENS
93 .iter()
94 .map(|s| s.to_string())
95 .collect(),
96 built: false,
97 component: None,
98 }
99 }
100
101 pub fn with_wrap(text: impl Into<String>, wrap_at: usize) -> Self {
102 Self {
103 text: text.into(),
104 font_size: Self::DEFAULT_FONT_SIZE,
105 authored_font_size: Self::DEFAULT_FONT_SIZE,
106 wrap_at,
107 authored_wrap_at: wrap_at,
108 word_wrap: false,
109 authored_word_wrap: false,
110 word_wrap_tokens: Self::DEFAULT_WORD_WRAP_TOKENS
111 .iter()
112 .map(|s| s.to_string())
113 .collect(),
114 authored_word_wrap_tokens: Self::DEFAULT_WORD_WRAP_TOKENS
115 .iter()
116 .map(|s| s.to_string())
117 .collect(),
118 built: false,
119 component: None,
120 }
121 }
122
123 /// Word-wrap (prefer wrapping at whitespace) aiming for `wrap_at` characters.
124 ///
125 /// If the line exceeds `wrap_at` and there was no whitespace to wrap at,
126 /// the line will continue (words are not broken).
127 pub fn with_word_wrap(text: impl Into<String>, wrap_at: usize) -> Self {
128 Self {
129 text: text.into(),
130 font_size: Self::DEFAULT_FONT_SIZE,
131 authored_font_size: Self::DEFAULT_FONT_SIZE,
132 wrap_at,
133 authored_wrap_at: wrap_at,
134 word_wrap: true,
135 authored_word_wrap: true,
136 word_wrap_tokens: Self::DEFAULT_WORD_WRAP_TOKENS
137 .iter()
138 .map(|s| s.to_string())
139 .collect(),
140 authored_word_wrap_tokens: Self::DEFAULT_WORD_WRAP_TOKENS
141 .iter()
142 .map(|s| s.to_string())
143 .collect(),
144 built: false,
145 component: None,
146 }
147 }
148
149 /// Word-wrap (prefer wrapping at whitespace / tokens) aiming for `wrap_at` characters.
150 ///
151 /// `tokens` are additional “wrap-allowed-after” sequences (e.g. "::", ".", ",").
152 /// Whitespace tokens (space + tab) are always included.
153 pub fn with_word_wrap_tokens<T, I>(text: impl Into<String>, wrap_at: usize, tokens: I) -> Self
154 where
155 I: IntoIterator<Item = T>,
156 T: Into<String>,
157 {
158 let mut all_tokens: Vec<String> = Self::DEFAULT_WORD_WRAP_TOKENS
159 .iter()
160 .map(|s| s.to_string())
161 .collect();
162 all_tokens.extend(tokens.into_iter().map(Into::into));
163
164 // Dedup, keep stable-ish order (defaults first).
165 all_tokens.sort();
166 all_tokens.dedup();
167
168 Self {
169 text: text.into(),
170 font_size: Self::DEFAULT_FONT_SIZE,
171 authored_font_size: Self::DEFAULT_FONT_SIZE,
172 wrap_at,
173 authored_wrap_at: wrap_at,
174 word_wrap: true,
175 authored_word_wrap: true,
176 word_wrap_tokens: all_tokens.clone(),
177 authored_word_wrap_tokens: all_tokens,
178 built: false,
179 component: None,
180 }
181 }
182
183 pub(crate) fn is_built(&self) -> bool {
184 self.built
185 }
186
187 pub(crate) fn mark_unbuilt(&mut self) {
188 self.built = false;
189 }
190
191 pub(crate) fn mark_built(&mut self) {
192 self.built = true;
193 }
194
195 pub fn with_font_size(mut self, font_size: f32) -> Self {
196 self.font_size = font_size;
197 self.authored_font_size = font_size;
198 self
199 }
200
201 pub fn set_font_size(&mut self, font_size: f32) {
202 self.font_size = font_size;
203 self.authored_font_size = font_size;
204 }
205
206 pub fn set_effective_font_size(&mut self, font_size: f32) {
207 self.font_size = font_size;
208 }
209}
210
211impl Component for TextComponent {
212 fn name(&self) -> &'static str {
213 "text"
214 }
215
216 fn set_id(&mut self, component: ComponentId) {
217 self.component = Some(component);
218 }
219
220 fn as_any(&self) -> &dyn std::any::Any {
221 self
222 }
223
224 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
225 self
226 }
227
228 fn init(&mut self, emit: &mut dyn crate::engine::ecs::SignalEmitter, component: ComponentId) {
229 let _ = self.component;
230 emit.push_intent_now(
231 component,
232 crate::engine::ecs::IntentValue::RegisterText {
233 component_ids: vec![component],
234 },
235 );
236 }
237
238 fn to_mms_ast(
239 &self,
240 _world: &crate::engine::ecs::World,
241 ) -> crate::scripting::ast::ComponentExpression {
242 use crate::engine::ecs::component::ce_helpers::*;
243 use crate::scripting::ast::{Expression, Statement};
244 let mut node = ce("Text");
245 if !self.text.is_empty() {
246 // `Text` consumes a positional string literal in its body
247 // (see `apply_positional` in component_registry).
248 node.body
249 .statements
250 .push(Statement::Expression(Expression::String(self.text.clone())));
251 }
252 if (self.authored_font_size - Self::DEFAULT_FONT_SIZE).abs() > f32::EPSILON {
253 node = node.with_call("font_size", nums([self.authored_font_size as f64]));
254 }
255 node
256 }
257}