Skip to main content

egui_code_editor/
lib.rs

1#![allow(rustdoc::invalid_rust_codeblocks)]
2//! Text Editor Widget for [egui](https://github.com/emilk/egui) with numbered lines and simple syntax highlighting based on keywords sets.
3//!
4//! ## Usage with egui
5//!
6//! ```rust
7//! use egui_code_editor::{CodeEditor, ColorTheme, Syntax};
8//!
9//! CodeEditor::default()
10//!   .id_source("code editor")
11//!   .with_rows(12)
12//!   .with_fontsize(14.0)
13//!   .with_theme(ColorTheme::GRUVBOX)
14//!   .with_numlines(true)
15//!   .with_clickable_links(true)
16//!   .show(ui, &self.syntax, &mut self.code);
17//! ```
18//!
19//! ## Usage as lexer without egui
20//!
21//! **Cargo.toml**
22//!
23//! ```toml
24//! [dependencies]
25//! egui_code_editor = { version = "0.2" , default-features = false }
26//! colorful = "0.2.2"
27//! ```
28//!
29//! **main.rs**
30//!
31//! ```rust
32//! use colorful::{Color, Colorful};
33//! use egui_code_editor::{Syntax, Token, TokenType};
34//!
35//! fn color(token: TokenType) -> Color {
36//!     match token {
37//!         TokenType::Comment(_) => Color::Grey37,
38//!         TokenType::Function => Color::Yellow3b,
39//!         TokenType::Keyword => Color::IndianRed1c,
40//!         TokenType::Literal => Color::NavajoWhite1,
41//!         TokenType::Numeric(_) => Color::MediumPurple,
42//!         TokenType::Punctuation(_) => Color::Orange3,
43//!         TokenType::Special => Color::Cyan,
44//!         TokenType::Str(_) => Color::Green,
45//!         TokenType::Type => Color::GreenYellow,
46//!         TokenType::Whitespace(_) => Color::White,
47//!         TokenType::Unknown => Color::Pink1,
48//!     }
49//! }
50//!
51//! fn main() {
52//!     let text = r#"// Code Editor
53//! CodeEditor::default()
54//!     .id_source("code editor")
55//!     .with_rows(12)
56//!     .with_fontsize(14.0)
57//!     .with_theme(self.theme)
58//!     .with_numlines(true)
59//!     .vscroll(true)
60//!     .show(ui, &self.syntax, &mut self.code);
61//!     "#;
62//!
63//!     let syntax = Syntax::rust();
64//!     for token in Token::default().tokens(&syntax, text) {
65//!         print!("{}", token.buffer().color(color(token.ty())));
66//!     }
67//! }
68//! ```
69#[cfg(feature = "egui")]
70mod completer;
71pub mod highlighting;
72#[cfg(feature = "egui")]
73mod hyperlinks;
74mod syntax;
75#[cfg(test)]
76mod tests;
77mod themes;
78
79#[cfg(feature = "egui")]
80use egui::Stroke;
81#[cfg(feature = "egui")]
82use egui::text::LayoutJob;
83#[cfg(feature = "egui")]
84use egui::widgets::text_edit::TextEditOutput;
85pub use highlighting::Token;
86#[cfg(feature = "egui")]
87use highlighting::highlight;
88#[cfg(feature = "egui")]
89use hyperlinks::handle_links;
90#[cfg(feature = "editor")]
91use std::hash::{Hash, Hasher};
92pub use syntax::{Patch, Syntax, TokenType};
93pub use themes::ColorTheme;
94pub use themes::DEFAULT_THEMES;
95
96#[cfg(feature = "egui")]
97pub use crate::completer::Completer;
98
99#[cfg(feature = "egui")]
100pub trait Editor: Hash {
101    fn append(&self, job: &mut LayoutJob, token: &Token);
102}
103
104#[cfg(feature = "editor")]
105#[derive(Clone, Debug, PartialEq)]
106/// CodeEditor struct which stores settings for highlighting.
107pub struct CodeEditor {
108    id: String,
109    theme: ColorTheme,
110    // syntax: &'a Syntax,
111    numlines: bool,
112    numlines_shift: isize,
113    numlines_only_natural: bool,
114    fontsize: f32,
115    clickable_links: bool,
116    rows: usize,
117    vscroll: bool,
118    stick_to_bottom: bool,
119    desired_width: f32,
120    wrap: bool,
121    hint_text: Option<String>,
122}
123
124#[cfg(feature = "editor")]
125impl Hash for CodeEditor {
126    fn hash<H: Hasher>(&self, state: &mut H) {
127        self.theme.hash(state);
128        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
129        (self.fontsize as u32).hash(state);
130    }
131}
132
133#[cfg(feature = "editor")]
134impl Default for CodeEditor {
135    fn default() -> CodeEditor {
136        CodeEditor {
137            id: String::from("Code Editor"),
138            theme: ColorTheme::GRUVBOX,
139            numlines: true,
140            numlines_shift: 0,
141            numlines_only_natural: false,
142            fontsize: 10.0,
143            clickable_links: true,
144            rows: 10,
145            vscroll: true,
146            stick_to_bottom: false,
147            desired_width: f32::INFINITY,
148            wrap: false,
149            hint_text: None,
150        }
151    }
152}
153
154#[cfg(feature = "editor")]
155impl CodeEditor {
156    pub fn id_source(self, id_source: impl Into<String>) -> Self {
157        CodeEditor {
158            id: id_source.into(),
159            ..self
160        }
161    }
162
163    /// Minimum number of rows to show.
164    ///
165    /// **Default: 10**
166    pub fn with_rows(self, rows: usize) -> Self {
167        CodeEditor { rows, ..self }
168    }
169
170    /// Use custom Color Theme
171    ///
172    /// **Default: Gruvbox**
173    pub fn with_theme(self, theme: ColorTheme) -> Self {
174        CodeEditor { theme, ..self }
175    }
176
177    /// Use custom font size
178    ///
179    /// **Default: 10.0**
180    pub fn with_fontsize(self, fontsize: f32) -> Self {
181        CodeEditor { fontsize, ..self }
182    }
183
184    #[cfg(feature = "egui")]
185    /// Use UI font size
186    pub fn with_ui_fontsize(self, ui: &mut egui::Ui) -> Self {
187        CodeEditor {
188            fontsize: egui::TextStyle::Monospace.resolve(ui.style()).size,
189            ..self
190        }
191    }
192
193    #[cfg(feature = "egui")]
194    /// Make hyperlinks clickable
195    pub fn with_clickable_links(self, clickable_links: bool) -> Self {
196        CodeEditor {
197            clickable_links,
198            ..self
199        }
200    }
201    /// Show or hide lines numbering. If true ignores text wrapping mode.
202    ///
203    /// **Default: true**
204    pub fn with_numlines(self, numlines: bool) -> Self {
205        CodeEditor { numlines, ..self }
206    }
207
208    /// Shift lines numbering by this value
209    ///
210    /// **Default: 0**
211    pub fn with_numlines_shift(self, numlines_shift: isize) -> Self {
212        CodeEditor {
213            numlines_shift,
214            ..self
215        }
216    }
217
218    /// Show lines numbering only above zero, useful for enabling numbering since nth row
219    ///
220    /// **Default: false**
221    pub fn with_numlines_only_natural(self, numlines_only_natural: bool) -> Self {
222        CodeEditor {
223            numlines_only_natural,
224            ..self
225        }
226    }
227
228    /// Allows editing text to wrap. Ignored if numlines enabled.
229    ///
230    /// **Default: false**
231    pub fn with_wrap(self, wrap: bool) -> Self {
232        CodeEditor { wrap, ..self }
233    }
234    // Use custom syntax for highlighting
235    //
236    // **Default: Rust**
237    // pub fn with_syntax(self, syntax: Syntax) -> Self {
238    // CodeEditor { syntax, ..self }
239    // }
240
241    /// Turn on/off scrolling on the vertical axis.
242    ///
243    /// **Default: true**
244    pub fn vscroll(self, vscroll: bool) -> Self {
245        CodeEditor { vscroll, ..self }
246    }
247    /// Should the containing area shrink if the content is small?
248    ///
249    /// **Default: false**
250    pub fn auto_shrink(self, shrink: bool) -> Self {
251        CodeEditor {
252            desired_width: if shrink { 0.0 } else { self.desired_width },
253            ..self
254        }
255    }
256
257    /// Sets the desired width of the code editor
258    ///
259    /// **Default: `f32::INFINITY`**
260    pub fn desired_width(self, width: f32) -> Self {
261        CodeEditor {
262            desired_width: width,
263            ..self
264        }
265    }
266
267    /// Stick to bottom
268    /// The scroll handle will stick to the bottom position even while the content size
269    /// changes dynamically. This can be useful to simulate terminal UIs or log/info scrollers.
270    /// The scroll handle remains stuck until user manually changes position. Once "unstuck"
271    /// it will remain focused on whatever content viewport the user left it on. If the scroll
272    /// handle is dragged to the bottom it will again become stuck and remain there until manually
273    /// pulled from the end position.
274    ///
275    /// **Default: false**
276    pub fn stick_to_bottom(self, stick_to_bottom: bool) -> Self {
277        CodeEditor {
278            stick_to_bottom,
279            ..self
280        }
281    }
282
283    pub fn hint_text<S: Into<String>>(self, hint_text: S) -> Self {
284        let hint_text = hint_text.into();
285        let rows = self.rows.max(hint_text.lines().count());
286        CodeEditor {
287            hint_text: Some(hint_text),
288            rows,
289            ..self
290        }
291    }
292
293    #[cfg(feature = "egui")]
294    pub fn format_token(&self, ty: TokenType) -> egui::text::TextFormat {
295        format_token(&self.theme, self.fontsize, ty)
296    }
297
298    #[cfg(feature = "egui")]
299    fn numlines_show(&self, ui: &mut egui::Ui, text: &str) {
300        use egui::TextBuffer;
301
302        let total = if text.ends_with('\n') || text.is_empty() {
303            text.lines().count() + 1
304        } else {
305            text.lines().count()
306        }
307        .max(self.rows) as isize;
308        let max_indent = total
309            .to_string()
310            .len()
311            .max(!self.numlines_only_natural as usize * self.numlines_shift.to_string().len());
312        let mut counter = (1..=total)
313            .map(|i| {
314                let num = i + self.numlines_shift;
315                if num <= 0 && self.numlines_only_natural {
316                    String::new()
317                } else {
318                    let label = num.to_string();
319                    format!(
320                        "{}{label}",
321                        " ".repeat(max_indent.saturating_sub(label.len()))
322                    )
323                }
324            })
325            .collect::<Vec<String>>()
326            .join("\n");
327
328        #[allow(clippy::cast_precision_loss)]
329        let width = max_indent as f32
330            * self.fontsize
331            * 0.5
332            * !(total + self.numlines_shift <= 0 && self.numlines_only_natural) as u8 as f32;
333
334        let mut layouter = |ui: &egui::Ui, text_buffer: &dyn TextBuffer, _wrap_width: f32| {
335            let layout_job = egui::text::LayoutJob::single_section(
336                text_buffer.as_str().to_string(),
337                egui::TextFormat::simple(
338                    egui::FontId::monospace(self.fontsize),
339                    self.theme.type_color(TokenType::Comment(true)),
340                ),
341            );
342            ui.fonts_mut(|f| f.layout_job(layout_job))
343        };
344
345        ui.add(
346            egui::TextEdit::multiline(&mut counter)
347                .id_source(format!("{}_numlines", self.id))
348                .font(egui::TextStyle::Monospace)
349                .interactive(false)
350                .frame(egui::Frame::NONE)
351                .desired_rows(self.rows)
352                .desired_width(width)
353                .layouter(&mut layouter),
354        );
355    }
356
357    #[cfg(feature = "egui")]
358    /// Show Code Editor with auto-completion feature
359    pub fn show_with_completer(
360        &mut self,
361        ui: &mut egui::Ui,
362        text: &mut dyn egui::TextBuffer,
363        syntax: &Syntax,
364        completer: &mut Completer,
365    ) -> TextEditOutput {
366        completer.handle_input(ui.ctx());
367        let mut editor_output = self.show(ui, text, syntax);
368        completer.show(syntax, &self.theme, self.fontsize, &mut editor_output);
369        editor_output
370    }
371
372    #[cfg(feature = "egui")]
373    /// Show Code Editor
374    pub fn show(
375        &mut self,
376        ui: &mut egui::Ui,
377        text: &mut dyn egui::TextBuffer,
378        syntax: &Syntax,
379    ) -> TextEditOutput {
380        use egui::TextBuffer;
381        let mut text_edit_output: Option<TextEditOutput> = None;
382        let mut code_editor = |ui: &mut egui::Ui| {
383            let frame = egui::Frame::new().fill(self.theme.bg());
384            frame.show(ui, |ui| {
385                ui.horizontal_top(|h| {
386                    self.theme.modify_style(h, self.fontsize);
387                    if self.numlines {
388                        self.numlines_show(h, text.as_str());
389                    }
390                    egui::ScrollArea::horizontal()
391                        .id_salt(format!("{}_inner_scroll", self.id))
392                        .show(h, |ui| {
393                            use crate::highlighting::Links;
394
395                            let mut links_ranges = Links::default();
396                            let mut layouter =
397                                |ui: &egui::Ui, text_buffer: &dyn TextBuffer, wrap_width: f32| {
398                                    let text_str = text_buffer.as_str();
399                                    let (mut layout_job, links) =
400                                        highlight(ui.ctx(), self, text_str, syntax);
401                                    links_ranges = links;
402
403                                    if !self.numlines && self.wrap {
404                                        layout_job.wrap =
405                                            egui::text::TextWrapping::wrap_at_width(wrap_width);
406                                    }
407                                    ui.fonts_mut(|f| f.layout_job(layout_job))
408                                };
409
410                            let mut text_edit = egui::TextEdit::multiline(text)
411                                .id_source(&self.id)
412                                .lock_focus(true)
413                                .desired_rows(self.rows)
414                                .desired_width(self.desired_width)
415                                .layouter(&mut layouter);
416                            if let Some(hint) = self.hint_text.as_ref() {
417                                text_edit = text_edit.hint_text(hint);
418                            }
419                            let output = text_edit.show(ui);
420
421                            if self.clickable_links {
422                                handle_links(&output, &links_ranges);
423                            }
424                            text_edit_output = Some(output);
425                        });
426                });
427            });
428        };
429        if self.vscroll {
430            egui::ScrollArea::vertical()
431                .id_salt(format!("{}_outer_scroll", self.id))
432                .stick_to_bottom(self.stick_to_bottom)
433                .show(ui, code_editor);
434        } else {
435            code_editor(ui);
436        }
437
438        text_edit_output.expect("TextEditOutput should exist at this point")
439    }
440}
441
442#[cfg(feature = "editor")]
443#[cfg(feature = "egui")]
444impl Editor for CodeEditor {
445    fn append(&self, job: &mut LayoutJob, token: &Token) {
446        if !token.buffer().is_empty() {
447            job.append(token.buffer(), 0.0, self.format_token(token.ty()));
448        }
449    }
450}
451
452#[cfg(feature = "egui")]
453pub fn format_token(theme: &ColorTheme, fontsize: f32, ty: TokenType) -> egui::text::TextFormat {
454    let font_id = egui::FontId::monospace(fontsize);
455    let color = theme.type_color(ty);
456
457    let mut tf = egui::text::TextFormat::simple(font_id, color);
458    if ty == TokenType::Hyperlink {
459        tf.underline = Stroke::new(fontsize * 0.1, color);
460    }
461    tf
462}