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.
105    pub fn context_menu(
106        mut self,
107        f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
108    ) -> Self {
109        self.context_menu_builder = Some(Rc::new(f));
110        self
111    }
112
113    /// Intercept paste payloads (images, files) before the default text insertion.
114    ///
115    /// `true` consumes the paste so nothing is inserted, `false` falls through
116    /// to `clipboard.text()`. Copied files arrive as `ExternalPaths` through
117    /// the same hook. On web the clipboard reads `None`; image paste needs
118    /// async clipboard access and is out of scope.
119    pub fn on_paste(
120        mut self,
121        handler: impl Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool + 'static,
122    ) -> Self {
123        self.paste_handler = Some(Rc::new(handler));
124        self
125    }
126}
127
128impl Styled for Editor {
129    fn style(&mut self) -> &mut StyleRefinement {
130        &mut self.style
131    }
132}
133
134impl RenderOnce for Editor {
135    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
136        Input::from_state(self.state.clone())
137            // Source code wants a monospace font at a code size, and rows that
138            // follow that size. These come first so that a text style set on
139            // this editor refines over them: `.text_sm()` and `.font_family()`
140            // keep working.
141            .font_family(cx.theme().mono_font_family.clone())
142            .text_size(cx.theme().mono_font_size)
143            .line_height(relative(EDITOR_LINE_HEIGHT))
144            .appearance(self.appearance)
145            .bordered(self.bordered)
146            .focus_bordered(false)
147            .disabled(self.disabled)
148            .readonly(self.readonly)
149            .tab_index(self.tab_index)
150            .role(self.role)
151            .when_some(self.height, |this, height| this.h(height))
152            .when_some(self.aria_label, |this, label| this.aria_label(label))
153            .when_some(self.context_menu_builder, |this, build| {
154                this.context_menu(move |menu, window, cx| build(menu, window, cx))
155            })
156            .when_some(self.paste_handler, |this, handler| {
157                this.on_paste(move |item, window, cx| handler(item, window, cx))
158            })
159            .refine_style(&self.style)
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::input::EditorState;
167    use gpui::{
168        AppContext as _, Context, ParentElement as _, Pixels, Render, TestAppContext,
169        VisualTestContext, div, px,
170    };
171
172    struct Harness {
173        state: Entity<EditorState>,
174        /// A text size set on the editor, as `.text_sm()` would.
175        text_size: Option<Pixels>,
176    }
177
178    impl Render for Harness {
179        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
180            div().size_full().child(
181                Editor::new(&self.state)
182                    .when_some(self.text_size, |this, size| this.text_size(size)),
183            )
184        }
185    }
186
187    /// The row height the editor laid out with, which follows its font size.
188    fn line_height(cx: &mut TestAppContext, text_size: Option<Pixels>) -> Pixels {
189        cx.update(crate::init);
190        let mut state = None;
191        let (_, cx) = cx.add_window_view(|window, cx| {
192            let editor = cx.new(|cx| EditorState::new(window, cx).default_value("fn main() {}"));
193            state = Some(editor.clone());
194            Harness {
195                state: editor,
196                text_size,
197            }
198        });
199        let state = state.unwrap();
200        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
201
202        cx.read(|cx| {
203            state
204                .read(cx)
205                .line_height()
206                .expect("the editor must lay out")
207        })
208    }
209
210    #[gpui::test]
211    fn the_rows_follow_the_font_size(cx: &mut TestAppContext) {
212        // With nothing set, the theme's monospace size, not the ambient one.
213        assert_eq!(line_height(cx, None), px(20.));
214        // A text style set on the editor refines over that, rows and all.
215        assert_eq!(line_height(cx, Some(px(24.))), px(36.));
216        assert_eq!(line_height(cx, Some(px(40.))), px(60.));
217    }
218    #[gpui::test]
219    fn language_config_works_without_render_sync(cx: &mut TestAppContext) {
220        use crate::input::{AutoClosingPair, language_config::LanguageConfig, set_language_config};
221        use gpui::EntityInputHandler as _;
222        cx.update(crate::init);
223        let mut state = None;
224        let (_, cx) = cx.add_window_view(|window, cx| {
225            let editor = cx.new(|cx| EditorState::new(window, cx).language("plaintext"));
226            // Plain-text defaults apply before the first render.
227            editor.update(cx, |state, cx| {
228                state.replace_text_in_range(None, "(", window, cx);
229                assert_eq!(state.text().to_string(), "(");
230                state.set_value("", window, cx);
231                state.set_highlighter("json", cx);
232                state.replace_text_in_range(None, "[", window, cx);
233                assert_eq!(state.text().to_string(), "[]");
234            });
235            state = Some(editor.clone());
236            Harness {
237                state: editor,
238                text_size: None,
239            }
240        });
241        let state = state.unwrap();
242        VisualTestContext::update(cx, |_, cx| {
243            set_language_config(
244                "json",
245                LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("«", "»")]),
246                cx,
247            );
248        });
249        // A styled render must preserve the registered configuration.
250        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
251        VisualTestContext::update(cx, |window, cx| {
252            state.update(cx, |state, cx| {
253                state.set_value("", window, cx);
254                state.replace_text_in_range(None, "«", window, cx);
255                assert_eq!(state.text().to_string(), "«»");
256                state.set_value("if enabled:", window, cx);
257                state.set_selected_range(11..11, cx);
258                state.set_highlighter("python", cx);
259                state.focus(window, cx);
260            });
261        });
262        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
263        cx.simulate_keystrokes("enter");
264        cx.read(|cx| assert_eq!(state.read(cx).text().to_string(), "if enabled:\n  "));
265    }
266
267    #[gpui::test]
268    fn aliases_share_config_even_when_registered_before_init(cx: &mut TestAppContext) {
269        use crate::input::{AutoClosingPair, language_config::LanguageConfig, set_language_config};
270        use gpui::EntityInputHandler as _;
271        cx.update(|cx| {
272            set_language_config(
273                "py",
274                LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("«", "»")]),
275                cx,
276            )
277        });
278        cx.update(crate::init);
279        cx.add_window_view(|window, cx| {
280            let editor = cx.new(|cx| EditorState::new(window, cx).language("PYTHON"));
281            editor.update(cx, |state, cx| {
282                state.replace_text_in_range(None, "«", window, cx);
283                assert_eq!(state.text().to_string(), "«»");
284            });
285            set_language_config("pyi", LanguageConfig::default().auto_closing_pairs([]), cx);
286            editor.update(cx, |state, cx| {
287                state.set_value("", window, cx);
288                state.set_highlighter("py", cx);
289                state.replace_text_in_range(None, "(", window, cx);
290                assert_eq!(state.text().to_string(), "(");
291            });
292            Harness {
293                state: editor,
294                text_size: None,
295            }
296        });
297    }
298
299    #[cfg(all(feature = "tree-sitter-python", feature = "tree-sitter-rust"))]
300    #[gpui::test]
301    fn syntax_follows_language_before_first_render_and_after_switch(cx: &mut TestAppContext) {
302        use gpui::EntityInputHandler as _;
303        cx.update(crate::init);
304        cx.add_window_view(|window, cx| {
305            let editor = cx.new(|cx| {
306                EditorState::new(window, cx)
307                    .language("python")
308                    .default_value("\"hello world\"")
309            });
310            editor.update(cx, |state, cx| {
311                state.set_selected_range(6..6, cx);
312                state.replace_text_in_range(None, "(", window, cx);
313                assert_eq!(state.text().to_string(), "\"hello( world\"");
314                state.set_value("# comment ", window, cx);
315                state.set_selected_range(10..10, cx);
316                state.replace_text_in_range(None, "(", window, cx);
317                assert_eq!(state.text().to_string(), "# comment (");
318                state.set_value("# comment ", window, cx);
319                state.set_selected_range(10..10, cx);
320                state.set_highlighter("rust", cx);
321                state.replace_text_in_range(None, "(", window, cx);
322                assert_eq!(state.text().to_string(), "# comment ()");
323            });
324            Harness {
325                state: editor,
326                text_size: None,
327            }
328        });
329    }
330
331    #[cfg(feature = "tree-sitter-python")]
332    #[gpui::test]
333    fn python_pairing_uses_pre_edit_context(cx: &mut TestAppContext) {
334        use gpui::EntityInputHandler as _;
335        cx.update(crate::init);
336        for (before, cursor, typed, expected) in [
337            ("x = ", 4, "\"", "x = \"\""),
338            ("x = f\"{value}\"", 12, "(", "x = f\"{value()}\""),
339            ("x = \"value\"", 7, "(", "x = \"va(lue\""),
340            ("x = \"value\"", 5, "(", "x = \"(value\""),
341            ("x = \"value\"", 10, "(", "x = \"value(\""),
342        ] {
343            let mut state = None;
344            let (_, cx) = cx.add_window_view(|window, cx| {
345                let editor = cx.new(|cx| EditorState::new(window, cx).default_value(before));
346                state = Some(editor.clone());
347                Harness {
348                    state: editor,
349                    text_size: None,
350                }
351            });
352            let state = state.unwrap();
353            VisualTestContext::update(cx, |window, cx| {
354                state.update(cx, |state, cx| {
355                    state.set_highlighter("python", cx);
356                    state.set_selected_range(cursor..cursor, cx);
357                    state.replace_text_in_range(None, typed, window, cx);
358                    assert_eq!(state.text().to_string(), expected);
359                });
360            });
361        }
362    }
363    #[cfg(feature = "tree-sitter-rust")]
364    #[gpui::test]
365    fn generated_comment_closer_survives_syntax_changes(cx: &mut TestAppContext) {
366        use crate::input::{AutoClosingPair, SyntaxContext, language_config::LanguageConfig};
367        use gpui::EntityInputHandler as _;
368        cx.update(crate::init);
369        cx.update(|cx| {
370            crate::input::set_language_config(
371                "rust",
372                LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("/*", "*/")
373                    .not_in([SyntaxContext::String, SyntaxContext::Comment])]),
374                cx,
375            )
376        });
377        let mut state = None;
378        let (_, cx) = cx.add_window_view(|window, cx| {
379            let editor = cx.new(|cx| EditorState::new(window, cx).language("rust"));
380            state = Some(editor.clone());
381            Harness {
382                state: editor,
383                text_size: None,
384            }
385        });
386        let state = state.unwrap();
387        VisualTestContext::update(cx, |window, cx| {
388            state.update(cx, |state, cx| {
389                state.set_highlighter("rust", cx);
390
391                for text in ["/", "*", "x", "*", "/"] {
392                    state.replace_text_in_range(None, text, window, cx);
393                }
394                assert_eq!(state.text().to_string(), "/*x*/");
395                state.replace_text_in_range(None, "!", window, cx);
396                assert_eq!(state.text().to_string(), "/*x*/!");
397            });
398        });
399    }
400
401    #[gpui::test]
402    fn test_on_paste_builder(cx: &mut TestAppContext) {
403        use gpui::{AppContext as _, Render};
404
405        struct PasteProbe;
406        impl Render for PasteProbe {
407            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
408                div()
409            }
410        }
411
412        cx.update(crate::init);
413        let _ = cx.add_window_view(|window, cx| {
414            let state = cx.new(|cx| EditorState::new(window, cx));
415            assert!(Editor::new(&state).paste_handler.is_none());
416            let editor = Editor::new(&state).on_paste(|_, _, _| true);
417            assert!(editor.paste_handler.is_some());
418            PasteProbe
419        });
420    }
421}