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