Skip to main content

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}