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
12const EDITOR_LINE_HEIGHT: f32 = 1.5;
15
16#[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 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 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 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 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 .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 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 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 assert_eq!(line_height(cx, None), px(20.));
216 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 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 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}