Skip to main content

gpui_component/input/
textarea.rs

1use std::rc::Rc;
2
3use gpui::{
4    App, DefiniteLength, Entity, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled,
5    Window, prelude::FluentBuilder as _,
6};
7
8use super::{Input, TextareaState};
9use crate::native_menu::NativeMenu;
10use crate::{RoleOverride, Sizable, Size, StyledExt as _};
11
12/// A styled ordinary multi-line text field.
13#[derive(IntoElement)]
14pub struct Textarea {
15    token_renderer: Option<gpui_base::input::InlineTokenRenderer>,
16    token_click_listener: Option<gpui_base::input::InlineTokenClickListener>,
17    token_hover_listener: Option<gpui_base::input::InlineTokenHoverListener>,
18    state: Entity<TextareaState>,
19    style: StyleRefinement,
20    size: Size,
21    height: Option<DefiniteLength>,
22    appearance: bool,
23    bordered: bool,
24    disabled: bool,
25    readonly: bool,
26    tab_index: isize,
27    role: RoleOverride,
28    accessibility_id: Option<SharedString>,
29    aria_label: Option<SharedString>,
30
31    /// An optional context menu builder to allow a custom context menu.
32    ///
33    /// If set, this overrides the built-in context menu.
34    context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
35
36    paste_handler: Option<Rc<dyn Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool>>,
37}
38
39impl Textarea {
40    /// The element each atomic inline token renders as, in place of the default
41    /// [`InputToken`](super::InputToken); editing and history stay
42    /// with the input.
43    pub fn token<R: IntoElement>(
44        mut self,
45        render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static,
46    ) -> Self {
47        self.token_renderer = Some(Rc::new(move |token, window, cx| {
48            render(token, window, cx).into_any_element()
49        }));
50        self
51    }
52    /// Open a reference after a completed, unconsumed token click.
53    pub fn on_token_click(
54        mut self,
55        listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static,
56    ) -> Self {
57        self.token_click_listener = Some(Rc::new(listener));
58        self
59    }
60    /// Report pointer presence over a token so the application can show a
61    /// tooltip or run custom logic. Hover never selects or edits.
62    pub fn on_token_hover(
63        mut self,
64        listener: impl Fn(&super::InlineTokenHoverEvent, &mut Window, &mut App) + 'static,
65    ) -> Self {
66        self.token_hover_listener = Some(Rc::new(listener));
67        self
68    }
69
70    pub fn new(state: &Entity<TextareaState>) -> Self {
71        Self {
72            state: state.clone(),
73            style: StyleRefinement::default(),
74            size: Size::default(),
75            height: None,
76            appearance: true,
77            bordered: true,
78            disabled: false,
79            readonly: false,
80            tab_index: 0,
81            role: RoleOverride::default(),
82            accessibility_id: None,
83            aria_label: None,
84            context_menu_builder: None,
85            paste_handler: None,
86            token_renderer: None,
87            token_click_listener: None,
88            token_hover_listener: None,
89        }
90    }
91
92    pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
93        self.height = Some(height.into());
94        self
95    }
96
97    pub fn appearance(mut self, appearance: bool) -> Self {
98        self.appearance = appearance;
99        self
100    }
101
102    pub fn bordered(mut self, bordered: bool) -> Self {
103        self.bordered = bordered;
104        self
105    }
106
107    pub fn disabled(mut self, disabled: bool) -> Self {
108        self.disabled = disabled;
109        self
110    }
111
112    /// Set the textarea to read-only, default is `false`.
113    ///
114    /// Unlike [`Self::disabled`], a read-only textarea keeps the normal appearance
115    /// and still can be focused, selected and copied, it only rejects the changes
116    /// made by the user.
117    pub fn readonly(mut self, readonly: bool) -> Self {
118        self.readonly = readonly;
119        self
120    }
121
122    pub fn tab_index(mut self, index: isize) -> Self {
123        self.tab_index = index;
124        self
125    }
126
127    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
128        self.role = role.into();
129        self
130    }
131
132    /// Set the developer-assigned accessibility identifier.
133    pub fn accessibility_id(mut self, id: impl Into<SharedString>) -> Self {
134        self.accessibility_id = Some(id.into());
135        self
136    }
137
138    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
139        self.aria_label = Some(label.into());
140        self
141    }
142
143    /// Replace the built-in context menu shown on right-click.
144    ///
145    /// The closure receives an empty menu and returns the one to show, so it
146    /// decides entirely what appears — the default items are not added. It
147    /// shows only while the state's context menu is enabled, which is the
148    /// default.
149    pub fn context_menu(
150        mut self,
151        f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
152    ) -> Self {
153        self.context_menu_builder = Some(Rc::new(f));
154        self
155    }
156
157    /// Intercept paste payloads (images, files) before the default text insertion.
158    ///
159    /// `true` consumes the paste so nothing is inserted, `false` falls through
160    /// to `clipboard.text()`. Copied files arrive as `ExternalPaths` through
161    /// the same hook. On web the clipboard reads `None`; image paste needs
162    /// async clipboard access and is out of scope.
163    pub fn on_paste(
164        mut self,
165        handler: impl Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool + 'static,
166    ) -> Self {
167        self.paste_handler = Some(Rc::new(handler));
168        self
169    }
170}
171
172impl Sizable for Textarea {
173    fn with_size(mut self, size: impl Into<Size>) -> Self {
174        self.size = size.into();
175        self
176    }
177}
178
179impl Styled for Textarea {
180    fn style(&mut self) -> &mut StyleRefinement {
181        &mut self.style
182    }
183}
184
185impl Textarea {
186    /// The [`Input`] this textarea renders, for a compound control that frames
187    /// it.
188    pub(crate) fn into_input(self) -> Input {
189        Input::from_state(self.state.clone())
190            .when_some(self.token_renderer, |this, render| {
191                this.token(move |token, window, cx| render(token, window, cx))
192            })
193            .when_some(self.token_click_listener, |this, listener| {
194                this.on_token_click(move |event, window, cx| listener(event, window, cx))
195            })
196            .when_some(self.token_hover_listener, |this, listener| {
197                this.on_token_hover(move |event, window, cx| listener(event, window, cx))
198            })
199            .appearance(self.appearance)
200            .bordered(self.bordered)
201            .disabled(self.disabled)
202            .readonly(self.readonly)
203            .tab_index(self.tab_index)
204            .role(self.role)
205            .with_size(self.size)
206            .when_some(self.height, |this, height| this.h(height))
207            .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
208            .when_some(self.aria_label, |this, label| this.aria_label(label))
209            .when_some(self.context_menu_builder, |this, build| {
210                this.context_menu(move |menu, window, cx| build(menu, window, cx))
211            })
212            .when_some(self.paste_handler, |this, handler| {
213                this.on_paste(move |item, window, cx| handler(item, window, cx))
214            })
215            .refine_style(&self.style)
216    }
217}
218
219impl RenderOnce for Textarea {
220    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
221        self.into_input()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[gpui::test]
230    fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
231        use gpui::{AppContext as _, Render};
232
233        struct Probe;
234        impl Render for Probe {
235            fn render(
236                &mut self,
237                _: &mut Window,
238                _: &mut gpui::Context<Self>,
239            ) -> impl gpui::IntoElement {
240                gpui::div()
241            }
242        }
243
244        cx.update(crate::init);
245        let _ = cx.add_window_view(|window, cx| {
246            let state = cx.new(|cx| TextareaState::new(window, cx));
247            assert!(Textarea::new(&state).paste_handler.is_none());
248            let textarea = Textarea::new(&state).on_paste(|_, _, _| true);
249            assert!(textarea.paste_handler.is_some());
250            Probe
251        });
252    }
253
254    #[gpui::test]
255    fn test_on_token_hover_builder(cx: &mut gpui::TestAppContext) {
256        use gpui::{AppContext as _, Render};
257
258        struct Probe;
259        impl Render for Probe {
260            fn render(
261                &mut self,
262                _: &mut Window,
263                _: &mut gpui::Context<Self>,
264            ) -> impl gpui::IntoElement {
265                gpui::div()
266            }
267        }
268
269        cx.update(crate::init);
270        let _ = cx.add_window_view(|window, cx| {
271            let state = cx.new(|cx| TextareaState::new(window, cx));
272            assert!(Textarea::new(&state).token_hover_listener.is_none());
273            let textarea = Textarea::new(&state).on_token_hover(|_, _, _| {});
274            assert!(textarea.token_hover_listener.is_some());
275            Probe
276        });
277    }
278}