Skip to main content

guise/input/
textarea.rs

1//! `TextArea` — a multiline text field (gpui entity).
2//!
3//! Reuses the [`TextEdit`] char model (newline-aware), renders line-by-line with
4//! a caret on the active line, and emits [`TextAreaEvent`] on edit. Enter inserts
5//! a newline; up/down move between lines keeping the column.
6
7use gpui::prelude::*;
8use gpui::{
9    div, px, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, IntoElement,
10    KeyDownEvent, MouseButton, SharedString, Window,
11};
12
13use super::{control_metrics, Field, TextEdit};
14use crate::devtools::ProbedAny;
15use crate::reactive::Signal;
16use crate::theme::{theme, ColorName, Size};
17
18/// Emitted as the user edits the field. Carries the full new value.
19#[derive(Debug, Clone)]
20pub struct TextAreaEvent(pub String);
21
22/// Emitted when Enter commits the field, which only happens under
23/// [`TextArea::submit_on_enter`]. Carries the value. It is a separate event
24/// type rather than a variant so that subscribers to [`TextAreaEvent`] keep
25/// working unchanged.
26#[derive(Debug, Clone)]
27pub struct TextAreaSubmit(pub String);
28
29/// A multiline text field. Create with `cx.new(|cx| TextArea::new(cx))`.
30pub struct TextArea {
31    edit: TextEdit,
32    focus: FocusHandle,
33    placeholder: SharedString,
34    label: Option<SharedString>,
35    description: Option<SharedString>,
36    error: Option<SharedString>,
37    rows: usize,
38    max_rows: Option<usize>,
39    submit_on_enter: bool,
40    size: Size,
41    disabled: bool,
42}
43
44impl EventEmitter<TextAreaEvent> for TextArea {}
45impl EventEmitter<TextAreaSubmit> for TextArea {}
46
47/// A line that renders with height even when empty.
48fn line(text: &str) -> SharedString {
49    if text.is_empty() {
50        SharedString::new_static(" ")
51    } else {
52        SharedString::from(text.to_string())
53    }
54}
55
56impl TextArea {
57    pub fn new(cx: &mut Context<Self>) -> Self {
58        TextArea {
59            edit: TextEdit::new(""),
60            focus: cx.focus_handle().tab_stop(true),
61            placeholder: SharedString::default(),
62            label: None,
63            description: None,
64            error: None,
65            rows: 3,
66            max_rows: None,
67            submit_on_enter: false,
68            size: Size::Sm,
69            disabled: false,
70        }
71    }
72
73    pub fn value(mut self, value: &str) -> Self {
74        self.edit = TextEdit::new(value);
75        self
76    }
77
78    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
79        self.placeholder = placeholder.into();
80        self
81    }
82
83    /// Replace the placeholder after construction, for a field that is built
84    /// once and re-labelled later.
85    pub fn set_placeholder(
86        &mut self,
87        placeholder: impl Into<SharedString>,
88        cx: &mut Context<Self>,
89    ) {
90        self.placeholder = placeholder.into();
91        cx.notify();
92    }
93
94    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
95        self.label = Some(label.into());
96        self
97    }
98
99    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
100        self.description = Some(description.into());
101        self
102    }
103
104    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
105        self.error = Some(error.into());
106        self
107    }
108
109    /// Stop growing past `rows` and scroll instead. Without this a field that
110    /// grows with its content has no ceiling, which is wrong for a composer
111    /// pinned to the bottom of a window.
112    pub fn max_rows(mut self, rows: usize) -> Self {
113        self.max_rows = Some(rows.max(1));
114        self
115    }
116
117    /// Make Enter commit the value (emitting [`TextAreaSubmit`]) and
118    /// Shift+Enter insert the newline — the convention a chat composer uses.
119    pub fn submit_on_enter(mut self, submit: bool) -> Self {
120        self.submit_on_enter = submit;
121        self
122    }
123
124    /// Minimum visible rows (sets the field's minimum height).
125    pub fn rows(mut self, rows: usize) -> Self {
126        self.rows = rows.max(1);
127        self
128    }
129
130    pub fn size(mut self, size: Size) -> Self {
131        self.size = size;
132        self
133    }
134
135    pub fn disabled(mut self, disabled: bool) -> Self {
136        self.disabled = disabled;
137        self
138    }
139
140    /// The field's focus handle, so a host can focus it on open.
141    pub fn focus_handle(&self) -> FocusHandle {
142        self.focus.clone()
143    }
144
145    pub fn text(&self) -> String {
146        self.edit.text()
147    }
148
149    /// Whether the field holds nothing but whitespace. Cheaper than
150    /// `text().trim().is_empty()`, which builds and throws away a copy of the
151    /// whole value — and callers ask this every frame to enable a send button.
152    pub fn is_blank(&self) -> bool {
153        self.edit.chars().iter().all(|c| c.is_whitespace())
154    }
155
156    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
157        self.edit = TextEdit::new(value);
158        cx.notify();
159    }
160
161    /// Two-way bind this field's text to a `Signal<String>`. The signal is
162    /// the source of truth: the field adopts its value now, edits write back
163    /// through [`Signal::set_if_changed`], and signal writes replace the text.
164    /// Equality guards on both directions prevent update loops.
165    pub fn bind(entity: &Entity<TextArea>, signal: &Signal<String>, cx: &mut App) {
166        let initial = signal.get(cx);
167        entity.update(cx, |this, cx| {
168            if this.text() != initial {
169                this.set_text(&initial, cx);
170            }
171        });
172        let sink = signal.clone();
173        cx.subscribe(entity, move |_area, event: &TextAreaEvent, cx| {
174            sink.set_if_changed(cx, event.0.clone());
175        })
176        .detach();
177        let area = entity.downgrade();
178        cx.observe(signal.entity(), move |observed, cx| {
179            let value = observed.read(cx).clone();
180            area.update(cx, |this, cx| {
181                if this.text() != value {
182                    this.set_text(&value, cx);
183                }
184            })
185            .ok();
186        })
187        .detach();
188    }
189
190    fn copy(&self, cx: &mut Context<Self>) {
191        if let Some(text) = self.edit.selected_text() {
192            cx.write_to_clipboard(ClipboardItem::new_string(text));
193        }
194        cx.stop_propagation();
195    }
196
197    fn cut(&mut self, cx: &mut Context<Self>) {
198        if let Some(text) = self.edit.selected_text() {
199            cx.write_to_clipboard(ClipboardItem::new_string(text));
200            self.edit.delete_selection();
201            cx.emit(TextAreaEvent(self.edit.text()));
202            cx.notify();
203        }
204        cx.stop_propagation();
205    }
206
207    fn paste(&mut self, cx: &mut Context<Self>) {
208        if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
209            self.edit
210                .insert(&text.replace("\r\n", "\n").replace('\r', "\n"));
211            cx.emit(TextAreaEvent(self.edit.text()));
212            cx.notify();
213        }
214        cx.stop_propagation();
215    }
216
217    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
218        if self.disabled {
219            return;
220        }
221        let ks = &event.keystroke;
222        let m = &ks.modifiers;
223        if m.platform && !m.alt && !m.control {
224            match ks.key.as_str() {
225                "a" => {
226                    self.edit.select_all();
227                    cx.notify();
228                    cx.stop_propagation();
229                    return;
230                }
231                "c" => return self.copy(cx),
232                "x" => return self.cut(cx),
233                "v" => return self.paste(cx),
234                "z" | "y" => {
235                    let undo = ks.key == "z" && !m.shift;
236                    let changed = if undo {
237                        self.edit.undo()
238                    } else {
239                        self.edit.redo()
240                    };
241                    if changed {
242                        cx.emit(TextAreaEvent(self.edit.text()));
243                    }
244                    cx.notify();
245                    cx.stop_propagation();
246                    return;
247                }
248                _ => {}
249            }
250        }
251        // Tab moves to the next field, as it does in a `<textarea>` — a text
252        // area is still a form control, not a code editor. Escape bubbles so
253        // the host can dismiss. (Enter inserts a newline: this is multi-line.)
254        if ks.key == "tab" && !m.platform && !m.control {
255            if m.shift {
256                window.focus_prev();
257            } else {
258                window.focus_next();
259            }
260            cx.notify();
261            cx.stop_propagation();
262            return;
263        }
264        if ks.key == "escape" {
265            return;
266        }
267        let edited = match ks.key.as_str() {
268            "enter" if self.submit_on_enter && !m.shift => {
269                cx.emit(TextAreaSubmit(self.edit.text()));
270                cx.notify();
271                cx.stop_propagation();
272                return;
273            }
274            "enter" => {
275                self.edit.insert("\n");
276                true
277            }
278            "left" => {
279                if !m.shift && !m.platform && !m.alt && self.edit.collapse_selection_start() {
280                    true
281                } else {
282                    self.edit.pre_move(m.shift);
283                    if m.platform {
284                        self.edit.line_home();
285                    } else if m.alt {
286                        self.edit.word_left();
287                    } else {
288                        self.edit.left();
289                    }
290                    true
291                }
292            }
293            "right" => {
294                if !m.shift && !m.platform && !m.alt && self.edit.collapse_selection_end() {
295                    true
296                } else {
297                    self.edit.pre_move(m.shift);
298                    if m.platform {
299                        self.edit.line_end();
300                    } else if m.alt {
301                        self.edit.word_right();
302                    } else {
303                        self.edit.right();
304                    }
305                    true
306                }
307            }
308            "up" => {
309                self.edit.pre_move(m.shift);
310                if m.platform {
311                    self.edit.home();
312                } else {
313                    self.edit.up();
314                }
315                true
316            }
317            "down" => {
318                self.edit.pre_move(m.shift);
319                if m.platform {
320                    self.edit.end();
321                } else {
322                    self.edit.down();
323                }
324                true
325            }
326            "home" => {
327                self.edit.pre_move(m.shift);
328                self.edit.line_home();
329                true
330            }
331            "end" => {
332                self.edit.pre_move(m.shift);
333                self.edit.line_end();
334                true
335            }
336            "backspace" => {
337                if m.platform {
338                    self.edit.delete_to_start();
339                } else if m.alt {
340                    self.edit.delete_word_back();
341                } else {
342                    self.edit.backspace();
343                }
344                true
345            }
346            "delete" => {
347                if m.platform {
348                    self.edit.delete_to_end();
349                } else if m.alt {
350                    self.edit.delete_word_forward();
351                } else {
352                    self.edit.delete();
353                }
354                true
355            }
356            "k" if m.control => {
357                self.edit.delete_to_end();
358                true
359            }
360            "a" if m.control => {
361                self.edit.home();
362                true
363            }
364            "e" if m.control => {
365                self.edit.end();
366                true
367            }
368            _ => {
369                if !m.platform && !m.control {
370                    if let Some(text) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
371                        self.edit.insert(text);
372                        true
373                    } else {
374                        false
375                    }
376                } else {
377                    false
378                }
379            }
380        };
381        if edited {
382            cx.emit(TextAreaEvent(self.edit.text()));
383            cx.notify();
384            cx.stop_propagation();
385        }
386    }
387}
388
389impl Render for TextArea {
390    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
391        let t = theme(cx);
392        let (_, pad_x, font) = control_metrics(self.size);
393        let radius = t.radius(t.default_radius);
394        let focused = self.focus.is_focused(window) && !self.disabled;
395        let line_h = font * 1.5;
396        let pad_y = 8.0;
397        let min_h = self.rows as f32 * line_h + pad_y * 2.0;
398        let max_h = self
399            .max_rows
400            .map(|rows| rows as f32 * line_h + pad_y * 2.0)
401            .filter(|max| *max >= min_h);
402
403        let border = if self.error.is_some() {
404            t.color(ColorName::Red, 6)
405        } else if focused {
406            t.primary()
407        } else {
408            t.border()
409        }
410        .hsla();
411        let text_color = t.text().hsla();
412        let dimmed = t.dimmed().hsla();
413        let surface = t.surface().hsla();
414        let caret = t.primary().hsla();
415        let selection_bg = t.selection();
416
417        let mut body = div().flex().flex_col().text_color(text_color);
418        // A focused-but-empty field still shows its placeholder, the way a
419        // `<textarea>` does — hiding it the moment the caret lands takes the
420        // label away exactly when the user is deciding what to write. The
421        // caret is drawn beside it.
422        if focused && self.edit.is_empty() && !self.placeholder.is_empty() {
423            body = body.child(
424                div()
425                    .flex()
426                    .items_center()
427                    .h(px(line_h))
428                    .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
429                    .child(div().text_color(dimmed).child(self.placeholder.clone())),
430            );
431        } else if focused {
432            if let Some((before, selected, after)) = self.edit.split_selection() {
433                let before_lines: Vec<&str> = before.split('\n').collect();
434                let selected_lines: Vec<&str> = selected.split('\n').collect();
435                let after_lines: Vec<&str> = after.split('\n').collect();
436                let before_last = before_lines.len() - 1;
437                for text in &before_lines[..before_last] {
438                    body = body.child(div().h(px(line_h)).child(line(text)));
439                }
440                let selected_part =
441                    |text: &str| div().bg(selection_bg).rounded(px(2.0)).child(line(text));
442                body = body.child(
443                    div()
444                        .flex()
445                        .items_center()
446                        .h(px(line_h))
447                        .child(SharedString::from(before_lines[before_last].to_string()))
448                        .child(selected_part(selected_lines[0]))
449                        .when(selected_lines.len() == 1, |row| {
450                            row.child(SharedString::from(after_lines[0].to_string()))
451                        }),
452                );
453                if selected_lines.len() > 1 {
454                    for text in &selected_lines[1..selected_lines.len() - 1] {
455                        body = body.child(div().flex().h(px(line_h)).child(selected_part(text)));
456                    }
457                    body = body.child(
458                        div()
459                            .flex()
460                            .items_center()
461                            .h(px(line_h))
462                            .child(selected_part(selected_lines[selected_lines.len() - 1]))
463                            .child(SharedString::from(after_lines[0].to_string())),
464                    );
465                }
466                for text in &after_lines[1..] {
467                    body = body.child(div().h(px(line_h)).child(line(text)));
468                }
469            } else {
470                let (before, after) = self.edit.split();
471                let before_lines: Vec<&str> = before.split('\n').collect();
472                let after_lines: Vec<&str> = after.split('\n').collect();
473                let last = before_lines.len() - 1;
474                for text in &before_lines[..last] {
475                    body = body.child(div().h(px(line_h)).child(line(text)));
476                }
477                body = body.child(
478                    div()
479                        .flex()
480                        .items_center()
481                        .h(px(line_h))
482                        .child(SharedString::from(before_lines[last].to_string()))
483                        .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
484                        .child(SharedString::from(after_lines[0].to_string())),
485                );
486                for text in &after_lines[1..] {
487                    body = body.child(div().h(px(line_h)).child(line(text)));
488                }
489            }
490        } else if self.edit.is_empty() {
491            body = body
492                .text_color(dimmed)
493                .child(div().h(px(line_h)).child(self.placeholder.clone()));
494        } else {
495            for l in self.edit.text().split('\n') {
496                body = body.child(div().h(px(line_h)).child(line(l)));
497            }
498        }
499
500        let field = div()
501            .id("guise-textarea")
502            .track_focus(&self.focus)
503            .on_key_down(cx.listener(Self::on_key))
504            .on_mouse_down(
505                MouseButton::Left,
506                cx.listener(|this, _ev, window, cx| {
507                    window.focus(&this.focus);
508                    cx.notify();
509                }),
510            )
511            .flex()
512            .items_start()
513            .overflow_hidden()
514            .min_h(px(min_h))
515            .when_some(max_h, |field, max| field.max_h(px(max)))
516            .w_full()
517            .px(px(pad_x))
518            .py(px(pad_y))
519            .rounded(px(radius))
520            .border_1()
521            .border_color(border)
522            .bg(surface)
523            .text_size(px(font))
524            .child(div().w_full().min_w(px(0.0)).overflow_hidden().child(body));
525
526        let mut chrome = Field::new().child(if self.disabled {
527            field.opacity(0.6)
528        } else {
529            field
530        });
531        if let Some(label) = self.label.clone() {
532            chrome = chrome.label(label);
533        }
534        if let Some(error) = self.error.clone() {
535            chrome = chrome.error(error);
536        } else if let Some(description) = self.description.clone() {
537            chrome = chrome.description(description);
538        }
539        chrome.probe_any("TextArea")
540    }
541}