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_syntax(Syntax::rust())
15//!   .with_numlines(true)
16//!   .show(ui, &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_syntax(self.syntax.to_owned())
59//!     .with_numlines(true)
60//!     .vscroll(true)
61//!     .show(ui, &mut self.code);
62//!     "#;
63//!
64//!     let syntax = Syntax::rust();
65//!     for token in Token::default().tokens(&syntax, text) {
66//!         print!("{}", token.buffer().color(color(token.ty())));
67//!     }
68//! }
69//! ```
70#[cfg(feature = "egui")]
71mod completer;
72pub mod highlighting;
73mod syntax;
74#[cfg(test)]
75mod tests;
76mod themes;
77
78#[cfg(feature = "egui")]
79use egui::text::LayoutJob;
80#[cfg(feature = "egui")]
81use egui::widgets::text_edit::TextEditOutput;
82pub use highlighting::Token;
83#[cfg(feature = "egui")]
84use highlighting::highlight;
85#[cfg(feature = "editor")]
86use std::hash::{Hash, Hasher};
87pub use syntax::{Syntax, TokenType};
88pub use themes::ColorTheme;
89pub use themes::DEFAULT_THEMES;
90
91#[cfg(feature = "egui")]
92pub use crate::completer::Completer;
93
94#[cfg(feature = "egui")]
95pub trait Editor: Hash {
96    fn append(&self, job: &mut LayoutJob, token: &Token);
97    fn syntax(&self) -> &Syntax;
98}
99
100#[cfg(feature = "editor")]
101#[derive(Clone, Debug, PartialEq)]
102/// CodeEditor struct which stores settings for highlighting.
103pub struct CodeEditor {
104    id: String,
105    theme: ColorTheme,
106    syntax: Syntax,
107    numlines: bool,
108    numlines_shift: isize,
109    numlines_only_natural: bool,
110    fontsize: f32,
111    rows: usize,
112    vscroll: bool,
113    stick_to_bottom: bool,
114    desired_width: f32,
115}
116
117#[cfg(feature = "editor")]
118impl Hash for CodeEditor {
119    fn hash<H: Hasher>(&self, state: &mut H) {
120        self.theme.hash(state);
121        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
122        (self.fontsize as u32).hash(state);
123        self.syntax.hash(state);
124    }
125}
126
127#[cfg(feature = "editor")]
128impl Default for CodeEditor {
129    fn default() -> CodeEditor {
130        let syntax = Syntax::rust();
131        CodeEditor {
132            id: String::from("Code Editor"),
133            theme: ColorTheme::GRUVBOX,
134            syntax,
135            numlines: true,
136            numlines_shift: 0,
137            numlines_only_natural: false,
138            fontsize: 10.0,
139            rows: 10,
140            vscroll: true,
141            stick_to_bottom: false,
142            desired_width: f32::INFINITY,
143        }
144    }
145}
146
147#[cfg(feature = "editor")]
148impl CodeEditor {
149    pub fn id_source(self, id_source: impl Into<String>) -> Self {
150        CodeEditor {
151            id: id_source.into(),
152            ..self
153        }
154    }
155
156    /// Minimum number of rows to show.
157    ///
158    /// **Default: 10**
159    pub fn with_rows(self, rows: usize) -> Self {
160        CodeEditor { rows, ..self }
161    }
162
163    /// Use custom Color Theme
164    ///
165    /// **Default: Gruvbox**
166    pub fn with_theme(self, theme: ColorTheme) -> Self {
167        CodeEditor { theme, ..self }
168    }
169
170    /// Use custom font size
171    ///
172    /// **Default: 10.0**
173    pub fn with_fontsize(self, fontsize: f32) -> Self {
174        CodeEditor { fontsize, ..self }
175    }
176
177    #[cfg(feature = "egui")]
178    /// Use UI font size
179    pub fn with_ui_fontsize(self, ui: &mut egui::Ui) -> Self {
180        CodeEditor {
181            fontsize: egui::TextStyle::Monospace.resolve(ui.style()).size,
182            ..self
183        }
184    }
185
186    /// Show or hide lines numbering
187    ///
188    /// **Default: true**
189    pub fn with_numlines(self, numlines: bool) -> Self {
190        CodeEditor { numlines, ..self }
191    }
192
193    /// Shift lines numbering by this value
194    ///
195    /// **Default: 0**
196    pub fn with_numlines_shift(self, numlines_shift: isize) -> Self {
197        CodeEditor {
198            numlines_shift,
199            ..self
200        }
201    }
202
203    /// Show lines numbering only above zero, useful for enabling numbering since nth row
204    ///
205    /// **Default: false**
206    pub fn with_numlines_only_natural(self, numlines_only_natural: bool) -> Self {
207        CodeEditor {
208            numlines_only_natural,
209            ..self
210        }
211    }
212
213    /// Use custom syntax for highlighting
214    ///
215    /// **Default: Rust**
216    pub fn with_syntax(self, syntax: Syntax) -> Self {
217        CodeEditor { syntax, ..self }
218    }
219
220    /// Turn on/off scrolling on the vertical axis.
221    ///
222    /// **Default: true**
223    pub fn vscroll(self, vscroll: bool) -> Self {
224        CodeEditor { vscroll, ..self }
225    }
226    /// Should the containing area shrink if the content is small?
227    ///
228    /// **Default: false**
229    pub fn auto_shrink(self, shrink: bool) -> Self {
230        CodeEditor {
231            desired_width: if shrink { 0.0 } else { self.desired_width },
232            ..self
233        }
234    }
235
236    /// Sets the desired width of the code editor
237    ///
238    /// **Default: `f32::INFINITY`**
239    pub fn desired_width(self, width: f32) -> Self {
240        CodeEditor {
241            desired_width: width,
242            ..self
243        }
244    }
245
246    /// Stick to bottom
247    /// The scroll handle will stick to the bottom position even while the content size
248    /// changes dynamically. This can be useful to simulate terminal UIs or log/info scrollers.
249    /// The scroll handle remains stuck until user manually changes position. Once "unstuck"
250    /// it will remain focused on whatever content viewport the user left it on. If the scroll
251    /// handle is dragged to the bottom it will again become stuck and remain there until manually
252    /// pulled from the end position.
253    ///
254    /// **Default: false**
255    pub fn stick_to_bottom(self, stick_to_bottom: bool) -> Self {
256        CodeEditor {
257            stick_to_bottom,
258            ..self
259        }
260    }
261
262    #[cfg(feature = "egui")]
263    pub fn format_token(&self, ty: TokenType) -> egui::text::TextFormat {
264        format_token(&self.theme, self.fontsize, ty)
265    }
266
267    #[cfg(feature = "egui")]
268    fn numlines_show(&self, ui: &mut egui::Ui, text: &str) {
269        use egui::TextBuffer;
270
271        let total = if text.ends_with('\n') || text.is_empty() {
272            text.lines().count() + 1
273        } else {
274            text.lines().count()
275        }
276        .max(self.rows) as isize;
277        let max_indent = total
278            .to_string()
279            .len()
280            .max(!self.numlines_only_natural as usize * self.numlines_shift.to_string().len());
281        let mut counter = (1..=total)
282            .map(|i| {
283                let num = i + self.numlines_shift;
284                if num <= 0 && self.numlines_only_natural {
285                    String::new()
286                } else {
287                    let label = num.to_string();
288                    format!(
289                        "{}{label}",
290                        " ".repeat(max_indent.saturating_sub(label.len()))
291                    )
292                }
293            })
294            .collect::<Vec<String>>()
295            .join("\n");
296
297        #[allow(clippy::cast_precision_loss)]
298        let width = max_indent as f32
299            * self.fontsize
300            * 0.5
301            * !(total + self.numlines_shift <= 0 && self.numlines_only_natural) as u8 as f32;
302
303        let mut layouter = |ui: &egui::Ui, text_buffer: &dyn TextBuffer, _wrap_width: f32| {
304            let layout_job = egui::text::LayoutJob::single_section(
305                text_buffer.as_str().to_string(),
306                egui::TextFormat::simple(
307                    egui::FontId::monospace(self.fontsize),
308                    self.theme.type_color(TokenType::Comment(true)),
309                ),
310            );
311            ui.fonts_mut(|f| f.layout_job(layout_job))
312        };
313
314        ui.add(
315            egui::TextEdit::multiline(&mut counter)
316                .id_source(format!("{}_numlines", self.id))
317                .font(egui::TextStyle::Monospace)
318                .interactive(false)
319                .frame(false)
320                .desired_rows(self.rows)
321                .desired_width(width)
322                .layouter(&mut layouter),
323        );
324    }
325
326    #[cfg(feature = "egui")]
327    /// Show Code Editor with auto-completion feature
328    pub fn show_with_completer(
329        &mut self,
330        ui: &mut egui::Ui,
331        text: &mut dyn egui::TextBuffer,
332        completer: &mut Completer,
333    ) -> TextEditOutput {
334        completer.handle_input(ui.ctx());
335        let mut editor_output = self.show(ui, text);
336        completer.show(&self.syntax, &self.theme, self.fontsize, &mut editor_output);
337        editor_output
338    }
339
340    #[cfg(feature = "egui")]
341    /// Show Code Editor
342    pub fn show(&mut self, ui: &mut egui::Ui, text: &mut dyn egui::TextBuffer) -> TextEditOutput {
343        use egui::TextBuffer;
344
345        let mut text_edit_output: Option<TextEditOutput> = None;
346        let mut code_editor = |ui: &mut egui::Ui| {
347            ui.horizontal_top(|h| {
348                self.theme.modify_style(h, self.fontsize);
349                if self.numlines {
350                    self.numlines_show(h, text.as_str());
351                }
352                egui::ScrollArea::horizontal()
353                    .id_salt(format!("{}_inner_scroll", self.id))
354                    .show(h, |ui| {
355                        let mut layouter =
356                            |ui: &egui::Ui, text_buffer: &dyn TextBuffer, _wrap_width: f32| {
357                                let layout_job = highlight(ui.ctx(), self, text_buffer.as_str());
358                                ui.fonts_mut(|f| f.layout_job(layout_job))
359                            };
360                        let output = egui::TextEdit::multiline(text)
361                            .id_source(&self.id)
362                            .lock_focus(true)
363                            .desired_rows(self.rows)
364                            .frame(true)
365                            .desired_width(self.desired_width)
366                            .layouter(&mut layouter)
367                            .show(ui);
368                        text_edit_output = Some(output);
369                    });
370            });
371        };
372        if self.vscroll {
373            egui::ScrollArea::vertical()
374                .id_salt(format!("{}_outer_scroll", self.id))
375                .stick_to_bottom(self.stick_to_bottom)
376                .show(ui, code_editor);
377        } else {
378            code_editor(ui);
379        }
380
381        text_edit_output.expect("TextEditOutput should exist at this point")
382    }
383}
384
385#[cfg(feature = "editor")]
386#[cfg(feature = "egui")]
387impl Editor for CodeEditor {
388    fn append(&self, job: &mut LayoutJob, token: &Token) {
389        if !token.buffer().is_empty() {
390            job.append(token.buffer(), 0.0, self.format_token(token.ty()));
391        }
392    }
393
394    fn syntax(&self) -> &Syntax {
395        &self.syntax
396    }
397}
398
399#[cfg(feature = "egui")]
400pub fn format_token(theme: &ColorTheme, fontsize: f32, ty: TokenType) -> egui::text::TextFormat {
401    let font_id = egui::FontId::monospace(fontsize);
402    let color = theme.type_color(ty);
403    egui::text::TextFormat::simple(font_id, color)
404}