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, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
10    SharedString, Window,
11};
12
13use super::{control_metrics, Field, TextEdit};
14use crate::theme::{theme, ColorName, Size};
15
16/// Emitted as the user edits the field. Carries the full new value.
17#[derive(Debug, Clone)]
18pub struct TextAreaEvent(pub String);
19
20/// A multiline text field. Create with `cx.new(|cx| TextArea::new(cx))`.
21pub struct TextArea {
22    edit: TextEdit,
23    focus: FocusHandle,
24    placeholder: SharedString,
25    label: Option<SharedString>,
26    description: Option<SharedString>,
27    error: Option<SharedString>,
28    rows: usize,
29    size: Size,
30    disabled: bool,
31}
32
33impl EventEmitter<TextAreaEvent> for TextArea {}
34
35/// A line that renders with height even when empty.
36fn line(text: &str) -> SharedString {
37    if text.is_empty() {
38        SharedString::new_static(" ")
39    } else {
40        SharedString::from(text.to_string())
41    }
42}
43
44impl TextArea {
45    pub fn new(cx: &mut Context<Self>) -> Self {
46        TextArea {
47            edit: TextEdit::new(""),
48            focus: cx.focus_handle(),
49            placeholder: SharedString::default(),
50            label: None,
51            description: None,
52            error: None,
53            rows: 3,
54            size: Size::Sm,
55            disabled: false,
56        }
57    }
58
59    pub fn value(mut self, value: &str) -> Self {
60        self.edit = TextEdit::new(value);
61        self
62    }
63
64    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
65        self.placeholder = placeholder.into();
66        self
67    }
68
69    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
70        self.label = Some(label.into());
71        self
72    }
73
74    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
75        self.description = Some(description.into());
76        self
77    }
78
79    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
80        self.error = Some(error.into());
81        self
82    }
83
84    /// Minimum visible rows (sets the field's minimum height).
85    pub fn rows(mut self, rows: usize) -> Self {
86        self.rows = rows.max(1);
87        self
88    }
89
90    pub fn size(mut self, size: Size) -> Self {
91        self.size = size;
92        self
93    }
94
95    pub fn disabled(mut self, disabled: bool) -> Self {
96        self.disabled = disabled;
97        self
98    }
99
100    /// The field's focus handle, so a host can focus it on open.
101    pub fn focus_handle(&self) -> FocusHandle {
102        self.focus.clone()
103    }
104
105    pub fn text(&self) -> String {
106        self.edit.text()
107    }
108
109    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
110        self.edit = TextEdit::new(value);
111        cx.notify();
112    }
113
114    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
115        if self.disabled {
116            return;
117        }
118        let ks = &event.keystroke;
119        let m = &ks.modifiers;
120        // Tab and unconsumed shortcuts bubble so the host can act; Escape too.
121        // (Enter inserts a newline here — this is a multi-line field.)
122        if matches!(ks.key.as_str(), "escape" | "tab") {
123            return;
124        }
125        let edited = match ks.key.as_str() {
126            "enter" => {
127                self.edit.insert("\n");
128                true
129            }
130            "left" => {
131                if m.platform {
132                    self.edit.home();
133                } else if m.alt {
134                    self.edit.word_left();
135                } else {
136                    self.edit.left();
137                }
138                true
139            }
140            "right" => {
141                if m.platform {
142                    self.edit.end();
143                } else if m.alt {
144                    self.edit.word_right();
145                } else {
146                    self.edit.right();
147                }
148                true
149            }
150            "up" => {
151                self.edit.up();
152                true
153            }
154            "down" => {
155                self.edit.down();
156                true
157            }
158            "home" => {
159                self.edit.home();
160                true
161            }
162            "end" => {
163                self.edit.end();
164                true
165            }
166            "backspace" => {
167                if m.platform {
168                    self.edit.delete_to_start();
169                } else if m.alt {
170                    self.edit.delete_word_back();
171                } else {
172                    self.edit.backspace();
173                }
174                true
175            }
176            "delete" => {
177                if m.platform {
178                    self.edit.delete_to_end();
179                } else if m.alt {
180                    self.edit.delete_word_forward();
181                } else {
182                    self.edit.delete();
183                }
184                true
185            }
186            "k" if m.control => {
187                self.edit.delete_to_end();
188                true
189            }
190            "a" if m.control => {
191                self.edit.home();
192                true
193            }
194            "e" if m.control => {
195                self.edit.end();
196                true
197            }
198            _ => {
199                if !m.platform && !m.control {
200                    if let Some(text) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
201                        self.edit.insert(text);
202                        true
203                    } else {
204                        false
205                    }
206                } else {
207                    false
208                }
209            }
210        };
211        if edited {
212            cx.emit(TextAreaEvent(self.edit.text()));
213            cx.notify();
214            cx.stop_propagation();
215        }
216    }
217}
218
219impl Render for TextArea {
220    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
221        let t = theme(cx);
222        let (_, pad_x, font) = control_metrics(self.size);
223        let radius = t.radius(t.default_radius);
224        let focused = self.focus.is_focused(window) && !self.disabled;
225        let line_h = font * 1.5;
226        let pad_y = 8.0;
227        let min_h = self.rows as f32 * line_h + pad_y * 2.0;
228
229        let border = if self.error.is_some() {
230            t.color(ColorName::Red, 6)
231        } else if focused {
232            t.primary()
233        } else {
234            t.border()
235        }
236        .hsla();
237        let text_color = t.text().hsla();
238        let dimmed = t.dimmed().hsla();
239        let surface = t.surface().hsla();
240        let caret = t.primary().hsla();
241
242        let mut body = div().flex().flex_col().text_color(text_color);
243        if focused {
244            let (before, after) = self.edit.split();
245            let before_lines: Vec<&str> = before.split('\n').collect();
246            let after_lines: Vec<&str> = after.split('\n').collect();
247            let last = before_lines.len() - 1;
248            for l in &before_lines[..last] {
249                body = body.child(div().h(px(line_h)).child(line(l)));
250            }
251            // Caret line: tail of `before` + caret + head of `after`.
252            body = body.child(
253                div()
254                    .flex()
255                    .items_center()
256                    .h(px(line_h))
257                    .child(SharedString::from(before_lines[last].to_string()))
258                    .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
259                    .child(SharedString::from(after_lines[0].to_string())),
260            );
261            for l in &after_lines[1..] {
262                body = body.child(div().h(px(line_h)).child(line(l)));
263            }
264        } else if self.edit.is_empty() {
265            body = body
266                .text_color(dimmed)
267                .child(div().h(px(line_h)).child(self.placeholder.clone()));
268        } else {
269            for l in self.edit.text().split('\n') {
270                body = body.child(div().h(px(line_h)).child(line(l)));
271            }
272        }
273
274        let field = div()
275            .id("guise-textarea")
276            .track_focus(&self.focus)
277            .on_key_down(cx.listener(Self::on_key))
278            .on_mouse_down(
279                MouseButton::Left,
280                cx.listener(|this, _ev, window, cx| {
281                    window.focus(&this.focus);
282                    cx.notify();
283                }),
284            )
285            .flex()
286            .items_start()
287            .min_h(px(min_h))
288            .w_full()
289            .px(px(pad_x))
290            .py(px(pad_y))
291            .rounded(px(radius))
292            .border_1()
293            .border_color(border)
294            .bg(surface)
295            .text_size(px(font))
296            .child(body);
297
298        let mut chrome = Field::new().child(if self.disabled {
299            field.opacity(0.6)
300        } else {
301            field
302        });
303        if let Some(label) = self.label.clone() {
304            chrome = chrome.label(label);
305        }
306        if let Some(error) = self.error.clone() {
307            chrome = chrome.error(error);
308        } else if let Some(description) = self.description.clone() {
309            chrome = chrome.description(description);
310        }
311        chrome
312    }
313}