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