1use crate::{CodeBlock, CodeLanguage, CodeTheme, Input};
2use gpui::{
3 App, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, KeyBinding, Pixels, Render,
4 SharedString, Window, actions, div, prelude::*, px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use std::sync::Arc;
10
11pub type CodeEditorChangeCallback = dyn Fn(&str, &mut Context<CodeEditor>) + 'static;
12pub type CodeDiagnosticsProvider = dyn Fn(&str) -> Vec<CodeDiagnostic> + 'static;
13
14actions!(code_editor, [CodeIndent, CodeOutdent]);
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum CodeDiagnosticSeverity {
18 Info,
19 Warning,
20 Error,
21}
22
23impl CodeDiagnosticSeverity {
24 fn label(self) -> &'static str {
25 match self {
26 Self::Info => "info",
27 Self::Warning => "warning",
28 Self::Error => "error",
29 }
30 }
31
32 fn color(self, theme: &liora_theme::Theme) -> Hsla {
33 match self {
34 Self::Info => theme.info.base,
35 Self::Warning => theme.warning.base,
36 Self::Error => theme.danger.base,
37 }
38 }
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct CodeDiagnostic {
43 pub line: usize,
44 pub column: usize,
45 pub severity: CodeDiagnosticSeverity,
46 pub message: SharedString,
47}
48
49impl CodeDiagnostic {
50 pub fn new(
51 line: usize,
52 column: usize,
53 severity: CodeDiagnosticSeverity,
54 message: impl Into<SharedString>,
55 ) -> Self {
56 Self {
57 line: line.max(1),
58 column: column.max(1),
59 severity,
60 message: message.into(),
61 }
62 }
63
64 pub fn info(line: usize, column: usize, message: impl Into<SharedString>) -> Self {
65 Self::new(line, column, CodeDiagnosticSeverity::Info, message)
66 }
67
68 pub fn warning(line: usize, column: usize, message: impl Into<SharedString>) -> Self {
69 Self::new(line, column, CodeDiagnosticSeverity::Warning, message)
70 }
71
72 pub fn error(line: usize, column: usize, message: impl Into<SharedString>) -> Self {
73 Self::new(line, column, CodeDiagnosticSeverity::Error, message)
74 }
75}
76
77pub struct CodeEditor {
84 input: Entity<Input>,
85 focus_handle: FocusHandle,
86 language: CodeLanguage,
87 theme: CodeTheme,
88 line_numbers: bool,
89 tab_size: usize,
90 soft_tabs: bool,
91 rows: usize,
92 height: Option<Pixels>,
93 preview: bool,
94 diagnostics: Vec<CodeDiagnostic>,
95 diagnostics_provider: Option<Arc<CodeDiagnosticsProvider>>,
96 on_change: Option<Arc<CodeEditorChangeCallback>>,
97}
98
99impl CodeEditor {
100 pub fn new(value: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
101 let value = value.into();
102 let rows = line_count(value.as_ref()).max(8);
103 let owner = cx.entity().downgrade();
104 let input = cx.new(|cx| {
105 Input::new(value, cx)
106 .min_rows(rows)
107 .on_change(move |value, cx| {
108 let _ = owner.update(cx, |editor, cx| editor.handle_input_change(value, cx));
109 })
110 });
111
112 Self {
113 input,
114 focus_handle: cx.focus_handle(),
115 language: CodeLanguage::PlainText,
116 theme: CodeTheme::Auto,
117 line_numbers: true,
118 tab_size: 4,
119 soft_tabs: true,
120 rows,
121 height: None,
122 preview: true,
123 diagnostics: Vec::new(),
124 diagnostics_provider: None,
125 on_change: None,
126 }
127 }
128
129 pub fn entity(value: impl Into<SharedString>, cx: &mut App) -> Entity<Self> {
130 let value = value.into();
131 cx.new(|cx| Self::new(value, cx))
132 }
133
134 pub fn value(&self, cx: &App) -> SharedString {
135 self.input.read(cx).value()
136 }
137
138 pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut Context<Self>) {
139 self.input
140 .update(cx, |input, cx| input.set_value(value, cx));
141 cx.notify();
142 }
143
144 pub fn language(mut self, language: impl Into<CodeLanguage>) -> Self {
145 self.language = language.into();
146 self
147 }
148
149 pub fn set_language(&mut self, language: impl Into<CodeLanguage>, cx: &mut Context<Self>) {
150 let language = language.into();
151 if self.language != language {
152 self.language = language;
153 cx.notify();
154 }
155 }
156
157 pub fn theme(mut self, theme: CodeTheme) -> Self {
158 self.theme = theme;
159 self
160 }
161
162 pub fn line_numbers(mut self, enabled: bool) -> Self {
163 self.line_numbers = enabled;
164 self
165 }
166
167 pub fn tab_size(mut self, size: usize) -> Self {
168 self.tab_size = size.max(1);
169 self
170 }
171
172 pub fn soft_tabs(mut self, enabled: bool) -> Self {
173 self.soft_tabs = enabled;
174 self
175 }
176
177 pub fn rows(mut self, rows: usize) -> Self {
178 self.rows = rows.max(1);
179 self
180 }
181
182 pub fn height(mut self, height: impl Into<Pixels>) -> Self {
183 self.height = Some(height.into());
184 self
185 }
186
187 pub fn preview(mut self, preview: bool) -> Self {
188 self.preview = preview;
189 self
190 }
191
192 pub fn diagnostics(mut self, diagnostics: impl IntoIterator<Item = CodeDiagnostic>) -> Self {
193 self.diagnostics = diagnostics.into_iter().collect();
194 self
195 }
196
197 pub fn set_diagnostics(
198 &mut self,
199 diagnostics: impl IntoIterator<Item = CodeDiagnostic>,
200 cx: &mut Context<Self>,
201 ) {
202 self.diagnostics = diagnostics.into_iter().collect();
203 cx.notify();
204 }
205
206 pub fn diagnostics_provider(
207 mut self,
208 provider: impl Fn(&str) -> Vec<CodeDiagnostic> + 'static,
209 ) -> Self {
210 self.diagnostics_provider = Some(Arc::new(provider));
211 self
212 }
213
214 pub fn set_diagnostics_provider(
215 &mut self,
216 provider: impl Fn(&str) -> Vec<CodeDiagnostic> + 'static,
217 cx: &mut Context<Self>,
218 ) {
219 self.diagnostics_provider = Some(Arc::new(provider));
220 self.refresh_diagnostics(cx);
221 cx.notify();
222 }
223
224 pub fn clear_diagnostics_provider(&mut self, cx: &mut Context<Self>) {
225 self.diagnostics_provider = None;
226 cx.notify();
227 }
228
229 pub fn on_change(
230 mut self,
231 callback: impl Fn(&str, &mut Context<CodeEditor>) + 'static,
232 ) -> Self {
233 self.on_change = Some(Arc::new(callback));
234 self
235 }
236
237 pub fn set_on_change(
238 &mut self,
239 callback: impl Fn(&str, &mut Context<CodeEditor>) + 'static,
240 cx: &mut Context<Self>,
241 ) {
242 self.on_change = Some(Arc::new(callback));
243 cx.notify();
244 }
245
246 pub fn indent_unit(&self) -> String {
247 if self.soft_tabs {
248 " ".repeat(self.tab_size)
249 } else {
250 "\t".to_string()
251 }
252 }
253
254 pub fn register_key_bindings(cx: &mut App) {
255 cx.bind_keys([
256 KeyBinding::new("tab", CodeIndent, None),
257 KeyBinding::new("shift-tab", CodeOutdent, None),
258 ]);
259 }
260
261 fn indent(&mut self, _: &CodeIndent, _: &mut Window, cx: &mut Context<Self>) {
262 let indent = self.indent_unit();
263 self.input
264 .update(cx, |input, cx| input.indent_selection(&indent, cx));
265 }
266
267 fn outdent(&mut self, _: &CodeOutdent, _: &mut Window, cx: &mut Context<Self>) {
268 let indent = self.indent_unit();
269 self.input
270 .update(cx, |input, cx| input.outdent_selection(&indent, cx));
271 }
272
273 fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
274 if let Some(provider) = self.diagnostics_provider.clone() {
275 let value = self.value(cx);
276 self.diagnostics = provider(value.as_ref());
277 }
278 }
279
280 fn handle_input_change(&mut self, value: &str, cx: &mut Context<Self>) {
281 if let Some(provider) = self.diagnostics_provider.clone() {
282 self.diagnostics = provider(value);
283 }
284 if let Some(callback) = self.on_change.clone() {
285 callback(value, cx);
286 }
287 cx.notify();
288 }
289}
290
291impl Focusable for CodeEditor {
292 fn focus_handle(&self, _cx: &App) -> FocusHandle {
293 self.focus_handle.clone()
294 }
295}
296
297impl Render for CodeEditor {
298 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
299 let theme = cx.global::<Config>().theme.clone();
300 let value = self.value(cx);
301 let line_count = line_count(value.as_ref());
302 let rows = self.rows.max(line_count).max(1);
303 self.input.update(cx, |input, cx| {
304 if input.min_rows != rows {
305 input.set_min_rows(rows, cx);
306 }
307 });
308
309 let indent_label = if self.soft_tabs {
310 format!("spaces:{}", self.tab_size)
311 } else {
312 "tabs".to_string()
313 };
314
315 div()
316 .flex()
317 .flex_col()
318 .w_full()
319 .rounded(px(theme.radius.lg))
320 .border_1()
321 .border_color(theme.neutral.border)
322 .bg(theme.neutral.card)
323 .overflow_hidden()
324 .when_some(self.height, |s, height| s.h(height))
325 .on_action(cx.listener(Self::indent))
326 .on_action(cx.listener(Self::outdent))
327 .child(
328 div()
329 .flex()
330 .items_center()
331 .justify_between()
332 .gap_3()
333 .px_4()
334 .py_2()
335 .border_b_1()
336 .border_color(theme.neutral.border)
337 .bg(theme.neutral.hover.opacity(0.52))
338 .child(
339 div()
340 .flex()
341 .items_center()
342 .gap_2()
343 .text_sm()
344 .font_weight(gpui::FontWeight::BOLD)
345 .text_color(theme.neutral.text_1)
346 .child(
347 Icon::new(IconName::FileCode)
348 .size(px(14.0))
349 .color(theme.primary.base),
350 )
351 .child("CodeEditor"),
352 )
353 .child(
354 div()
355 .flex()
356 .items_center()
357 .gap_3()
358 .text_xs()
359 .text_color(theme.neutral.text_3)
360 .child(self.language.label())
361 .child(indent_label)
362 .child(format!("{} lines", line_count)),
363 ),
364 )
365 .child(
366 div()
367 .flex()
368 .items_start()
369 .min_h(px(220.0))
370 .bg(theme.neutral.hover.opacity(0.24))
371 .child(if self.line_numbers {
372 line_number_gutter(line_count, &theme).into_any_element()
373 } else {
374 div().into_any_element()
375 })
376 .child(
377 div()
378 .flex_1()
379 .p_3()
380 .font_family(".ZedMono")
381 .text_sm()
382 .child(self.input.clone()),
383 ),
384 )
385 .when(!self.diagnostics.is_empty(), |s| {
386 s.child(render_diagnostics(&self.diagnostics, &theme))
387 })
388 .when(self.preview, |s| {
389 s.child(
390 div()
391 .border_t_1()
392 .border_color(theme.neutral.border)
393 .p_3()
394 .child(
395 div()
396 .mb_2()
397 .text_xs()
398 .font_weight(gpui::FontWeight::BOLD)
399 .text_color(theme.neutral.text_3)
400 .child("Syntax preview"),
401 )
402 .child(
403 CodeBlock::new(value)
404 .language(self.language)
405 .theme(self.theme)
406 .copyable(false)
407 .selectable(true),
408 ),
409 )
410 })
411 }
412}
413
414fn line_count(value: &str) -> usize {
415 value.lines().count().max(1)
416}
417
418fn line_number_gutter(line_count: usize, theme: &liora_theme::Theme) -> gpui::Div {
419 let mut gutter = div()
420 .flex_none()
421 .w(px(52.0))
422 .px_3()
423 .py_4()
424 .border_r_1()
425 .border_color(theme.neutral.border)
426 .font_family(".ZedMono")
427 .text_xs()
428 .text_color(theme.neutral.text_3)
429 .flex()
430 .flex_col()
431 .items_end()
432 .gap_1();
433
434 for line in 1..=line_count {
435 gutter = gutter.child(format!("{line}"));
436 }
437
438 gutter
439}
440
441fn render_diagnostics(diagnostics: &[CodeDiagnostic], theme: &liora_theme::Theme) -> gpui::Div {
442 let mut panel = div()
443 .flex()
444 .flex_col()
445 .gap_1()
446 .border_t_1()
447 .border_color(theme.neutral.border)
448 .bg(theme.neutral.hover.opacity(0.36))
449 .px_4()
450 .py_3();
451
452 for diagnostic in diagnostics {
453 let color = diagnostic.severity.color(theme);
454 panel = panel.child(
455 div()
456 .flex()
457 .items_start()
458 .gap_2()
459 .text_sm()
460 .child(div().mt(px(7.0)).size(px(6.0)).rounded_full().bg(color))
461 .child(
462 div()
463 .flex_1()
464 .child(
465 div()
466 .text_xs()
467 .font_weight(gpui::FontWeight::BOLD)
468 .text_color(color)
469 .child(format!(
470 "{} at {}:{}",
471 diagnostic.severity.label(),
472 diagnostic.line,
473 diagnostic.column
474 )),
475 )
476 .child(
477 div()
478 .text_color(theme.neutral.text_2)
479 .child(diagnostic.message.clone()),
480 ),
481 ),
482 );
483 }
484
485 panel
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn diagnostic_constructors_clamp_to_one_based_locations() {
494 let diagnostic = CodeDiagnostic::warning(0, 0, "missing semicolon");
495 assert_eq!(diagnostic.line, 1);
496 assert_eq!(diagnostic.column, 1);
497 assert_eq!(diagnostic.severity, CodeDiagnosticSeverity::Warning);
498 }
499
500 #[test]
501 fn code_editor_exposes_planned_mvp_api() {
502 let source = include_str!("code_editor.rs");
503 assert!(source.contains("pub struct CodeEditor"));
504 assert!(source.contains("line_numbers"));
505 assert!(source.contains("tab_size"));
506 assert!(source.contains("soft_tabs"));
507 assert!(source.contains("diagnostics"));
508 assert!(source.contains("diagnostics_provider"));
509 assert!(source.contains("CodeIndent"));
510 assert!(source.contains("CodeOutdent"));
511 assert!(source.contains("CodeBlock::new"));
512 assert!(source.contains("on_change"));
513 }
514}