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}