Skip to main content

liora_components/
code_editor.rs

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
77/// Native code editing surface with line numbers, indentation metadata,
78/// syntax-highlight preview and pluggable diagnostics.
79///
80/// The current MVP deliberately reuses Liora's native `Input` editing core and
81/// `CodeBlock` highlighter instead of embedding a Web editor runtime. Future
82/// diagnostics providers can update `set_diagnostics` without changing the UI.
83pub 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}