Skip to main content

liora_components/
code_editor.rs

1//! Code Editor module.
2//!
3//! This public module implements the Liora native code editor surface for editable snippets and diagnostics. It keeps the reusable
4//! component logic inside `liora-components` rather than Gallery or Docs so
5//! downstream GPUI applications can compose the same behavior with their own
6//! app state, assets, and release policy.
7//!
8//! ## Usage model
9//!
10//! Components in this module render native GPUI element trees. Stateless builder
11//! values can be constructed inline, while controls with focus, selection,
12//! popup, drag, or editing state should be stored as `gpui::Entity<T>` fields in
13//! the parent view so state survives GPUI render passes.
14//!
15//! ## Design contract
16//!
17//! The implementation should use Liora theme tokens from `liora-core` and
18//! `liora-theme`, keep accessibility-oriented keyboard/pointer behavior close to
19//! the component, and avoid app-specific Gallery/Docs resources in this SDK
20//! crate.
21
22use 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
32/// Type alias for code editor change callback values used by the code editor API.
33pub type CodeEditorChangeCallback = dyn Fn(&str, &mut Context<CodeEditor>) + 'static;
34/// Type alias for code diagnostics provider values used by the code editor API.
35pub 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)]
48/// Options that control code diagnostic severity behavior.
49pub enum CodeDiagnosticSeverity {
50    /// Uses informational semantic color tokens.
51    Info,
52    /// Uses warning semantic color tokens.
53    Warning,
54    /// Reports a error failure.
55    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)]
77/// Fluent native GPUI component for rendering Liora code diagnostic.
78pub struct CodeDiagnostic {
79    /// One-based source line for diagnostics.
80    pub line: usize,
81    /// One-based source column for diagnostics.
82    pub column: usize,
83    /// Diagnostic severity used to choose color and icon treatment.
84    pub severity: CodeDiagnosticSeverity,
85    /// User-facing message associated with this item.
86    pub message: SharedString,
87}
88
89impl CodeDiagnostic {
90    /// Creates `CodeDiagnostic` with default theme-driven styling and no optional callbacks attached.
91    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    /// Applies the informational semantic visual variant.
106    pub fn info(line: usize, column: usize, message: impl Into<SharedString>) -> Self {
107        Self::new(line, column, CodeDiagnosticSeverity::Info, message)
108    }
109
110    /// Applies the warning semantic visual variant.
111    pub fn warning(line: usize, column: usize, message: impl Into<SharedString>) -> Self {
112        Self::new(line, column, CodeDiagnosticSeverity::Warning, message)
113    }
114
115    /// Sets the error value used by the component.
116    pub fn error(line: usize, column: usize, message: impl Into<SharedString>) -> Self {
117        Self::new(line, column, CodeDiagnosticSeverity::Error, message)
118    }
119}
120
121/// Native code editing surface with line numbers, indentation metadata,
122/// syntax-highlight preview and pluggable diagnostics.
123///
124/// The current MVP deliberately reuses Liora's native `Input` editing core and
125/// `CodeBlock` highlighter instead of embedding a Web editor runtime. Future
126/// diagnostics providers can update `set_diagnostics` without changing the UI.
127pub 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    /// Creates `CodeEditor` initialized from the supplied value.
145    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    /// Creates a GPUI entity that owns this component state across render passes.
175    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    /// Returns the serialized value used by forms, configuration, or persistence.
181    pub fn value(&self, cx: &App) -> SharedString {
182        self.input.read(cx).value()
183    }
184
185    /// Updates the stored value value and keeps the existing component identity.
186    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    /// Sets the language identifier used for code display.
193    pub fn language(mut self, language: impl Into<CodeLanguage>) -> Self {
194        self.language = language.into();
195        self
196    }
197
198    /// Updates the stored language value and keeps the existing component identity.
199    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    /// Applies an explicit theme or theme mode.
208    pub fn theme(mut self, theme: CodeTheme) -> Self {
209        self.theme = theme;
210        self
211    }
212
213    /// Sets the line numbers value used by the component.
214    pub fn line_numbers(mut self, enabled: bool) -> Self {
215        self.line_numbers = enabled;
216        self
217    }
218
219    /// Sets the tab size value used by the component.
220    pub fn tab_size(mut self, size: usize) -> Self {
221        self.tab_size = size.max(1);
222        self
223    }
224
225    /// Sets the soft tabs value used by the component.
226    pub fn soft_tabs(mut self, enabled: bool) -> Self {
227        self.soft_tabs = enabled;
228        self
229    }
230
231    /// Sets the visible row count for editor-like controls.
232    pub fn rows(mut self, rows: usize) -> Self {
233        self.rows = rows.max(1);
234        self
235    }
236
237    /// Sets the component height token used during GPUI layout.
238    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
239        self.height = Some(height.into());
240        self
241    }
242
243    /// Sets the preview value used by the component.
244    pub fn preview(mut self, preview: bool) -> Self {
245        self.preview = preview;
246        self
247    }
248
249    /// Sets the diagnostics value used by the component.
250    pub fn diagnostics(mut self, diagnostics: impl IntoIterator<Item = CodeDiagnostic>) -> Self {
251        self.diagnostics = diagnostics.into_iter().collect();
252        self
253    }
254
255    /// Updates the stored diagnostics value and keeps the existing component identity.
256    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    /// Performs the diagnostics provider operation used by this component.
266    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    /// Updates the stored diagnostics provider value and keeps the existing component identity.
275    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    /// Clears the current diagnostics provider state.
286    pub fn clear_diagnostics_provider(&mut self, cx: &mut Context<Self>) {
287        self.diagnostics_provider = None;
288        cx.notify();
289    }
290
291    /// Registers a callback that runs when change occurs.
292    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    /// Updates the stored on change value and keeps the existing component identity.
301    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    /// Performs the indent unit operation used by this component.
311    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    /// Registers GPUI key bindings required for keyboard interaction.
320    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}