Skip to main content

concinnity_core/components/
text_input.rs

1// Editable single-line text-field 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/// An editable single-line text field drawn as a UI overlay.
11///
12/// A filled rounded box showing the typed `content` (or a dimmer `placeholder`
13/// while empty), plus a caret when the field holds keyboard focus. The engine
14/// gives focus to the field the cursor clicks, appends the characters typed that
15/// frame, and moves or edits at the caret with the arrow / Home / End /
16/// Backspace / Delete keys. Read `content` back to use what the player typed;
17/// set it to pre-fill the field.
18///
19/// Like other overlay elements it belongs to a [Screen](#screen) resolved from the
20/// naming convention (`<screen>_*`), or is always shown when it has none.
21///
22/// ```rust
23/// # use concinnity_core::components::TextInput;
24/// TextInput {
25///     placeholder: "Enter your name".into(),
26///     x: 400.0,
27///     y: 300.0,
28///     width: 480.0,
29///     height: 48.0,
30///     max_len: 24,
31///     ..Default::default()
32/// };
33/// ```
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35#[serde(default)]
36pub struct TextInput {
37    /// Asset identity; injected via `inject_name`. Not part of `args`.
38    #[serde(skip)]
39    pub asset_id: AssetId,
40    /// The [Font](#font) used to render the field's text. Unset draws with the
41    /// engine's built-in face at its native 24px.
42    #[serde(deserialize_with = "de_opt_font_handle")]
43    pub font: Option<FontHandle>,
44    /// The current text. Edited in place as the player types; set an initial
45    /// value here to pre-fill the field.
46    pub content: String,
47    /// Dimmer prompt shown while `content` is empty and the field is unfocused.
48    pub placeholder: String,
49    /// Left edge in screen pixels from the window's top-left.
50    pub x: f32,
51    /// Top edge in screen pixels from the window's top-left.
52    pub y: f32,
53    /// Field width in screen pixels.
54    pub width: f32,
55    /// Field height in screen pixels.
56    pub height: f32,
57    /// Uniform scale applied on top of the font's `size_px` (24 for the
58    /// built-in face). 1.0 = native size.
59    pub scale: f32,
60    /// Linear-space RGB colour of the typed text.
61    pub text_color: [f32; 3],
62    /// Linear-space RGB colour of the placeholder prompt.
63    pub placeholder_color: [f32; 3],
64    /// RGBA fill of the field's background box, each channel in [0, 1].
65    pub background: [f32; 4],
66    /// Linear-space RGB colour of the caret bar.
67    pub caret_color: [f32; 3],
68    /// Corner rounding radius of the background box, in field pixels.
69    pub corner_radius: f32,
70    /// Inner horizontal inset from the box edge to the text, in pixels.
71    pub padding: f32,
72    /// Maximum number of characters accepted. 0 means no limit.
73    pub max_len: u32,
74    /// When false the field is skipped each frame and cannot take focus.
75    pub visible: bool,
76    /// How a screen-owned field maps from the reference canvas to the window when
77    /// their aspect ratios differ (matches [Sprite](#sprite)'s `fit`).
78    pub fit: SpriteFit,
79    /// [Screen](#screen) this field belongs to. Resolved automatically from
80    /// the naming convention (`<screen>_*`); you don't set this directly.
81    /// `None` means the field is always visible.
82    #[serde(default, deserialize_with = "de_opt_asset_ref")]
83    pub screen: Option<AssetId>,
84    /// Runtime keyboard-focus flag, set by the engine while this is the active
85    /// field. Not authored and not serialized to a blob.
86    #[serde(skip)]
87    pub focused: bool,
88    /// Runtime inline-completion suffix, drawn in the placeholder colour after
89    /// the typed content while the field holds focus. Set by whoever drives the
90    /// field (e.g. an autocomplete); never edited by typing. Not authored and
91    /// not serialized to a blob.
92    #[serde(skip)]
93    pub ghost: String,
94    /// Runtime caret position as a character index into `content`. Not authored
95    /// and not serialized to a blob.
96    #[serde(skip)]
97    pub caret: usize,
98}
99
100impl Default for TextInput {
101    fn default() -> Self {
102        Self {
103            asset_id: AssetId::default(),
104            font: None,
105            content: String::new(),
106            placeholder: String::new(),
107            x: 0.0,
108            y: 0.0,
109            width: 240.0,
110            height: 40.0,
111            scale: 1.0,
112            text_color: [0.95, 0.95, 0.97],
113            placeholder_color: [0.55, 0.55, 0.60],
114            background: [0.10, 0.10, 0.13, 1.0],
115            caret_color: [0.95, 0.95, 0.97],
116            corner_radius: 4.0,
117            padding: 8.0,
118            max_len: 0,
119            visible: true,
120            fit: SpriteFit::Fit,
121            screen: None,
122            focused: false,
123            ghost: String::new(),
124            caret: 0,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn a_blank_field_is_visible_empty_and_unfocused() {
135        let t = TextInput::default();
136        assert!(t.content.is_empty());
137        assert!(t.placeholder.is_empty());
138        assert_eq!((t.width, t.height), (240.0, 40.0));
139        assert_eq!(t.scale, 1.0);
140        assert_eq!(t.corner_radius, 4.0);
141        assert_eq!(t.padding, 8.0);
142        // Zero means "no length limit", not "accepts nothing".
143        assert_eq!(t.max_len, 0);
144        assert!(t.visible);
145        assert_eq!(t.fit, SpriteFit::Fit);
146        // Edit state is runtime-only.
147        assert!(!t.focused);
148        assert!(t.ghost.is_empty());
149        assert_eq!(t.caret, 0);
150        assert!(t.font.is_none());
151        assert!(t.screen.is_none());
152    }
153
154    #[test]
155    fn edit_state_is_runtime_only_and_never_rides_the_wire() {
156        crate::test_support::install_resolvers();
157        let t: TextInput = serde_json::from_str(
158            r#"{"font":"body","content":"hello","placeholder":"name","max_len":32,
159                "screen":"menu","focused":true,"caret":5,"ghost":"world"}"#,
160        )
161        .unwrap();
162        assert_eq!(t.font, Some(FontHandle(4)));
163        assert_eq!(t.content, "hello");
164        assert_eq!(t.screen, Some(AssetId(4)));
165        // Focus, caret, and the completion ghost are skipped on the way in.
166        assert!(!t.focused);
167        assert_eq!(t.caret, 0);
168        assert!(t.ghost.is_empty());
169
170        let bytes = postcard::to_allocvec(&t).unwrap();
171        let back: TextInput = postcard::from_bytes(&bytes).unwrap();
172        assert_eq!(back.content, "hello");
173        assert_eq!(back.placeholder, "name");
174        assert_eq!(back.max_len, 32);
175        assert_eq!(back.font, Some(FontHandle(4)));
176        assert!(!back.focused);
177        assert_eq!(back.asset_id, AssetId::default());
178    }
179
180    #[test]
181    fn an_authored_style_parses_and_round_trips_through_postcard() {
182        let t: TextInput = serde_json::from_str(
183            r#"{"x":12,"y":24,"width":300,"height":36,"scale":1.5,"text_color":[1,1,1],
184                "placeholder_color":[0.4,0.4,0.4],"background":[0,0,0,1],
185                "caret_color":[1,0,0],"corner_radius":0,"padding":4,"visible":false,
186                "fit":"bottom"}"#,
187        )
188        .unwrap();
189        let bytes = postcard::to_allocvec(&t).unwrap();
190        let back: TextInput = postcard::from_bytes(&bytes).unwrap();
191        assert_eq!((back.x, back.y), (12.0, 24.0));
192        assert_eq!(back.scale, 1.5);
193        assert_eq!(back.placeholder_color, [0.4, 0.4, 0.4]);
194        assert_eq!(back.background, [0.0, 0.0, 0.0, 1.0]);
195        assert_eq!(back.caret_color, [1.0, 0.0, 0.0]);
196        assert_eq!(back.padding, 4.0);
197        assert!(!back.visible);
198        assert_eq!(back.fit, SpriteFit::Bottom);
199    }
200}