dioxus_code_editor/
lib.rs1#![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
16pub const CODE_EDITOR_CSS: Asset = asset!("/assets/dioxus-code-editor.css");
23
24#[derive(Props, Clone, PartialEq)]
36pub struct CodeEditorProps {
37 #[props(into)]
39 pub value: String,
40 #[props(default = Language::Rust)]
45 pub language: Language,
46 #[props(default, into)]
50 pub theme: CodeTheme,
51 #[props(default = true)]
53 pub line_numbers: bool,
54 #[props(default = false)]
56 pub read_only: bool,
57 #[props(default = false)]
59 pub spellcheck: bool,
60 #[props(into, default = "Code editor")]
62 pub aria_label: String,
63 #[props(into, default)]
65 pub placeholder: String,
66 #[props(into, default)]
68 pub class: String,
69 #[props(default = EventHandler::new(|_: String| {}))]
71 pub oninput: EventHandler<String>,
72}
73
74struct EditorBuffer {
75 buffer: Option<Buffer>,
76 language: Language,
77}
78
79#[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}