From f0868c2f0744f49ade76c2b450ada01bda2c00c8 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 1 Oct 2021 21:29:33 +0200 Subject: [PATCH] Code editor demo: nice syntax highlighting with customizable theme --- Cargo.lock | 22 ++ egui/src/containers/frame.rs | 2 +- egui/src/widgets/mod.rs | 10 +- egui_demo_lib/Cargo.toml | 2 + egui_demo_lib/src/apps/demo/code_editor.rs | 237 +++++++++++++++------ 5 files changed, 202 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18bb9305..512cdefa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -865,6 +865,7 @@ dependencies = [ "criterion", "egui", "ehttp", + "enum-map", "epi", "image", "serde", @@ -929,6 +930,27 @@ dependencies = [ "serde", ] +[[package]] +name = "enum-map" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e893a7ba6116821058dec84a6fb14fb2a97cd8ce5fd0f85d5a4e760ecd7329d9" +dependencies = [ + "enum-map-derive", + "serde", +] + +[[package]] +name = "enum-map-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84278eae0af6e34ff6c1db44c11634a694aafac559ff3080e4db4e4ac35907aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_logger" version = "0.8.4" diff --git a/egui/src/containers/frame.rs b/egui/src/containers/frame.rs index 29c1d7ce..4214f991 100644 --- a/egui/src/containers/frame.rs +++ b/egui/src/containers/frame.rs @@ -23,7 +23,7 @@ impl Frame { /// For when you want to group a few widgets together within a frame. pub fn group(style: &Style) -> Self { Self { - margin: Vec2::new(8.0, 6.0), + margin: Vec2::splat(6.0), // symmetric looks best in corners when nesting corner_radius: style.visuals.widgets.noninteractive.corner_radius, stroke: style.visuals.widgets.noninteractive.bg_stroke, ..Default::default() diff --git a/egui/src/widgets/mod.rs b/egui/src/widgets/mod.rs index 0b630cd0..1bdeb7a5 100644 --- a/egui/src/widgets/mod.rs +++ b/egui/src/widgets/mod.rs @@ -81,12 +81,16 @@ where /// Show a button to reset a value to its default. /// The button is only enabled if the value does not already have its original value. pub fn reset_button(ui: &mut Ui, value: &mut T) { - let def = T::default(); + reset_button_with(ui, value, T::default()); +} +/// Show a button to reset a value to its default. +/// The button is only enabled if the value does not already have its original value. +pub fn reset_button_with(ui: &mut Ui, value: &mut T, reset_value: T) { if ui - .add(Button::new("Reset").enabled(*value != def)) + .add(Button::new("Reset").enabled(*value != reset_value)) .clicked() { - *value = def; + *value = reset_value; } } diff --git a/egui_demo_lib/Cargo.toml b/egui_demo_lib/Cargo.toml index 236f16be..754ed2d5 100644 --- a/egui_demo_lib/Cargo.toml +++ b/egui_demo_lib/Cargo.toml @@ -25,6 +25,8 @@ all-features = true [dependencies] egui = { version = "0.14.0", path = "../egui", default-features = false } epi = { version = "0.14.0", path = "../epi" } + +enum-map = { version = "1", features = ["serde"] } unicode_names2 = { version = "0.4.0", default-features = false } # feature "http": diff --git a/egui_demo_lib/src/apps/demo/code_editor.rs b/egui_demo_lib/src/apps/demo/code_editor.rs index 7dc825b3..8199d4b7 100644 --- a/egui_demo_lib/src/apps/demo/code_editor.rs +++ b/egui_demo_lib/src/apps/demo/code_editor.rs @@ -1,10 +1,128 @@ -use egui::text::LayoutJob; +#[derive(Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(enum_map::Enum)] +enum TokenType { + Comment, + Keyword, + Literal, + StringLiteral, + Punctuation, + Whitespace, +} + +#[derive(Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +struct CodeTheme { + dark_mode: bool, + formats: enum_map::EnumMap, +} + +impl Default for CodeTheme { + fn default() -> Self { + Self::dark(egui::TextStyle::Monospace) + } +} + +impl CodeTheme { + fn dark(text_style: egui::TextStyle) -> Self { + use egui::{Color32, TextFormat}; + Self { + dark_mode: true, + formats: enum_map::enum_map![ + TokenType::Comment => TextFormat::simple(text_style, Color32::from_gray(120)), + TokenType::Keyword => TextFormat::simple(text_style, Color32::from_rgb(255, 100, 100)), + TokenType::Literal => TextFormat::simple(text_style, Color32::from_rgb(178, 108, 210)), + TokenType::StringLiteral => TextFormat::simple(text_style, Color32::from_rgb(109, 147, 226)), + TokenType::Punctuation => TextFormat::simple(text_style, Color32::LIGHT_GRAY), + TokenType::Whitespace => TextFormat::simple(text_style, Color32::TRANSPARENT), + ], + } + } + + fn light(text_style: egui::TextStyle) -> Self { + use egui::{Color32, TextFormat}; + Self { + dark_mode: false, + formats: enum_map::enum_map![ + TokenType::Comment => TextFormat::simple(text_style, Color32::GRAY), + TokenType::Keyword => TextFormat::simple(text_style, Color32::from_rgb(235, 0, 0)), + TokenType::Literal => TextFormat::simple(text_style, Color32::from_rgb(153, 134, 255)), + TokenType::StringLiteral => TextFormat::simple(text_style, Color32::from_rgb(37, 203, 105)), + TokenType::Punctuation => TextFormat::simple(text_style, Color32::DARK_GRAY), + TokenType::Whitespace => TextFormat::simple(text_style, Color32::TRANSPARENT), + ], + } + } +} + +impl CodeTheme { + fn ui(&mut self, ui: &mut egui::Ui, reset_value: CodeTheme) { + ui.horizontal_top(|ui| { + let mut selected_tt: TokenType = *ui.memory().data.get_or(TokenType::Comment); + + ui.vertical(|ui| { + egui::widgets::global_dark_light_mode_buttons(ui); + + // ui.separator(); // TODO: fix forever-expand + ui.add_space(14.0); + + ui.scope(|ui| { + for (tt, tt_name) in [ + (TokenType::Comment, "// comment"), + (TokenType::Keyword, "keyword"), + (TokenType::Literal, "literal"), + (TokenType::StringLiteral, "\"string literal\""), + (TokenType::Punctuation, "punctuation ;"), + // (TokenType::Whitespace, "whitespace"), + ] { + let format = &mut self.formats[tt]; + ui.style_mut().override_text_style = Some(format.style); + ui.visuals_mut().override_text_color = Some(format.color); + ui.radio_value(&mut selected_tt, tt, tt_name); + } + }); + + ui.add_space(14.0); + + if ui + .add(egui::Button::new("Reset theme").enabled(*self != reset_value)) + .clicked() + { + *self = reset_value; + } + }); + + ui.add_space(16.0); + // ui.separator(); // TODO: fix forever-expand + + ui.memory().data.insert(selected_tt); + + egui::Frame::group(ui.style()) + .margin(egui::Vec2::splat(2.0)) + .show(ui, |ui| { + // ui.group(|ui| { + ui.style_mut().override_text_style = Some(egui::TextStyle::Small); + ui.spacing_mut().slider_width = 128.0; // Controls color picker size + egui::widgets::color_picker::color_picker_color32( + ui, + &mut self.formats[selected_tt].color, + egui::color_picker::Alpha::Opaque, + ); + }); + }); + } +} + +// ---------------------------------------------------------------------------- #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct CodeEditor { - code: String, + theme_dark: CodeTheme, + theme_light: CodeTheme, language: String, + code: String, #[cfg_attr(feature = "serde", serde(skip))] highlighter: MemoizedSyntaxHighlighter, } @@ -12,13 +130,15 @@ pub struct CodeEditor { impl Default for CodeEditor { fn default() -> Self { Self { + theme_dark: CodeTheme::dark(egui::TextStyle::Monospace), + theme_light: CodeTheme::light(egui::TextStyle::Monospace), + language: "rs".into(), code: "// A very simple example\n\ fn main() {\n\ \tprintln!(\"Hello world!\");\n\ }\n\ " .into(), - language: "rs".into(), highlighter: Default::default(), } } @@ -33,6 +153,7 @@ impl super::Demo for CodeEditor { use super::View; egui::Window::new(self.name()) .open(open) + .default_height(500.0) .show(ctx, |ui| self.ui(ui)); } } @@ -40,8 +161,10 @@ impl super::Demo for CodeEditor { impl super::View for CodeEditor { fn ui(&mut self, ui: &mut egui::Ui) { let Self { - code, + theme_dark, + theme_light, language, + code, highlighter, } = self; @@ -63,16 +186,36 @@ impl super::View for CodeEditor { ui.label("."); }); } else { - ui.horizontal_wrapped(|ui|{ + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; - ui.label("Compile the demo with the 'syntax_highlighting' feature to enable much nicer syntax highlighting using "); + ui.label("Compile the demo with the "); + ui.code("syntax_highlighting"); + ui.label(" feature to enable more accurate syntax highlighting using "); ui.hyperlink_to("syntect", "https://github.com/trishume/syntect"); ui.label("."); }); } + ui.collapsing("Theme", |ui| { + ui.group(|ui| { + if ui.visuals().dark_mode { + let reset_value = CodeTheme::dark(egui::TextStyle::Monospace); + theme_dark.ui(ui, reset_value); + } else { + let reset_value = CodeTheme::light(egui::TextStyle::Monospace); + theme_light.ui(ui, reset_value); + } + }); + }); + + let theme = if ui.visuals().dark_mode { + theme_dark + } else { + theme_light + }; + let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { - let mut layout_job = highlighter.highlight(ui.visuals().dark_mode, string, language); + let mut layout_job = highlighter.highlight(theme, string, language); layout_job.wrap_width = wrap_width; ui.fonts().layout_job(layout_job) }; @@ -82,6 +225,7 @@ impl super::View for CodeEditor { egui::TextEdit::multiline(code) .text_style(egui::TextStyle::Monospace) // for cursor height .code_editor() + .desired_rows(10) .lock_focus(true) .desired_width(f32::INFINITY) .layouter(&mut layouter), @@ -92,9 +236,11 @@ impl super::View for CodeEditor { // ---------------------------------------------------------------------------- +use egui::text::LayoutJob; + #[derive(Default)] struct MemoizedSyntaxHighlighter { - is_dark_mode: bool, + theme: CodeTheme, code: String, language: String, output: LayoutJob, @@ -102,24 +248,19 @@ struct MemoizedSyntaxHighlighter { } impl MemoizedSyntaxHighlighter { - fn highlight(&mut self, is_dark_mode: bool, code: &str, language: &str) -> LayoutJob { - if ( - self.is_dark_mode, - self.code.as_str(), - self.language.as_str(), - ) != (is_dark_mode, code, language) - { - self.is_dark_mode = is_dark_mode; + fn highlight(&mut self, theme: &CodeTheme, code: &str, language: &str) -> LayoutJob { + if (&self.theme, self.code.as_str(), self.language.as_str()) != (theme, code, language) { + self.theme = *theme; self.code = code.to_owned(); self.language = language.to_owned(); self.output = self .highligher - .highlight(is_dark_mode, code, language) + .highlight(theme, code, language) .unwrap_or_else(|| { LayoutJob::simple( code.into(), egui::TextStyle::Monospace, - if is_dark_mode { + if theme.dark_mode { egui::Color32::LIGHT_GRAY } else { egui::Color32::DARK_GRAY @@ -152,7 +293,7 @@ impl Default for Highligher { #[cfg(feature = "syntect")] impl Highligher { - fn highlight(&self, is_dark_mode: bool, text: &str, language: &str) -> Option { + fn highlight(&self, theme: &CodeTheme, text: &str, language: &str) -> Option { use syntect::easy::HighlightLines; use syntect::highlighting::FontStyle; use syntect::util::LinesWithEndings; @@ -162,7 +303,7 @@ impl Highligher { .find_syntax_by_name(language) .or_else(|| self.ps.find_syntax_by_extension(language))?; - let theme = if is_dark_mode { + let theme = if theme.dark_mode { "base16-mocha.dark" } else { "base16-ocean.light" @@ -224,54 +365,15 @@ struct Highligher {} #[cfg(not(feature = "syntect"))] impl Highligher { #[allow(clippy::unused_self, clippy::unnecessary_wraps)] - fn highlight(&self, is_dark_mode: bool, mut text: &str, _language: &str) -> Option { + fn highlight(&self, theme: &CodeTheme, mut text: &str, _language: &str) -> Option { // Extremely simple syntax highlighter for when we compile without syntect - use egui::text::TextFormat; - use egui::Color32; - let monospace = egui::TextStyle::Monospace; - - let comment_format = TextFormat::simple(monospace, Color32::GRAY); - let quoted_string_format = TextFormat::simple( - monospace, - if is_dark_mode { - Color32::KHAKI - } else { - Color32::BROWN - }, - ); - let keyword_format = TextFormat::simple( - monospace, - if is_dark_mode { - Color32::LIGHT_RED - } else { - Color32::DARK_RED - }, - ); - let literal_format = TextFormat::simple( - monospace, - if is_dark_mode { - Color32::LIGHT_GREEN - } else { - Color32::DARK_GREEN - }, - ); - let whitespace_format = TextFormat::simple(monospace, Color32::WHITE); - let punctuation_format = TextFormat::simple( - monospace, - if is_dark_mode { - Color32::LIGHT_GRAY - } else { - Color32::DARK_GRAY - }, - ); - let mut job = LayoutJob::default(); while !text.is_empty() { if text.starts_with("//") { let end = text.find('\n').unwrap_or_else(|| text.len()); - job.append(&text[..end], 0.0, comment_format); + job.append(&text[..end], 0.0, theme.formats[TokenType::Comment]); text = &text[end..]; } else if text.starts_with('"') { let end = text[1..] @@ -279,7 +381,7 @@ impl Highligher { .map(|i| i + 2) .or_else(|| text.find('\n')) .unwrap_or_else(|| text.len()); - job.append(&text[..end], 0.0, quoted_string_format); + job.append(&text[..end], 0.0, theme.formats[TokenType::StringLiteral]); text = &text[end..]; } else if text.starts_with(|c: char| c.is_ascii_alphanumeric()) { let end = text[1..] @@ -287,24 +389,25 @@ impl Highligher { .map(|i| i + 1) .unwrap_or_else(|| text.len()); let word = &text[..end]; - if is_keyword(word) { - job.append(word, 0.0, keyword_format); + let tt = if is_keyword(word) { + TokenType::Keyword } else { - job.append(word, 0.0, literal_format); + TokenType::Literal }; + job.append(word, 0.0, theme.formats[tt]); text = &text[end..]; } else if text.starts_with(|c: char| c.is_ascii_whitespace()) { let end = text[1..] .find(|c: char| !c.is_ascii_whitespace()) .map(|i| i + 1) .unwrap_or_else(|| text.len()); - job.append(&text[..end], 0.0, whitespace_format); + job.append(&text[..end], 0.0, theme.formats[TokenType::Whitespace]); text = &text[end..]; } else { let mut it = text.char_indices(); it.next(); let end = it.next().map_or(text.len(), |(idx, _chr)| idx); - job.append(&text[..end], 0.0, punctuation_format); + job.append(&text[..end], 0.0, theme.formats[TokenType::Punctuation]); text = &text[end..]; } }