Skip to main content

jag_ui/elements/
text_area.rs

1//! Multi-line text area element.
2
3use jag_draw::{Brush, Color, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// A multi-line text area with vertical scrolling, cursor, and basic editing.
15///
16/// This is a simplified standalone version that stores text as a `String`.
17pub struct TextArea {
18    /// Bounding rect.
19    pub rect: Rect,
20    /// Current text content.
21    pub text: String,
22    /// Font size.
23    pub text_size: f32,
24    /// Text color.
25    pub text_color: ColorLinPremul,
26    /// Placeholder text when empty.
27    pub placeholder: Option<String>,
28    /// Whether this text area is focused.
29    pub focused: bool,
30    /// Background color.
31    pub bg_color: ColorLinPremul,
32    /// Border color.
33    pub border_color: ColorLinPremul,
34    /// Border width.
35    pub border_width: f32,
36    /// Corner radius.
37    pub corner_radius: f32,
38    /// Validation error message.
39    pub validation_error: Option<String>,
40    /// Cursor byte position.
41    pub cursor_position: usize,
42    /// Vertical scroll offset.
43    scroll_y: f32,
44    /// Horizontal padding.
45    padding_x: f32,
46    /// Vertical padding.
47    padding_y: f32,
48    /// Line height multiplier.
49    line_height_factor: f32,
50    /// Focus identifier.
51    pub focus_id: FocusId,
52}
53
54impl TextArea {
55    /// Create a new text area.
56    pub fn new(rect: Rect) -> Self {
57        Self {
58            rect,
59            text: String::new(),
60            text_size: 14.0,
61            text_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
62            placeholder: None,
63            focused: false,
64            bg_color: ColorLinPremul::from_srgba_u8([40, 40, 40, 255]),
65            border_color: ColorLinPremul::from_srgba_u8([80, 80, 80, 255]),
66            border_width: 1.0,
67            corner_radius: 4.0,
68            validation_error: None,
69            cursor_position: 0,
70            scroll_y: 0.0,
71            padding_x: 8.0,
72            padding_y: 8.0,
73            line_height_factor: 1.3,
74            focus_id: FocusId(0),
75        }
76    }
77
78    /// Get the current text content.
79    pub fn text(&self) -> &str {
80        &self.text
81    }
82
83    /// Set the text and move cursor to end.
84    pub fn set_text(&mut self, text: impl Into<String>) {
85        self.text = text.into();
86        self.cursor_position = self.text.len();
87    }
88
89    /// Set the placeholder text.
90    pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
91        self.placeholder = Some(placeholder.into());
92    }
93
94    /// Line height in logical pixels.
95    fn line_height(&self) -> f32 {
96        self.text_size * self.line_height_factor
97    }
98
99    /// Insert text at the cursor position.
100    pub fn insert_text(&mut self, s: &str) {
101        self.text.insert_str(self.cursor_position, s);
102        self.cursor_position += s.len();
103    }
104
105    /// Delete one character before the cursor.
106    pub fn delete_char_before(&mut self) {
107        if self.cursor_position == 0 {
108            return;
109        }
110        let prev = self.text[..self.cursor_position]
111            .char_indices()
112            .next_back()
113            .map(|(i, _)| i)
114            .unwrap_or(0);
115        self.text.drain(prev..self.cursor_position);
116        self.cursor_position = prev;
117    }
118
119    /// Delete one character after the cursor.
120    pub fn delete_char_after(&mut self) {
121        if self.cursor_position >= self.text.len() {
122            return;
123        }
124        let next = self.text[self.cursor_position..]
125            .char_indices()
126            .nth(1)
127            .map(|(i, _)| self.cursor_position + i)
128            .unwrap_or(self.text.len());
129        self.text.drain(self.cursor_position..next);
130    }
131
132    /// Move cursor left by one character.
133    pub fn move_cursor_left(&mut self) {
134        if self.cursor_position > 0 {
135            self.cursor_position = self.text[..self.cursor_position]
136                .char_indices()
137                .next_back()
138                .map(|(i, _)| i)
139                .unwrap_or(0);
140        }
141    }
142
143    /// Move cursor right by one character.
144    pub fn move_cursor_right(&mut self) {
145        if self.cursor_position < self.text.len() {
146            self.cursor_position = self.text[self.cursor_position..]
147                .char_indices()
148                .nth(1)
149                .map(|(i, _)| self.cursor_position + i)
150                .unwrap_or(self.text.len());
151        }
152    }
153
154    /// Move cursor home (beginning of current line).
155    pub fn move_cursor_home(&mut self) {
156        let before = &self.text[..self.cursor_position];
157        if let Some(nl) = before.rfind('\n') {
158            self.cursor_position = nl + 1;
159        } else {
160            self.cursor_position = 0;
161        }
162    }
163
164    /// Move cursor to end of current line.
165    pub fn move_cursor_end(&mut self) {
166        let after = &self.text[self.cursor_position..];
167        if let Some(nl) = after.find('\n') {
168            self.cursor_position += nl;
169        } else {
170            self.cursor_position = self.text.len();
171        }
172    }
173
174    /// Split text into lines for rendering.
175    fn lines(&self) -> Vec<&str> {
176        self.text.split('\n').collect()
177    }
178
179    /// Hit-test the text area rect.
180    pub fn hit_test(&self, x: f32, y: f32) -> bool {
181        x >= self.rect.x
182            && x <= self.rect.x + self.rect.w
183            && y >= self.rect.y
184            && y <= self.rect.y + self.rect.h
185    }
186}
187
188impl Default for TextArea {
189    fn default() -> Self {
190        Self::new(Rect {
191            x: 0.0,
192            y: 0.0,
193            w: 300.0,
194            h: 120.0,
195        })
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Element trait
201// ---------------------------------------------------------------------------
202
203impl Element for TextArea {
204    fn rect(&self) -> Rect {
205        self.rect
206    }
207
208    fn set_rect(&mut self, rect: Rect) {
209        self.rect = rect;
210    }
211
212    fn render(&self, canvas: &mut Canvas, z: i32) {
213        // Background + border
214        let rrect = RoundedRect {
215            rect: self.rect,
216            radii: RoundedRadii {
217                tl: self.corner_radius,
218                tr: self.corner_radius,
219                br: self.corner_radius,
220                bl: self.corner_radius,
221            },
222        };
223
224        let has_error = self.validation_error.is_some();
225        let border_color = if has_error {
226            Color::rgba(220, 38, 38, 255)
227        } else if self.focused {
228            Color::rgba(63, 130, 246, 255)
229        } else {
230            self.border_color
231        };
232        let border_width = if has_error {
233            self.border_width.max(2.0)
234        } else if self.focused {
235            (self.border_width + 1.0).max(2.0)
236        } else {
237            self.border_width
238        };
239
240        jag_surface::shapes::draw_snapped_rounded_rectangle(
241            canvas,
242            rrect,
243            Some(Brush::Solid(self.bg_color)),
244            Some(border_width),
245            Some(Brush::Solid(border_color)),
246            z,
247        );
248
249        let content_x = self.rect.x + self.padding_x;
250        let content_y = self.rect.y + self.padding_y;
251        let lh = self.line_height();
252
253        if self.text.is_empty() {
254            // Placeholder
255            if let Some(ref ph) = self.placeholder {
256                let ph_color = ColorLinPremul::from_srgba_u8([160, 160, 160, 255]);
257                let baseline = content_y + self.text_size * 0.85 - self.scroll_y;
258                canvas.draw_text_run_weighted(
259                    [content_x, baseline],
260                    ph.clone(),
261                    self.text_size,
262                    400.0,
263                    ph_color,
264                    z + 1,
265                );
266            }
267        } else {
268            // Render lines
269            let lines = self.lines();
270            for (i, line) in lines.iter().enumerate() {
271                let baseline = content_y + (i as f32 * lh) + self.text_size * 0.85 - self.scroll_y;
272                if baseline < self.rect.y - lh || baseline > self.rect.y + self.rect.h + lh {
273                    continue; // skip lines outside the viewport
274                }
275                canvas.draw_text_run_weighted(
276                    [content_x, baseline],
277                    line.to_string(),
278                    self.text_size,
279                    400.0,
280                    self.text_color,
281                    z + 1,
282                );
283            }
284        }
285
286        // Cursor
287        if self.focused {
288            let before_cursor = &self.text[..self.cursor_position];
289            let line_idx = before_cursor.matches('\n').count();
290            let line_start = if let Some(nl) = before_cursor.rfind('\n') {
291                nl + 1
292            } else {
293                0
294            };
295            let line_text = &self.text[line_start..self.cursor_position];
296            let cursor_offset = canvas.measure_text_width(line_text, self.text_size);
297
298            let cursor_x = content_x + cursor_offset;
299            let cursor_y = content_y + (line_idx as f32 * lh) + 2.0 - self.scroll_y;
300            let cursor_h = (lh - 4.0).max(0.0);
301
302            canvas.fill_rect(
303                cursor_x,
304                cursor_y,
305                1.5,
306                cursor_h,
307                Brush::Solid(self.text_color),
308                z + 2,
309            );
310        }
311
312        // Validation error
313        if let Some(ref error_msg) = self.validation_error {
314            let error_size = (self.text_size * 0.85).max(12.0);
315            let baseline_offset = error_size * 0.8;
316            let top_gap = 3.0;
317            let error_y = self.rect.y + self.rect.h + top_gap + baseline_offset;
318            let error_color = ColorLinPremul::from_srgba_u8([220, 38, 38, 255]);
319
320            canvas.draw_text_run_weighted(
321                [self.rect.x + self.padding_x, error_y],
322                error_msg.clone(),
323                error_size,
324                400.0,
325                error_color,
326                z + 3,
327            );
328        }
329    }
330
331    fn focus_id(&self) -> Option<FocusId> {
332        Some(self.focus_id)
333    }
334}
335
336// ---------------------------------------------------------------------------
337// EventHandler trait
338// ---------------------------------------------------------------------------
339
340impl EventHandler for TextArea {
341    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
342        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
343            return EventResult::Ignored;
344        }
345        if self.hit_test(event.x, event.y) {
346            EventResult::Handled
347        } else {
348            EventResult::Ignored
349        }
350    }
351
352    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
353        if event.state != ElementState::Pressed || !self.focused {
354            return EventResult::Ignored;
355        }
356        match event.key {
357            KeyCode::Backspace => {
358                self.delete_char_before();
359                EventResult::Handled
360            }
361            KeyCode::Delete => {
362                self.delete_char_after();
363                EventResult::Handled
364            }
365            KeyCode::ArrowLeft => {
366                self.move_cursor_left();
367                EventResult::Handled
368            }
369            KeyCode::ArrowRight => {
370                self.move_cursor_right();
371                EventResult::Handled
372            }
373            KeyCode::Home => {
374                self.move_cursor_home();
375                EventResult::Handled
376            }
377            KeyCode::End => {
378                self.move_cursor_end();
379                EventResult::Handled
380            }
381            KeyCode::Enter => {
382                self.insert_text("\n");
383                EventResult::Handled
384            }
385            _ => {
386                if let Some(ref text) = event.text
387                    && !text.is_empty()
388                    && text.chars().all(|c| !c.is_control() || c == ' ')
389                {
390                    self.insert_text(text);
391                    return EventResult::Handled;
392                }
393                EventResult::Ignored
394            }
395        }
396    }
397
398    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
399        EventResult::Ignored
400    }
401
402    fn handle_scroll(&mut self, event: &ScrollEvent) -> EventResult {
403        if self.hit_test(event.x, event.y) {
404            self.scroll_y = (self.scroll_y - event.delta_y).max(0.0);
405            EventResult::Handled
406        } else {
407            EventResult::Ignored
408        }
409    }
410
411    fn is_focused(&self) -> bool {
412        self.focused
413    }
414
415    fn set_focused(&mut self, focused: bool) {
416        self.focused = focused;
417        if focused {
418            self.cursor_position = self.text.len();
419        }
420    }
421
422    fn contains_point(&self, x: f32, y: f32) -> bool {
423        self.hit_test(x, y)
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Tests
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn text_area_defaults() {
437        let ta = TextArea::default();
438        assert!(ta.text.is_empty());
439        assert!(!ta.focused);
440        assert_eq!(ta.cursor_position, 0);
441    }
442
443    #[test]
444    fn text_area_set_text() {
445        let mut ta = TextArea::default();
446        ta.set_text("Line1\nLine2");
447        assert_eq!(ta.text(), "Line1\nLine2");
448        assert_eq!(ta.cursor_position, 11);
449    }
450
451    #[test]
452    fn text_area_insert_newline() {
453        let mut ta = TextArea::default();
454        ta.insert_text("hello");
455        ta.insert_text("\n");
456        ta.insert_text("world");
457        assert_eq!(ta.text(), "hello\nworld");
458        assert_eq!(ta.lines().len(), 2);
459    }
460
461    #[test]
462    fn text_area_cursor_home_end() {
463        let mut ta = TextArea::default();
464        ta.set_text("line1\nline2\nline3");
465        // Cursor at end of "line3"
466        assert_eq!(ta.cursor_position, 17);
467
468        ta.move_cursor_home();
469        // Should go to start of "line3" (after second \n = 12)
470        assert_eq!(ta.cursor_position, 12);
471
472        ta.move_cursor_end();
473        // Should go to end of "line3"
474        assert_eq!(ta.cursor_position, 17);
475    }
476
477    #[test]
478    fn text_area_delete() {
479        let mut ta = TextArea::default();
480        ta.set_text("abcd");
481        ta.delete_char_before();
482        assert_eq!(ta.text(), "abc");
483        ta.move_cursor_home();
484        ta.delete_char_after();
485        assert_eq!(ta.text(), "bc");
486    }
487
488    #[test]
489    fn text_area_keyboard_enter() {
490        let mut ta = TextArea::default();
491        ta.focused = true;
492        ta.set_text("hello");
493        ta.cursor_position = 5;
494        let evt = KeyboardEvent {
495            key: KeyCode::Enter,
496            state: ElementState::Pressed,
497            modifiers: Default::default(),
498            text: None,
499        };
500        assert_eq!(ta.handle_keyboard(&evt), EventResult::Handled);
501        assert_eq!(ta.text(), "hello\n");
502    }
503
504    #[test]
505    fn text_area_scroll() {
506        let mut ta = TextArea::default();
507        ta.rect = Rect {
508            x: 0.0,
509            y: 0.0,
510            w: 300.0,
511            h: 100.0,
512        };
513        let evt = ScrollEvent {
514            x: 50.0,
515            y: 50.0,
516            delta_x: 0.0,
517            delta_y: -10.0,
518        };
519        assert_eq!(ta.handle_scroll(&evt), EventResult::Handled);
520        assert!((ta.scroll_y - 10.0).abs() < f32::EPSILON);
521    }
522}