Skip to main content

gpui_component/input/
editor.rs

1use std::rc::Rc;
2
3use gpui::{
4    App, DefiniteLength, Entity, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled,
5    Window, prelude::FluentBuilder as _, relative,
6};
7
8use super::{EditorState, Input};
9use crate::native_menu::NativeMenu;
10use crate::{ActiveTheme as _, RoleOverride, StyledExt as _};
11
12/// A code editor takes its rows from the font, so that a smaller or larger
13/// font keeps its leading in proportion.
14const EDITOR_LINE_HEIGHT: f32 = 1.5;
15
16/// A styled source-code editor.
17#[derive(IntoElement)]
18pub struct Editor {
19    state: Entity<EditorState>,
20    style: StyleRefinement,
21    height: Option<DefiniteLength>,
22    appearance: bool,
23    bordered: bool,
24    disabled: bool,
25    readonly: bool,
26    tab_index: isize,
27    role: RoleOverride,
28    aria_label: Option<SharedString>,
29
30    /// An optional context menu builder to allow a custom context menu.
31    ///
32    /// If set, this overrides the built-in context menu.
33    context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
34
35    paste_handler: Option<Rc<dyn Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool>>,
36}
37
38impl Editor {
39    pub fn new(state: &Entity<EditorState>) -> Self {
40        Self {
41            state: state.clone(),
42            style: StyleRefinement::default(),
43            height: None,
44            appearance: true,
45            bordered: true,
46            disabled: false,
47            readonly: false,
48            tab_index: 0,
49            role: RoleOverride::default(),
50            aria_label: None,
51            context_menu_builder: None,
52            paste_handler: None,
53        }
54    }
55
56    pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
57        self.height = Some(height.into());
58        self
59    }
60
61    pub fn appearance(mut self, appearance: bool) -> Self {
62        self.appearance = appearance;
63        self
64    }
65
66    pub fn bordered(mut self, bordered: bool) -> Self {
67        self.bordered = bordered;
68        self
69    }
70
71    pub fn disabled(mut self, disabled: bool) -> Self {
72        self.disabled = disabled;
73        self
74    }
75
76    /// Set the editor to read-only, default is `false`.
77    ///
78    /// Unlike [`Self::disabled`], a read-only editor keeps the normal appearance
79    /// and still can be focused, selected and copied, it only rejects the changes
80    /// made by the user.
81    pub fn readonly(mut self, readonly: bool) -> Self {
82        self.readonly = readonly;
83        self
84    }
85
86    pub fn tab_index(mut self, index: isize) -> Self {
87        self.tab_index = index;
88        self
89    }
90
91    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
92        self.role = role.into();
93        self
94    }
95
96    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
97        self.aria_label = Some(label.into());
98        self
99    }
100
101    /// Replace the built-in context menu shown on right-click.
102    ///
103    /// The closure receives an empty menu and returns the one to show, so it
104    /// decides entirely what appears — the default items are not added. It
105    /// shows only while the state's context menu is enabled, which is the
106    /// default.
107    pub fn context_menu(
108        mut self,
109        f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
110    ) -> Self {
111        self.context_menu_builder = Some(Rc::new(f));
112        self
113    }
114
115    /// Intercept paste payloads (images, files) before the default text insertion.
116    ///
117    /// `true` consumes the paste so nothing is inserted, `false` falls through
118    /// to `clipboard.text()`. Copied files arrive as `ExternalPaths` through
119    /// the same hook. On web the clipboard reads `None`; image paste needs
120    /// async clipboard access and is out of scope.
121    pub fn on_paste(
122        mut self,
123        handler: impl Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool + 'static,
124    ) -> Self {
125        self.paste_handler = Some(Rc::new(handler));
126        self
127    }
128}
129
130impl Styled for Editor {
131    fn style(&mut self) -> &mut StyleRefinement {
132        &mut self.style
133    }
134}
135
136impl RenderOnce for Editor {
137    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
138        Input::from_state(self.state.clone())
139            // Source code wants a monospace font at a code size, and rows that
140            // follow that size. These come first so that a text style set on
141            // this editor refines over them: `.text_sm()` and `.font_family()`
142            // keep working.
143            .font_family(cx.theme().mono_font_family.clone())
144            .text_size(cx.theme().mono_font_size)
145            .line_height(relative(EDITOR_LINE_HEIGHT))
146            .appearance(self.appearance)
147            .bordered(self.bordered)
148            .focus_bordered(false)
149            .disabled(self.disabled)
150            .readonly(self.readonly)
151            .tab_index(self.tab_index)
152            .role(self.role)
153            .when_some(self.height, |this, height| this.h(height))
154            .when_some(self.aria_label, |this, label| this.aria_label(label))
155            .when_some(self.context_menu_builder, |this, build| {
156                this.context_menu(move |menu, window, cx| build(menu, window, cx))
157            })
158            .when_some(self.paste_handler, |this, handler| {
159                this.on_paste(move |item, window, cx| handler(item, window, cx))
160            })
161            .refine_style(&self.style)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::input::EditorState;
169    use gpui::{
170        AppContext as _, Context, ParentElement as _, Pixels, Render, TestAppContext,
171        VisualTestContext, div, px,
172    };
173
174    struct Harness {
175        state: Entity<EditorState>,
176        /// A text size set on the editor, as `.text_sm()` would.
177        text_size: Option<Pixels>,
178    }
179
180    impl Render for Harness {
181        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
182            div().size_full().child(
183                Editor::new(&self.state)
184                    .when_some(self.text_size, |this, size| this.text_size(size)),
185            )
186        }
187    }
188
189    /// The row height the editor laid out with, which follows its font size.
190    fn line_height(cx: &mut TestAppContext, text_size: Option<Pixels>) -> Pixels {
191        cx.update(crate::init);
192        let mut state = None;
193        let (_, cx) = cx.add_window_view(|window, cx| {
194            let editor = cx.new(|cx| EditorState::new(window, cx).default_value("fn main() {}"));
195            state = Some(editor.clone());
196            Harness {
197                state: editor,
198                text_size,
199            }
200        });
201        let state = state.unwrap();
202        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
203
204        cx.read(|cx| {
205            state
206                .read(cx)
207                .line_height()
208                .expect("the editor must lay out")
209        })
210    }
211
212    #[gpui::test]
213    fn the_rows_follow_the_font_size(cx: &mut TestAppContext) {
214        // With nothing set, the theme's monospace size, not the ambient one.
215        assert_eq!(line_height(cx, None), px(20.));
216        // A text style set on the editor refines over that, rows and all.
217        assert_eq!(line_height(cx, Some(px(24.))), px(36.));
218        assert_eq!(line_height(cx, Some(px(40.))), px(60.));
219    }
220    #[gpui::test]
221    fn language_config_works_without_render_sync(cx: &mut TestAppContext) {
222        use crate::input::{AutoClosingPair, language_config::LanguageConfig, set_language_config};
223        use gpui::EntityInputHandler as _;
224        cx.update(crate::init);
225        let mut state = None;
226        let (_, cx) = cx.add_window_view(|window, cx| {
227            let editor = cx.new(|cx| EditorState::new(window, cx).language("plaintext"));
228            // Plain-text defaults apply before the first render.
229            editor.update(cx, |state, cx| {
230                state.replace_text_in_range(None, "(", window, cx);
231                assert_eq!(state.text().to_string(), "(");
232                state.set_value("", window, cx);
233                state.set_highlighter("json", cx);
234                state.replace_text_in_range(None, "[", window, cx);
235                assert_eq!(state.text().to_string(), "[]");
236            });
237            state = Some(editor.clone());
238            Harness {
239                state: editor,
240                text_size: None,
241            }
242        });
243        let state = state.unwrap();
244        VisualTestContext::update(cx, |_, cx| {
245            set_language_config(
246                "json",
247                LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("«", "»")]),
248                cx,
249            );
250        });
251        // A styled render must preserve the registered configuration.
252        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
253        VisualTestContext::update(cx, |window, cx| {
254            state.update(cx, |state, cx| {
255                state.set_value("", window, cx);
256                state.replace_text_in_range(None, "«", window, cx);
257                assert_eq!(state.text().to_string(), "«»");
258                state.set_value("if enabled:", window, cx);
259                state.set_selected_range(11..11, cx);
260                state.set_highlighter("python", cx);
261                state.focus(window, cx);
262            });
263        });
264        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
265        cx.simulate_keystrokes("enter");
266        cx.read(|cx| assert_eq!(state.read(cx).text().to_string(), "if enabled:\n  "));
267    }
268
269    #[gpui::test]
270    fn aliases_share_config_even_when_registered_before_init(cx: &mut TestAppContext) {
271        use crate::input::{AutoClosingPair, language_config::LanguageConfig, set_language_config};
272        use gpui::EntityInputHandler as _;
273        cx.update(|cx| {
274            set_language_config(
275                "py",
276                LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("«", "»")]),
277                cx,
278            )
279        });
280        cx.update(crate::init);
281        cx.add_window_view(|window, cx| {
282            let editor = cx.new(|cx| EditorState::new(window, cx).language("PYTHON"));
283            editor.update(cx, |state, cx| {
284                state.replace_text_in_range(None, "«", window, cx);
285                assert_eq!(state.text().to_string(), "«»");
286            });
287            set_language_config("pyi", LanguageConfig::default().auto_closing_pairs([]), cx);
288            editor.update(cx, |state, cx| {
289                state.set_value("", window, cx);
290                state.set_highlighter("py", cx);
291                state.replace_text_in_range(None, "(", window, cx);
292                assert_eq!(state.text().to_string(), "(");
293            });
294            Harness {
295                state: editor,
296                text_size: None,
297            }
298        });
299    }
300
301    #[cfg(all(feature = "tree-sitter-python", feature = "tree-sitter-rust"))]
302    #[gpui::test]
303    fn syntax_follows_language_before_first_render_and_after_switch(cx: &mut TestAppContext) {
304        use gpui::EntityInputHandler as _;
305        cx.update(crate::init);
306        cx.add_window_view(|window, cx| {
307            let editor = cx.new(|cx| {
308                EditorState::new(window, cx)
309                    .language("python")
310                    .default_value("\"hello world\"")
311            });
312            editor.update(cx, |state, cx| {
313                state.set_selected_range(6..6, cx);
314                state.replace_text_in_range(None, "(", window, cx);
315                assert_eq!(state.text().to_string(), "\"hello( world\"");
316                state.set_value("# comment ", window, cx);
317                state.set_selected_range(10..10, cx);
318                state.replace_text_in_range(None, "(", window, cx);
319                assert_eq!(state.text().to_string(), "# comment (");
320                state.set_value("# comment ", window, cx);
321                state.set_selected_range(10..10, cx);
322                state.set_highlighter("rust", cx);
323                state.replace_text_in_range(None, "(", window, cx);
324                assert_eq!(state.text().to_string(), "# comment ()");
325            });
326            Harness {
327                state: editor,
328                text_size: None,
329            }
330        });
331    }
332
333    #[cfg(feature = "tree-sitter-python")]
334    #[gpui::test]
335    fn python_pairing_uses_pre_edit_context(cx: &mut TestAppContext) {
336        use gpui::EntityInputHandler as _;
337        cx.update(crate::init);
338        for (before, cursor, typed, expected) in [
339            ("x = ", 4, "\"", "x = \"\""),
340            ("x = f\"{value}\"", 12, "(", "x = f\"{value()}\""),
341            ("x = \"value\"", 7, "(", "x = \"va(lue\""),
342            ("x = \"value\"", 5, "(", "x = \"(value\""),
343            ("x = \"value\"", 10, "(", "x = \"value(\""),
344        ] {
345            let mut state = None;
346            let (_, cx) = cx.add_window_view(|window, cx| {
347                let editor = cx.new(|cx| EditorState::new(window, cx).default_value(before));
348                state = Some(editor.clone());
349                Harness {
350                    state: editor,
351                    text_size: None,
352                }
353            });
354            let state = state.unwrap();
355            VisualTestContext::update(cx, |window, cx| {
356                state.update(cx, |state, cx| {
357                    state.set_highlighter("python", cx);
358                    state.set_selected_range(cursor..cursor, cx);
359                    state.replace_text_in_range(None, typed, window, cx);
360                    assert_eq!(state.text().to_string(), expected);
361                });
362            });
363        }
364    }
365    #[cfg(feature = "tree-sitter-rust")]
366    #[gpui::test]
367    fn generated_comment_closer_survives_syntax_changes(cx: &mut TestAppContext) {
368        use crate::input::{AutoClosingPair, SyntaxContext, language_config::LanguageConfig};
369        use gpui::EntityInputHandler as _;
370        cx.update(crate::init);
371        cx.update(|cx| {
372            crate::input::set_language_config(
373                "rust",
374                LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("/*", "*/")
375                    .not_in([SyntaxContext::String, SyntaxContext::Comment])]),
376                cx,
377            )
378        });
379        let mut state = None;
380        let (_, cx) = cx.add_window_view(|window, cx| {
381            let editor = cx.new(|cx| EditorState::new(window, cx).language("rust"));
382            state = Some(editor.clone());
383            Harness {
384                state: editor,
385                text_size: None,
386            }
387        });
388        let state = state.unwrap();
389        VisualTestContext::update(cx, |window, cx| {
390            state.update(cx, |state, cx| {
391                state.set_highlighter("rust", cx);
392
393                for text in ["/", "*", "x", "*", "/"] {
394                    state.replace_text_in_range(None, text, window, cx);
395                }
396                assert_eq!(state.text().to_string(), "/*x*/");
397                state.replace_text_in_range(None, "!", window, cx);
398                assert_eq!(state.text().to_string(), "/*x*/!");
399            });
400        });
401    }
402
403    #[gpui::test]
404    fn test_on_paste_builder(cx: &mut TestAppContext) {
405        use gpui::{AppContext as _, Render};
406
407        struct PasteProbe;
408        impl Render for PasteProbe {
409            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
410                div()
411            }
412        }
413
414        cx.update(crate::init);
415        let _ = cx.add_window_view(|window, cx| {
416            let state = cx.new(|cx| EditorState::new(window, cx));
417            assert!(Editor::new(&state).paste_handler.is_none());
418            let editor = Editor::new(&state).on_paste(|_, _, _| true);
419            assert!(editor.paste_handler.is_some());
420            PasteProbe
421        });
422    }
423}