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