Skip to main content

dioxus_code_editor/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use dioxus::prelude::*;
5pub use dioxus_code::Language;
6#[cfg(test)]
7use dioxus_code::Theme;
8use dioxus_code::advanced::{Buffer, CodeThemeStyles, HighlightError, TokenSpan};
9#[cfg(test)]
10use dioxus_code::advanced::{HighlightSegment, HighlightedSource};
11use dioxus_code::{CodeTheme, SourceCode};
12use std::{cell::RefCell, rc::Rc};
13
14mod edit_capture;
15
16/// Base stylesheet injected by [`CodeEditor`].
17///
18/// ```rust
19/// use dioxus_code_editor::CODE_EDITOR_CSS;
20/// let _href = CODE_EDITOR_CSS;
21/// ```
22pub const CODE_EDITOR_CSS: Asset = asset!("/assets/dioxus-code-editor.css");
23
24/// Props for [`CodeEditor`].
25///
26/// ```rust,no_run
27/// use dioxus_code::{CodeTheme, Theme};
28/// use dioxus_code_editor::{CodeEditorProps, Language};
29/// let _props = CodeEditorProps::builder()
30///     .value("fn main() {}")
31///     .language(Language::Rust)
32///     .theme(CodeTheme::fixed(Theme::TOKYO_NIGHT))
33///     .build();
34/// ```
35#[derive(Props, Clone, PartialEq)]
36pub struct CodeEditorProps {
37    /// The current editor contents.
38    #[props(into)]
39    pub value: String,
40    /// Tree-sitter grammar used for syntax highlighting.
41    ///
42    /// Pass a [`Language`] variant directly. Use [`Language::from_slug`] to
43    /// turn a runtime slug into a variant. Defaults to [`Language::Rust`].
44    #[props(default = Language::Rust)]
45    pub language: Language,
46    /// Syntax theme selection shared with [`dioxus-code`].
47    ///
48    /// [`dioxus-code`]: https://docs.rs/dioxus-code/latest/dioxus_code/
49    #[props(default, into)]
50    pub theme: CodeTheme,
51    /// Show a gutter with one-based line numbers.
52    #[props(default = true)]
53    pub line_numbers: bool,
54    /// Disable editing while preserving syntax highlighting and text selection.
55    #[props(default = false)]
56    pub read_only: bool,
57    /// Forward spellcheck to the textarea input layer.
58    #[props(default = false)]
59    pub spellcheck: bool,
60    /// Accessible label for the editor textbox.
61    #[props(into, default = "Code editor")]
62    pub aria_label: String,
63    /// Placeholder shown only while [`CodeEditorProps::value`] is empty.
64    #[props(into, default)]
65    pub placeholder: String,
66    /// Extra class names appended to the editor root.
67    #[props(into, default)]
68    pub class: String,
69    /// Called with the full editor text after each input event.
70    #[props(default = EventHandler::new(|_: String| {}))]
71    pub oninput: EventHandler<String>,
72}
73
74struct EditorBuffer {
75    buffer: Option<Buffer>,
76    language: Language,
77}
78
79/// Editable syntax-highlighted code surface.
80///
81/// The component is controlled by [`CodeEditorProps::value`]; update that value
82/// from [`CodeEditorProps::oninput`] to keep the highlight layer and editable
83/// layer in sync.
84///
85/// ```rust
86/// use dioxus::prelude::*;
87/// use dioxus_code::Theme;
88/// use dioxus_code_editor::{CodeEditor, Language};
89///
90/// fn _example() -> Element {
91///     let mut source = use_signal(String::new);
92///     rsx! {
93///         CodeEditor {
94///             value: source(),
95///             language: Language::Rust,
96///             theme: Theme::TOKYO_NIGHT,
97///             oninput: move |value| source.set(value),
98///         }
99///     }
100/// }
101/// ```
102#[component]
103pub fn CodeEditor(props: CodeEditorProps) -> Element {
104    let state = use_hook({
105        let value = props.value.clone();
106        let language = props.language;
107        move || {
108            Rc::new(RefCell::new(EditorBuffer {
109                buffer: Buffer::new(language, value).ok(),
110                language,
111            }))
112        }
113    });
114    let edit_tracker = use_hook(|| {
115        Rc::new(RefCell::new(edit_capture::InputEditTracker::new(
116            props.value.clone(),
117        )))
118    });
119
120    let edit = edit_tracker.borrow_mut().take_for_render(&props.value);
121    let snapshot = {
122        let mut slot = state.borrow_mut();
123        if slot.language != props.language {
124            slot.buffer = Buffer::new(props.language, props.value.clone()).ok();
125            slot.language = props.language;
126        }
127
128        match slot.buffer.as_mut() {
129            Some(buffer) => {
130                if buffer.source() != props.value {
131                    let result = match edit {
132                        Some(edit) => match buffer.edit(edit, props.value.clone()) {
133                            Ok(()) => Ok(()),
134                            Err(HighlightError::InvalidEdit { .. }) => {
135                                buffer.replace(props.value.clone())
136                            }
137                            Err(error) => Err(error),
138                        },
139                        None => buffer.replace(props.value.clone()),
140                    };
141                    let _ = result;
142                }
143                buffer.highlighted()
144            }
145            None => SourceCode::new(props.language, props.value.clone()).into(),
146        }
147    };
148    let lines = snapshot.lines();
149    let line_count = lines.len();
150    let class = editor_class(props.theme, props.line_numbers, &props.class);
151    let textarea_value = props.value.clone();
152    let readonly = props.read_only.then_some("true");
153    let input_attributes = edit_capture::use_input_edit_attributes(edit_tracker.clone(), {
154        let oninput = props.oninput;
155        move |value| oninput.call(value)
156    });
157
158    rsx! {
159        CodeThemeStyles { theme: props.theme }
160        document::Stylesheet { href: CODE_EDITOR_CSS }
161        div {
162            class,
163            if props.line_numbers {
164                div { class: "dxc-editor-gutter", aria_hidden: "true",
165                    for index in 0..line_count {
166                        div { class: "dxc-editor-gutter-line", "{index + 1}" }
167                    }
168                }
169            }
170            div { class: "dxc-editor-viewport",
171                div { class: "dxc-editor-highlight", aria_hidden: "true",
172                    for line in lines {
173                        div { class: "dxc-editor-line",
174                            for segment in line {
175                                if let Some(tag) = segment.tag() {
176                                    TokenSpan {
177                                        text: segment.text(),
178                                        tag,
179                                    }
180                                } else {
181                                    span { "{segment.text()}" }
182                                }
183                            }
184                        }
185                    }
186                }
187                textarea {
188                    class: "dxc-editor-input",
189                    readonly: props.read_only,
190                    spellcheck: props.spellcheck,
191                    role: "textbox",
192                    "aria-label": props.aria_label,
193                    "aria-multiline": "true",
194                    "aria-readonly": readonly,
195                    placeholder: props.placeholder,
196                    wrap: "off",
197                    ..input_attributes,
198                    "{textarea_value}"
199                }
200            }
201        }
202    }
203}
204
205fn editor_class(theme: impl Into<CodeTheme>, line_numbers: bool, extra_class: &str) -> String {
206    let mut class = format!("dxc-editor {}", theme.into().classes());
207    if !line_numbers {
208        class.push_str(" dxc-editor-no-gutter");
209    }
210    if !extra_class.is_empty() {
211        class.push(' ');
212        class.push_str(extra_class);
213    }
214    class
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn editor_class_includes_theme_and_extra_class() {
223        assert_eq!(
224            editor_class(Theme::TOKYO_NIGHT, true, "is-compact"),
225            "dxc-editor dxc-tokyo-night is-compact",
226        );
227    }
228
229    #[test]
230    fn editor_class_can_hide_gutter() {
231        assert_eq!(
232            editor_class(Theme::TOKYO_NIGHT, false, ""),
233            "dxc-editor dxc-tokyo-night dxc-editor-no-gutter",
234        );
235    }
236
237    #[test]
238    fn editor_class_can_use_system_theme() {
239        assert_eq!(
240            editor_class(
241                CodeTheme::system(Theme::GITHUB_LIGHT, Theme::TOKYO_NIGHT),
242                true,
243                "",
244            ),
245            "dxc-editor dxc-system dxc-system-light-github-light dxc-system-dark-tokyo-night",
246        );
247    }
248
249    #[test]
250    fn lines_preserve_trailing_empty_line() {
251        let source = HighlightedSource::from_static_parts("let x = 1;\n", Language::Rust, &[]);
252        let lines = source.lines();
253        assert_eq!(lines.len(), 2);
254        assert_eq!(lines[0], vec![HighlightSegment::new("let x = 1;", None)]);
255        assert!(lines[1].is_empty());
256    }
257}