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