pix_engine/gui/widgets/
field.rs

1//! Input field widget rendering methods.
2//!
3//! Provided [`PixState`] methods:
4//!
5//! - [`PixState::text_field`]
6//! - [`PixState::advanced_text_field`]
7//! - [`PixState::text_area`]
8//! - [`PixState::advanced_text_area`]
9//!
10//! # Example
11//!
12//! ```
13//! # use pix_engine::prelude::*;
14//! # struct App { text_field: String, text_area: String};
15//! # impl PixEngine for App {
16//! fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
17//!     s.text_field("Text Field", &mut self.text_field)?;
18//!     s.advanced_text_field(
19//!         "Filtered Text Field w/ hint",
20//!         "placeholder",
21//!         &mut self.text_field,
22//!         Some(char::is_numeric),
23//!     )?;
24//!
25//!     s.text_area("Text Area", 200, 100, &mut self.text_area)?;
26//!     s.advanced_text_area(
27//!         "Filtered Text Area w/ hint",
28//!         "placeholder",
29//!         200,
30//!         100,
31//!         &mut self.text_area,
32//!         None,
33//!         )?;
34//!     Ok(())
35//! }
36//! # }
37//! ```
38
39use crate::{gui::MOD_CTRL, ops::clamp_size, prelude::*};
40
41const TEXT_CURSOR: &str = "_";
42
43impl PixState {
44    /// Draw a text field to the current canvas.
45    ///
46    /// # Errors
47    ///
48    /// If the renderer fails to draw to the current render target, then an error is returned.
49    ///
50    /// # Example
51    ///
52    /// ```
53    /// # use pix_engine::prelude::*;
54    /// # struct App { text_field: String, text_area: String};
55    /// # impl PixEngine for App {
56    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
57    ///     s.text_field("Text Field", &mut self.text_field)?;
58    ///     Ok(())
59    /// }
60    /// # }
61    /// ```
62    pub fn text_field<L>(&mut self, label: L, value: &mut String) -> PixResult<bool>
63    where
64        L: AsRef<str>,
65    {
66        self.advanced_text_field(label, "", value, None)
67    }
68
69    /// Draw a text field with a placeholder hint to the current canvas.
70    ///
71    /// # Errors
72    ///
73    /// If the renderer fails to draw to the current render target, then an error is returned.
74    ///
75    /// # Example
76    ///
77    /// ```
78    /// # use pix_engine::prelude::*;
79    /// # struct App { text_field: String, text_area: String};
80    /// # impl PixEngine for App {
81    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
82    ///     s.advanced_text_field(
83    ///         "Filtered Text Field w/ hint",
84    ///         "placeholder",
85    ///         &mut self.text_field,
86    ///         Some(char::is_numeric),
87    ///     )?;
88    ///     Ok(())
89    /// }
90    /// # }
91    /// ```
92    pub fn advanced_text_field<L, H>(
93        &mut self,
94        label: L,
95        hint: H,
96        value: &mut String,
97        filter: Option<fn(char) -> bool>,
98    ) -> PixResult<bool>
99    where
100        L: AsRef<str>,
101        H: AsRef<str>,
102    {
103        let label = label.as_ref();
104        let hint = hint.as_ref();
105
106        let s = self;
107        let id = s.ui.get_id(&label);
108        let label = s.ui.get_label(label);
109        let pos = s.cursor_pos();
110        let spacing = s.theme.spacing;
111        let ipad = spacing.item_pad;
112
113        // Calculate input rect
114        let width =
115            s.ui.next_width
116                .take()
117                .unwrap_or_else(|| s.ui_width().unwrap_or(100));
118        let (label_width, label_height) = s.text_size(label)?;
119        let [mut x, y] = pos.coords();
120        if !label.is_empty() {
121            x += label_width + ipad.x();
122        }
123        let input = rect![x, y, width, label_height + 2 * ipad.y()];
124
125        // Check hover/active/keyboard focus
126        let hovered = s.focused() && s.ui.try_hover(id, &input);
127        let focused = s.focused() && s.ui.try_focus(id);
128        let disabled = s.ui.disabled;
129
130        s.push();
131        s.ui.push_cursor();
132
133        // Label
134        if !label.is_empty() {
135            s.set_cursor_pos([pos.x(), pos.y() + input.height() / 2 - label_height / 2]);
136            s.text(label)?;
137        }
138
139        // Input
140        s.rect_mode(RectMode::Corner);
141        if hovered {
142            s.frame_cursor(&Cursor::ibeam())?;
143        }
144        let [stroke, bg, fg] = s.widget_colors(id, ColorType::Background);
145        s.stroke(stroke);
146        s.fill(bg);
147        s.rect(input)?;
148
149        // Text
150        let clip = input.shrink(ipad);
151        let (text_width, text_height) = s.text_size(value)?;
152        let (cursor_width, _) = s.text_size(TEXT_CURSOR)?;
153        let width = text_width + cursor_width;
154        let (mut x, y) = (clip.x(), input.center().y() - text_height / 2);
155        if width > clip.width() {
156            x -= width - clip.width();
157        }
158
159        s.wrap(None);
160        s.set_cursor_pos([x, y]);
161        s.clip(clip)?;
162        s.stroke(None);
163        s.fill(fg);
164        if value.is_empty() {
165            // FIXME: push and pop disabled state instead
166            s.ui.push_cursor();
167            s.disable(true);
168            s.text(hint)?;
169            if !disabled {
170                s.disable(false);
171            }
172            s.ui.pop_cursor();
173            if focused {
174                s.text(TEXT_CURSOR)?;
175            }
176        } else if focused {
177            s.text(format!("{value}{TEXT_CURSOR}"))?;
178        } else {
179            s.text(&value)?;
180        }
181
182        s.clip(None)?;
183        s.ui.pop_cursor();
184        s.pop();
185
186        // Process input
187        let changed = focused && {
188            if let Some(Key::Return | Key::Escape) = s.ui.key_entered() {
189                s.ui.blur();
190            }
191            s.handle_text_events(value)?
192        };
193        if changed {
194            value.retain(|c| !c.is_control());
195            if let Some(filter) = filter {
196                value.retain(filter);
197            }
198        }
199        s.ui.handle_focus(id);
200        s.advance_cursor([input.right() - pos.x(), input.height()]);
201
202        Ok(changed)
203    }
204
205    /// Draw a text area field to the current canvas.
206    ///
207    /// # Errors
208    ///
209    /// If the renderer fails to draw to the current render target, then an error is returned.
210    ///
211    /// # Example
212    ///
213    /// ```
214    /// # use pix_engine::prelude::*;
215    /// # struct App { text_field: String, text_area: String};
216    /// # impl PixEngine for App {
217    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
218    ///     s.text_area("Text Area", 200, 100, &mut self.text_area)?;
219    ///     Ok(())
220    /// }
221    /// # }
222    /// ```
223    pub fn text_area<L>(
224        &mut self,
225        label: L,
226        width: u32,
227        height: u32,
228        value: &mut String,
229    ) -> PixResult<bool>
230    where
231        L: AsRef<str>,
232    {
233        self.advanced_text_area(label, "", width, height, value, None)
234    }
235
236    /// Draw a text area field with a placeholder hint to the current canvas.
237    ///
238    /// # Errors
239    ///
240    /// If the renderer fails to draw to the current render target, then an error is returned.
241    ///
242    /// # Example
243    ///
244    /// ```
245    /// # use pix_engine::prelude::*;
246    /// # struct App { text_field: String, text_area: String};
247    /// # impl PixEngine for App {
248    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
249    ///     s.advanced_text_area(
250    ///         "Filtered Text Area w/ hint",
251    ///         "placeholder",
252    ///         200,
253    ///         100,
254    ///         &mut self.text_area,
255    ///         Some(char::is_alphabetic),
256    ///     )?;
257    ///     Ok(())
258    /// }
259    /// # }
260    /// ```
261    pub fn advanced_text_area<L, H>(
262        &mut self,
263        label: L,
264        hint: H,
265        width: u32,
266        height: u32,
267        value: &mut String,
268        filter: Option<fn(char) -> bool>,
269    ) -> PixResult<bool>
270    where
271        L: AsRef<str>,
272        H: AsRef<str>,
273    {
274        let label = label.as_ref();
275        let hint = hint.as_ref();
276
277        let s = self;
278        let id = s.ui.get_id(&label);
279        let label = s.ui.get_label(label);
280        let pos = s.cursor_pos();
281        let spacing = s.theme.spacing;
282        let ipad = spacing.item_pad;
283
284        // Calculate input rect
285        let (label_width, label_height) = s.text_size(label)?;
286        let [x, mut y] = pos.coords();
287        if !label.is_empty() {
288            y += label_height + 2 * ipad.y();
289        }
290        let input = rect![x, y, clamp_size(width), clamp_size(height)];
291
292        // Check hover/active/keyboard focus
293        let hovered = s.focused() && s.ui.try_hover(id, &input);
294        let focused = s.focused() && s.ui.try_focus(id);
295        let disabled = s.ui.disabled;
296
297        s.push();
298        s.ui.push_cursor();
299
300        // Label
301        if !label.is_empty() {
302            s.set_cursor_pos([pos.x(), pos.y() + ipad.y()]);
303            s.text(label)?;
304        }
305
306        // Input
307        s.rect_mode(RectMode::Corner);
308        if hovered {
309            s.frame_cursor(&Cursor::ibeam())?;
310        }
311        let [stroke, bg, fg] = s.widget_colors(id, ColorType::Background);
312        s.stroke(stroke);
313        s.fill(bg);
314        s.rect(input)?;
315
316        // Text
317        let clip = input.shrink(ipad);
318        let scroll = s.ui.scroll(id);
319        s.wrap(clip.width() as u32);
320        let mut text_pos = input.top_left();
321        text_pos.offset(ipad - scroll);
322
323        s.set_cursor_pos(text_pos);
324        s.clip(clip)?;
325        s.stroke(None);
326        s.fill(fg);
327        let (_, text_height) = if value.is_empty() {
328            // FIXME: push and pop disabled state instead
329            s.ui.push_cursor();
330            s.disable(true);
331            let size = s.text(hint)?;
332            if !disabled {
333                s.disable(false);
334            }
335            s.ui.pop_cursor();
336            if focused {
337                s.text(TEXT_CURSOR)?;
338            }
339            size
340        } else if focused {
341            s.text(format!("{value}{TEXT_CURSOR}"))?
342        } else {
343            s.text(&value)?
344        };
345
346        // Process input
347        let mut text_height = clamp_size(text_height) + 2 * ipad.y();
348        let changed = focused && {
349            match s.ui.key_entered() {
350                Some(Key::Return) => {
351                    value.push('\n');
352                    true
353                }
354                Some(Key::Escape) => {
355                    s.ui.blur();
356                    false
357                }
358                _ => s.handle_text_events(value)?,
359            }
360        };
361
362        if changed {
363            value.retain(|c| c == '\n' || !c.is_control());
364            if let Some(filter) = filter {
365                value.retain(filter);
366            }
367            let (_, height) = s.text_size(&format!("{value}{TEXT_CURSOR}"))?;
368            text_height = height + 2 * ipad.y();
369
370            // Keep cursor within scroll region
371            let mut scroll = s.ui.scroll(id);
372            if text_height < input.height() {
373                scroll.set_y(0);
374            } else {
375                scroll.set_y(text_height - input.height());
376            }
377            s.ui.set_scroll(id, scroll);
378        }
379
380        s.clip(None)?;
381        s.ui.pop_cursor();
382        s.pop();
383
384        s.ui.handle_focus(id);
385        // Scrollbars
386        let rect = s.scroll(id, input, 0, text_height)?;
387        s.advance_cursor([rect.width().max(label_width), rect.bottom() - pos.y()]);
388
389        Ok(changed)
390    }
391}
392
393impl PixState {
394    /// Helper to handle text entry and text shortcuts.
395    fn handle_text_events(&mut self, value: &mut String) -> PixResult<bool> {
396        let s = self;
397        let mut changed = false;
398        if let Some(key) = s.ui.key_entered() {
399            match key {
400                Key::Backspace if !value.is_empty() => {
401                    if s.keymod_down(MOD_CTRL) {
402                        value.clear();
403                    } else if s.keymod_down(KeyMod::ALT) {
404                        // If last char is whitespace, remove it so we find the next previous
405                        // word
406                        if value.chars().last().map(char::is_whitespace) == Some(true) {
407                            value.pop();
408                        }
409                        if let Some(idx) = value.rfind(char::is_whitespace) {
410                            value.truncate(idx + 1);
411                        } else {
412                            value.clear();
413                        }
414                    } else {
415                        value.pop();
416                    }
417                    changed = true;
418                }
419                Key::X if s.keymod_down(MOD_CTRL) => {
420                    s.set_clipboard_text(&value)?;
421                    value.clear();
422                    changed = true;
423                }
424                Key::C if s.keymod_down(MOD_CTRL) => {
425                    s.set_clipboard_text(&value)?;
426                }
427                Key::V if s.keymod_down(MOD_CTRL) => {
428                    value.push_str(&s.clipboard_text());
429                    changed = true;
430                }
431                _ => (),
432            }
433        }
434        if let Some(text) = s.ui.keys.typed.take() {
435            value.push_str(&text);
436            changed = true;
437        }
438        Ok(changed)
439    }
440}