Skip to main content

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}