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
36impl Editor {
37    pub fn new(state: &Entity<EditorState>) -> Self {
38        Self {
39            state: state.clone(),
40            style: StyleRefinement::default(),
41            height: None,
42            appearance: true,
43            bordered: true,
44            disabled: false,
45            readonly: false,
46            tab_index: 0,
47            role: RoleOverride::default(),
48            aria_label: None,
49            context_menu_builder: None,
50        }
51    }
52
53    pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
54        self.height = Some(height.into());
55        self
56    }
57
58    pub fn appearance(mut self, appearance: bool) -> Self {
59        self.appearance = appearance;
60        self
61    }
62
63    pub fn bordered(mut self, bordered: bool) -> Self {
64        self.bordered = bordered;
65        self
66    }
67
68    pub fn disabled(mut self, disabled: bool) -> Self {
69        self.disabled = disabled;
70        self
71    }
72
73    /// Set the editor to read-only, default is `false`.
74    ///
75    /// Unlike [`Self::disabled`], a read-only editor keeps the normal appearance
76    /// and still can be focused, selected and copied, it only rejects the changes
77    /// made by the user.
78    pub fn readonly(mut self, readonly: bool) -> Self {
79        self.readonly = readonly;
80        self
81    }
82
83    pub fn tab_index(mut self, index: isize) -> Self {
84        self.tab_index = index;
85        self
86    }
87
88    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
89        self.role = role.into();
90        self
91    }
92
93    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
94        self.aria_label = Some(label.into());
95        self
96    }
97
98    /// Replace the built-in context menu shown on right-click.
99    ///
100    /// The closure receives an empty menu and returns the one to show, so it
101    /// decides entirely what appears — the default items are not added.
102    pub fn context_menu(
103        mut self,
104        f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
105    ) -> Self {
106        self.context_menu_builder = Some(Rc::new(f));
107        self
108    }
109}
110
111impl Styled for Editor {
112    fn style(&mut self) -> &mut StyleRefinement {
113        &mut self.style
114    }
115}
116
117impl RenderOnce for Editor {
118    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
119        Input::from_state(self.state.clone())
120            // Source code wants a monospace font at a code size, and rows that
121            // follow that size. These come first so that a text style set on
122            // this editor refines over them: `.text_sm()` and `.font_family()`
123            // keep working.
124            .font_family(cx.theme().mono_font_family.clone())
125            .text_size(cx.theme().mono_font_size)
126            .line_height(relative(EDITOR_LINE_HEIGHT))
127            .appearance(self.appearance)
128            .bordered(self.bordered)
129            .focus_bordered(false)
130            .disabled(self.disabled)
131            .readonly(self.readonly)
132            .tab_index(self.tab_index)
133            .role(self.role)
134            .when_some(self.height, |this, height| this.h(height))
135            .when_some(self.aria_label, |this, label| this.aria_label(label))
136            .when_some(self.context_menu_builder, |this, build| {
137                this.context_menu(move |menu, window, cx| build(menu, window, cx))
138            })
139            .refine_style(&self.style)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::input::EditorState;
147    use gpui::{
148        AppContext as _, Context, ParentElement as _, Pixels, Render, TestAppContext,
149        VisualTestContext, div, px,
150    };
151
152    struct Harness {
153        state: Entity<EditorState>,
154        /// A text size set on the editor, as `.text_sm()` would.
155        text_size: Option<Pixels>,
156    }
157
158    impl Render for Harness {
159        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
160            div().size_full().child(
161                Editor::new(&self.state)
162                    .when_some(self.text_size, |this, size| this.text_size(size)),
163            )
164        }
165    }
166
167    /// The row height the editor laid out with, which follows its font size.
168    fn line_height(cx: &mut TestAppContext, text_size: Option<Pixels>) -> Pixels {
169        cx.update(crate::init);
170        let mut state = None;
171        let (_, cx) = cx.add_window_view(|window, cx| {
172            let editor = cx.new(|cx| EditorState::new(window, cx).default_value("fn main() {}"));
173            state = Some(editor.clone());
174            Harness {
175                state: editor,
176                text_size,
177            }
178        });
179        let state = state.unwrap();
180        VisualTestContext::update(cx, |window, cx| window.draw(cx).clear(cx));
181
182        cx.read(|cx| {
183            state
184                .read(cx)
185                .line_height()
186                .expect("the editor must lay out")
187        })
188    }
189
190    #[gpui::test]
191    fn the_rows_follow_the_font_size(cx: &mut TestAppContext) {
192        // With nothing set, the theme's monospace size, not the ambient one.
193        assert_eq!(line_height(cx, None), px(20.));
194        // A text style set on the editor refines over that, rows and all.
195        assert_eq!(line_height(cx, Some(px(24.))), px(36.));
196        assert_eq!(line_height(cx, Some(px(40.))), px(60.));
197    }
198}