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(
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 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 .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 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 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 assert_eq!(line_height(cx, None), px(20.));
214 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 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 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}