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, ScrollHandle, 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  scroll: ScrollHandle,
34  placeholder: SharedString,
35  label: Option<SharedString>,
36  description: Option<SharedString>,
37  error: Option<SharedString>,
38  rows: usize,
39  max_rows: Option<usize>,
40  submit_on_enter: bool,
41  size: Size,
42  disabled: bool,
43}
44
45impl EventEmitter<TextAreaEvent> for TextArea {}
46impl EventEmitter<TextAreaSubmit> for TextArea {}
47
48/// A line that renders with height even when empty.
49fn line(text: &str) -> SharedString {
50  if text.is_empty() {
51    SharedString::new_static(" ")
52  } else {
53    SharedString::from(text.to_string())
54  }
55}
56
57impl TextArea {
58  pub fn new(cx: &mut Context<Self>) -> Self {
59    TextArea {
60      edit: TextEdit::new(""),
61      focus: cx.focus_handle().tab_stop(true),
62      scroll: ScrollHandle::new(),
63      placeholder: SharedString::default(),
64      label: None,
65      description: None,
66      error: None,
67      rows: 3,
68      max_rows: None,
69      submit_on_enter: false,
70      size: Size::Sm,
71      disabled: false,
72    }
73  }
74
75  pub fn value(mut self, value: &str) -> Self {
76    self.edit = TextEdit::new(value);
77    self
78  }
79
80  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
81    self.placeholder = placeholder.into();
82    self
83  }
84
85  /// Replace the placeholder after construction, for a field that is built
86  /// once and re-labelled later.
87  pub fn set_placeholder(&mut self, placeholder: impl Into<SharedString>, cx: &mut Context<Self>) {
88    self.placeholder = placeholder.into();
89    cx.notify();
90  }
91
92  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
93    self.label = Some(label.into());
94    self
95  }
96
97  pub fn description(mut self, description: impl Into<SharedString>) -> Self {
98    self.description = Some(description.into());
99    self
100  }
101
102  pub fn error(mut self, error: impl Into<SharedString>) -> Self {
103    self.error = Some(error.into());
104    self
105  }
106
107  /// Stop growing past `rows` and scroll instead. Without this a field that
108  /// grows with its content has no ceiling, which is wrong for a composer
109  /// pinned to the bottom of a window.
110  pub fn max_rows(mut self, rows: usize) -> Self {
111    self.max_rows = Some(rows.max(1));
112    self
113  }
114
115  /// Make Enter commit the value (emitting [`TextAreaSubmit`]) and
116  /// Shift+Enter insert the newline — the convention a chat composer uses.
117  pub fn submit_on_enter(mut self, submit: bool) -> Self {
118    self.submit_on_enter = submit;
119    self
120  }
121
122  /// Minimum visible rows (sets the field's minimum height).
123  pub fn rows(mut self, rows: usize) -> Self {
124    self.rows = rows.max(1);
125    self
126  }
127
128  pub fn size(mut self, size: Size) -> Self {
129    self.size = size;
130    self
131  }
132
133  pub fn disabled(mut self, disabled: bool) -> Self {
134    self.disabled = disabled;
135    self
136  }
137
138  /// The field's focus handle, so a host can focus it on open.
139  pub fn focus_handle(&self) -> FocusHandle {
140    self.focus.clone()
141  }
142
143  pub fn text(&self) -> String {
144    self.edit.text()
145  }
146
147  /// Whether the field holds nothing but whitespace. Cheaper than
148  /// `text().trim().is_empty()`, which builds and throws away a copy of the
149  /// whole value — and callers ask this every frame to enable a send button.
150  pub fn is_blank(&self) -> bool {
151    self.edit.chars().iter().all(|c| c.is_whitespace())
152  }
153
154  pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
155    self.edit = TextEdit::new(value);
156    cx.notify();
157  }
158
159  /// Two-way bind this field's text to a `Signal<String>`. The signal is
160  /// the source of truth: the field adopts its value now, edits write back
161  /// through [`Signal::set_if_changed`], and signal writes replace the text.
162  /// Equality guards on both directions prevent update loops.
163  pub fn bind(entity: &Entity<TextArea>, signal: &Signal<String>, cx: &mut App) {
164    let initial = signal.get(cx);
165    entity.update(cx, |this, cx| {
166      if this.text() != initial {
167        this.set_text(&initial, cx);
168      }
169    });
170    let sink = signal.clone();
171    cx.subscribe(entity, move |_area, event: &TextAreaEvent, cx| {
172      sink.set_if_changed(cx, event.0.clone());
173    })
174    .detach();
175    let area = entity.downgrade();
176    cx.observe(signal.entity(), move |observed, cx| {
177      let value = observed.read(cx).clone();
178      area
179        .update(cx, |this, cx| {
180          if this.text() != value {
181            this.set_text(&value, cx);
182          }
183        })
184        .ok();
185    })
186    .detach();
187  }
188
189  fn copy(&self, cx: &mut Context<Self>) {
190    if let Some(text) = self.edit.selected_text() {
191      cx.write_to_clipboard(ClipboardItem::new_string(text));
192    }
193    cx.stop_propagation();
194  }
195
196  fn cut(&mut self, cx: &mut Context<Self>) {
197    if let Some(text) = self.edit.selected_text() {
198      cx.write_to_clipboard(ClipboardItem::new_string(text));
199      self.edit.delete_selection();
200      cx.emit(TextAreaEvent(self.edit.text()));
201      cx.notify();
202    }
203    cx.stop_propagation();
204  }
205
206  fn paste(&mut self, cx: &mut Context<Self>) {
207    if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
208      self
209        .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          .flex_wrap()
427          .items_center()
428          .w_full()
429          .min_h(px(line_h))
430          .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
431          .child(
432            div()
433              .flex_1()
434              .min_w(px(0.0))
435              .text_color(dimmed)
436              .child(self.placeholder.clone()),
437          ),
438      );
439    } else if focused {
440      if let Some((before, selected, after)) = self.edit.split_selection() {
441        let before_lines: Vec<&str> = before.split('\n').collect();
442        let selected_lines: Vec<&str> = selected.split('\n').collect();
443        let after_lines: Vec<&str> = after.split('\n').collect();
444        let before_last = before_lines.len() - 1;
445        for text in &before_lines[..before_last] {
446          body = body.child(div().w_full().min_h(px(line_h)).child(line(text)));
447        }
448        let selected_part = |text: &str| div().bg(selection_bg).rounded(px(2.0)).child(line(text));
449        body = body.child(
450          div()
451            .flex()
452            .flex_wrap()
453            .items_center()
454            .w_full()
455            .min_h(px(line_h))
456            .child(SharedString::from(before_lines[before_last].to_string()))
457            .child(selected_part(selected_lines[0]))
458            .when(selected_lines.len() == 1, |row| {
459              row.child(SharedString::from(after_lines[0].to_string()))
460            }),
461        );
462        if selected_lines.len() > 1 {
463          for text in &selected_lines[1..selected_lines.len() - 1] {
464            body = body.child(
465              div()
466                .flex()
467                .flex_wrap()
468                .w_full()
469                .min_h(px(line_h))
470                .child(selected_part(text)),
471            );
472          }
473          body = body.child(
474            div()
475              .flex()
476              .flex_wrap()
477              .items_center()
478              .w_full()
479              .min_h(px(line_h))
480              .child(selected_part(selected_lines[selected_lines.len() - 1]))
481              .child(SharedString::from(after_lines[0].to_string())),
482          );
483        }
484        for text in &after_lines[1..] {
485          body = body.child(div().w_full().min_h(px(line_h)).child(line(text)));
486        }
487      } else {
488        let (before, after) = self.edit.split();
489        let before_lines: Vec<&str> = before.split('\n').collect();
490        let after_lines: Vec<&str> = after.split('\n').collect();
491        let last = before_lines.len() - 1;
492        for text in &before_lines[..last] {
493          body = body.child(div().w_full().min_h(px(line_h)).child(line(text)));
494        }
495        body = body.child(
496          div()
497            .flex()
498            .flex_wrap()
499            .items_center()
500            .w_full()
501            .min_h(px(line_h))
502            .child(SharedString::from(before_lines[last].to_string()))
503            .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
504            .child(SharedString::from(after_lines[0].to_string())),
505        );
506        for text in &after_lines[1..] {
507          body = body.child(div().w_full().min_h(px(line_h)).child(line(text)));
508        }
509      }
510    } else if self.edit.is_empty() {
511      body = body.text_color(dimmed).child(
512        div()
513          .w_full()
514          .min_h(px(line_h))
515          .child(self.placeholder.clone()),
516      );
517    } else {
518      for l in self.edit.text().split('\n') {
519        body = body.child(div().w_full().min_h(px(line_h)).child(line(l)));
520      }
521    }
522
523    let mut field = div()
524      .id("guise-textarea")
525      .track_focus(&self.focus)
526      .on_key_down(cx.listener(Self::on_key))
527      .on_mouse_down(
528        MouseButton::Left,
529        cx.listener(|this, _ev, window, cx| {
530          window.focus(&this.focus);
531          cx.notify();
532        }),
533      )
534      .flex()
535      .items_start()
536      .overflow_x_hidden()
537      .min_h(px(min_h))
538      .w_full()
539      .px(px(pad_x))
540      .py(px(pad_y))
541      .rounded(px(radius))
542      .border_1()
543      .border_color(border)
544      .bg(surface)
545      .text_size(px(font))
546      .line_height(px(line_h))
547      .child(div().w_full().min_w(px(0.0)).child(body));
548
549    if let Some(max) = max_h {
550      field = field
551        .max_h(px(max))
552        .overflow_y_scroll()
553        .track_scroll(&self.scroll);
554    }
555
556    let mut chrome = Field::new().child(if self.disabled {
557      field.opacity(0.6)
558    } else {
559      field
560    });
561    if let Some(label) = self.label.clone() {
562      chrome = chrome.label(label);
563    }
564    if let Some(error) = self.error.clone() {
565      chrome = chrome.error(error);
566    } else if let Some(description) = self.description.clone() {
567      chrome = chrome.description(description);
568    }
569    chrome.probe_any("TextArea")
570  }
571}