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