diff --git a/egui_demo_lib/src/apps/demo/code_editor.rs b/egui_demo_lib/src/apps/demo/code_editor.rs index 8199d4b7..ebc8e57b 100644 --- a/egui_demo_lib/src/apps/demo/code_editor.rs +++ b/egui_demo_lib/src/apps/demo/code_editor.rs @@ -1,137 +1,15 @@ -#[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 { - theme_dark: CodeTheme, - theme_light: CodeTheme, language: String, code: String, - #[cfg_attr(feature = "serde", serde(skip))] - highlighter: MemoizedSyntaxHighlighter, } 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\ @@ -139,7 +17,6 @@ fn main() {\n\ }\n\ " .into(), - highlighter: Default::default(), } } } @@ -150,7 +27,7 @@ impl super::Demo for CodeEditor { } fn show(&mut self, ctx: &egui::CtxRef, open: &mut bool) { - use super::View; + use super::View as _; egui::Window::new(self.name()) .open(open) .default_height(500.0) @@ -160,13 +37,7 @@ impl super::Demo for CodeEditor { impl super::View for CodeEditor { fn ui(&mut self, ui: &mut egui::Ui) { - let Self { - theme_dark, - theme_light, - language, - code, - highlighter, - } = self; + let Self { language, code } = self; ui.horizontal(|ui| { ui.set_height(0.0); @@ -196,26 +67,17 @@ impl super::View for CodeEditor { }); } + let mut theme = crate::syntax_highlighting::CodeTheme::from_memory(ui.ctx()); 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); - } + theme.ui(ui); + theme.store_in_memory(ui.ctx()); }); }); - 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(theme, string, language); + let mut layout_job = + crate::syntax_highlighting::highlight(ui.ctx(), &theme, string, language); layout_job.wrap_width = wrap_width; ui.fonts().layout_job(layout_job) }; @@ -233,229 +95,3 @@ impl super::View for CodeEditor { }); } } - -// ---------------------------------------------------------------------------- - -use egui::text::LayoutJob; - -#[derive(Default)] -struct MemoizedSyntaxHighlighter { - theme: CodeTheme, - code: String, - language: String, - output: LayoutJob, - highligher: Highligher, -} - -impl MemoizedSyntaxHighlighter { - 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(theme, code, language) - .unwrap_or_else(|| { - LayoutJob::simple( - code.into(), - egui::TextStyle::Monospace, - if theme.dark_mode { - egui::Color32::LIGHT_GRAY - } else { - egui::Color32::DARK_GRAY - }, - f32::INFINITY, - ) - }); - } - self.output.clone() - } -} - -// ---------------------------------------------------------------------------- - -#[cfg(feature = "syntect")] -struct Highligher { - ps: syntect::parsing::SyntaxSet, - ts: syntect::highlighting::ThemeSet, -} - -#[cfg(feature = "syntect")] -impl Default for Highligher { - fn default() -> Self { - Self { - ps: syntect::parsing::SyntaxSet::load_defaults_newlines(), - ts: syntect::highlighting::ThemeSet::load_defaults(), - } - } -} - -#[cfg(feature = "syntect")] -impl Highligher { - fn highlight(&self, theme: &CodeTheme, text: &str, language: &str) -> Option { - use syntect::easy::HighlightLines; - use syntect::highlighting::FontStyle; - use syntect::util::LinesWithEndings; - - let syntax = self - .ps - .find_syntax_by_name(language) - .or_else(|| self.ps.find_syntax_by_extension(language))?; - - let theme = if theme.dark_mode { - "base16-mocha.dark" - } else { - "base16-ocean.light" - }; - let mut h = HighlightLines::new(syntax, &self.ts.themes[theme]); - - use egui::text::{LayoutSection, TextFormat}; - - let mut job = LayoutJob { - text: text.into(), - ..Default::default() - }; - - for line in LinesWithEndings::from(text) { - for (style, range) in h.highlight(line, &self.ps) { - let fg = style.foreground; - let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); - let italics = style.font_style.contains(FontStyle::ITALIC); - let underline = style.font_style.contains(FontStyle::ITALIC); - let underline = if underline { - egui::Stroke::new(1.0, text_color) - } else { - egui::Stroke::none() - }; - job.sections.push(LayoutSection { - leading_space: 0.0, - byte_range: as_byte_range(text, range), - format: TextFormat { - style: egui::TextStyle::Monospace, - color: text_color, - italics, - underline, - ..Default::default() - }, - }); - } - } - - Some(job) - } -} - -#[cfg(feature = "syntect")] -fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { - let whole_start = whole.as_ptr() as usize; - let range_start = range.as_ptr() as usize; - assert!(whole_start <= range_start); - assert!(range_start + range.len() <= whole_start + whole.len()); - let offset = range_start - whole_start; - offset..(offset + range.len()) -} - -// ---------------------------------------------------------------------------- - -#[cfg(not(feature = "syntect"))] -#[derive(Default)] -struct Highligher {} - -#[cfg(not(feature = "syntect"))] -impl Highligher { - #[allow(clippy::unused_self, clippy::unnecessary_wraps)] - fn highlight(&self, theme: &CodeTheme, mut text: &str, _language: &str) -> Option { - // Extremely simple syntax highlighter for when we compile without syntect - - 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, theme.formats[TokenType::Comment]); - text = &text[end..]; - } else if text.starts_with('"') { - let end = text[1..] - .find('"') - .map(|i| i + 2) - .or_else(|| text.find('\n')) - .unwrap_or_else(|| text.len()); - 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..] - .find(|c: char| !c.is_ascii_alphanumeric()) - .map(|i| i + 1) - .unwrap_or_else(|| text.len()); - let word = &text[..end]; - let tt = if is_keyword(word) { - TokenType::Keyword - } else { - 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, 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, theme.formats[TokenType::Punctuation]); - text = &text[end..]; - } - } - - Some(job) - } -} - -#[cfg(not(feature = "syntect"))] -fn is_keyword(word: &str) -> bool { - matches!( - word, - "as" | "async" - | "await" - | "break" - | "const" - | "continue" - | "crate" - | "dyn" - | "else" - | "enum" - | "extern" - | "false" - | "fn" - | "for" - | "if" - | "impl" - | "in" - | "let" - | "loop" - | "match" - | "mod" - | "move" - | "mut" - | "pub" - | "ref" - | "return" - | "self" - | "Self" - | "static" - | "struct" - | "super" - | "trait" - | "true" - | "type" - | "unsafe" - | "use" - | "where" - | "while" - ) -} diff --git a/egui_demo_lib/src/apps/http_app.rs b/egui_demo_lib/src/apps/http_app.rs index 8a4fb1c0..b4f8a0e4 100644 --- a/egui_demo_lib/src/apps/http_app.rs +++ b/egui_demo_lib/src/apps/http_app.rs @@ -14,7 +14,7 @@ struct Resource { } impl Resource { - fn from_response(response: ehttp::Response) -> Self { + fn from_response(ctx: &egui::Context, response: ehttp::Response) -> Self { let content_type = response.content_type().unwrap_or_default(); let image = if content_type.starts_with("image/") { Image::decode(&response.bytes) @@ -26,7 +26,7 @@ impl Resource { let colored_text = text .as_ref() - .and_then(|text| syntax_highlighting(&response, text)); + .and_then(|text| syntax_highlighting(ctx, &response, text)); Self { response, @@ -87,7 +87,7 @@ impl epi::App for HttpApp { // Are we there yet? if let Ok(result) = receiver.try_recv() { self.in_progress = None; - self.result = Some(result.map(Resource::from_response)); + self.result = Some(result.map(|response| Resource::from_response(ctx, response))); } } @@ -291,74 +291,24 @@ fn ui_resource( // Syntax highlighting: #[cfg(feature = "syntect")] -fn syntax_highlighting(response: &ehttp::Response, text: &str) -> Option { +fn syntax_highlighting( + ctx: &egui::Context, + response: &ehttp::Response, + text: &str, +) -> Option { let extension_and_rest: Vec<&str> = response.url.rsplitn(2, '.').collect(); let extension = extension_and_rest.get(0)?; - ColoredText::text_with_extension(text, extension) + let theme = crate::syntax_highlighting::CodeTheme::from_style(&ctx.style()); + Some(ColoredText(crate::syntax_highlighting::highlight( + ctx, &theme, text, extension, + ))) } /// Lines of text fragments -#[cfg(feature = "syntect")] struct ColoredText(egui::text::LayoutJob); -#[cfg(feature = "syntect")] impl ColoredText { - /// e.g. `text_with_extension("fn foo() {}", "rs")` - pub fn text_with_extension(text: &str, extension: &str) -> Option { - use syntect::easy::HighlightLines; - use syntect::highlighting::FontStyle; - use syntect::highlighting::ThemeSet; - use syntect::parsing::SyntaxSet; - use syntect::util::LinesWithEndings; - - let ps = SyntaxSet::load_defaults_newlines(); // should be cached and reused - let ts = ThemeSet::load_defaults(); // should be cached and reused - - let syntax = ps.find_syntax_by_extension(extension)?; - - let dark_mode = true; - let theme = if dark_mode { - "base16-mocha.dark" - } else { - "base16-ocean.light" - }; - let mut h = HighlightLines::new(syntax, &ts.themes[theme]); - - use egui::text::{LayoutJob, LayoutSection, TextFormat}; - - let mut job = LayoutJob { - text: text.into(), - ..Default::default() - }; - - for line in LinesWithEndings::from(text) { - for (style, range) in h.highlight(line, &ps) { - let fg = style.foreground; - let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); - let italics = style.font_style.contains(FontStyle::ITALIC); - let underline = style.font_style.contains(FontStyle::ITALIC); - let underline = if underline { - egui::Stroke::new(1.0, text_color) - } else { - egui::Stroke::none() - }; - job.sections.push(LayoutSection { - leading_space: 0.0, - byte_range: as_byte_range(text, range), - format: TextFormat { - style: egui::TextStyle::Monospace, - color: text_color, - italics, - underline, - ..Default::default() - }, - }); - } - } - - Some(ColoredText(job)) - } - + #[cfg(feature = "syntect")] pub fn ui(&self, ui: &mut egui::Ui) { let mut job = self.0.clone(); job.wrap_width = ui.available_width(); @@ -366,29 +316,16 @@ impl ColoredText { let (response, painter) = ui.allocate_painter(galley.size(), egui::Sense::hover()); painter.add(egui::Shape::galley(response.rect.min, galley)); } -} -#[cfg(feature = "syntect")] -fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { - let whole_start = whole.as_ptr() as usize; - let range_start = range.as_ptr() as usize; - assert!(whole_start <= range_start); - assert!(range_start + range.len() <= whole_start + whole.len()); - let offset = range_start - whole_start; - offset..(offset + range.len()) -} - -#[cfg(not(feature = "syntect"))] -fn syntax_highlighting(_: &ehttp::Response, _: &str) -> Option { - None -} -#[cfg(not(feature = "syntect"))] -struct ColoredText(); -#[cfg(not(feature = "syntect"))] -impl ColoredText { + #[cfg(not(feature = "syntect"))] pub fn ui(&self, _ui: &mut egui::Ui) {} } +#[cfg(not(feature = "syntect"))] +fn syntax_highlighting(_ctx: &egui::Context, _: &ehttp::Response, _: &str) -> Option { + None +} + // ---------------------------------------------------------------------------- // Texture/image handling is very manual at the moment. diff --git a/egui_demo_lib/src/lib.rs b/egui_demo_lib/src/lib.rs index 4eb7a4ab..e4ca3aef 100644 --- a/egui_demo_lib/src/lib.rs +++ b/egui_demo_lib/src/lib.rs @@ -77,6 +77,7 @@ mod apps; mod backend_panel; pub mod easy_mark; pub(crate) mod frame_history; +pub mod syntax_highlighting; mod wrap_app; pub use apps::ColorTest; // used for tests diff --git a/egui_demo_lib/src/syntax_highlighting.rs b/egui_demo_lib/src/syntax_highlighting.rs new file mode 100644 index 00000000..f1a48414 --- /dev/null +++ b/egui_demo_lib/src/syntax_highlighting.rs @@ -0,0 +1,491 @@ +use egui::text::LayoutJob; + +/// View some code with syntax highlighing and selection. +pub fn code_view_ui(ui: &mut egui::Ui, mut code: &str) { + let language = "rs"; + let theme = CodeTheme::from_memory(ui.ctx()); + + let mut layouter = |ui: &egui::Ui, string: &str, _wrap_width: f32| { + let layout_job = highlight(ui.ctx(), &theme, string, language); + // layout_job.wrap_width = wrap_width; // no wrapping + ui.fonts().layout_job(layout_job) + }; + + ui.add( + egui::TextEdit::multiline(&mut code) + .text_style(egui::TextStyle::Monospace) // for cursor height + .code_editor() + .desired_rows(1) + .lock_focus(true) + .layouter(&mut layouter), + ); +} + +/// Memoized Code highlighting +pub fn highlight(ctx: &egui::Context, theme: &CodeTheme, code: &str, language: &str) -> LayoutJob { + impl egui::util::cache::ComputerMut<(&CodeTheme, &str, &str), LayoutJob> for Highligher { + fn compute(&mut self, (theme, code, lang): (&CodeTheme, &str, &str)) -> LayoutJob { + self.highlight(theme, code, lang) + } + } + + type HighlightCache<'a> = egui::util::cache::FrameCache; + + let mut memory = ctx.memory(); + let highlight_cache = memory.caches.cache::>(); + highlight_cache.get((theme, code, language)) +} + +// ---------------------------------------------------------------------------- + +#[cfg(not(feature = "syntect"))] +#[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, +} + +#[cfg(feature = "syntect")] +#[derive(Clone, Copy, Hash, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +enum SyntectTheme { + Base16EightiesDark, + Base16MochaDark, + Base16OceanDark, + Base16OceanLight, + InspiredGitHub, + SolarizedDark, + SolarizedLight, +} + +#[cfg(feature = "syntect")] +impl SyntectTheme { + fn all() -> impl Iterator { + [ + Self::Base16EightiesDark, + Self::Base16MochaDark, + Self::Base16OceanDark, + Self::Base16OceanLight, + Self::InspiredGitHub, + Self::SolarizedDark, + Self::SolarizedLight, + ] + .iter() + .copied() + } + + fn name(&self) -> &'static str { + match self { + Self::Base16EightiesDark => "Base16 Eighties (dark)", + Self::Base16MochaDark => "Base16 Mocha (dark)", + Self::Base16OceanDark => "Base16 Ocean (dark)", + Self::Base16OceanLight => "Base16 Ocean (light)", + Self::InspiredGitHub => "InspiredGitHub (light)", + Self::SolarizedDark => "Solarized (dark)", + Self::SolarizedLight => "Solarized (light)", + } + } + + fn syntect_key_name(&self) -> &'static str { + match self { + Self::Base16EightiesDark => "base16-eighties.dark", + Self::Base16MochaDark => "base16-mocha.dark", + Self::Base16OceanDark => "base16-ocean.dark", + Self::Base16OceanLight => "base16-ocean.light", + Self::InspiredGitHub => "InspiredGitHub", + Self::SolarizedDark => "Solarized (dark)", + Self::SolarizedLight => "Solarized (light)", + } + } + + pub fn is_dark(&self) -> bool { + match self { + Self::Base16EightiesDark + | Self::Base16MochaDark + | Self::Base16OceanDark + | Self::SolarizedDark => true, + + Self::Base16OceanLight | Self::InspiredGitHub | Self::SolarizedLight => false, + } + } +} + +#[derive(Clone, Copy, Hash, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct CodeTheme { + dark_mode: bool, + + #[cfg(feature = "syntect")] + syntect_theme: SyntectTheme, + + #[cfg(not(feature = "syntect"))] + formats: enum_map::EnumMap, +} + +impl Default for CodeTheme { + fn default() -> Self { + Self::dark() + } +} + +impl CodeTheme { + pub fn from_style(style: &egui::Style) -> Self { + if style.visuals.dark_mode { + Self::dark() + } else { + Self::light() + } + } + + pub fn from_memory(ctx: &egui::Context) -> Self { + if ctx.style().visuals.dark_mode { + *ctx.memory() + .id_data + .get_or_insert_with(egui::Id::new("dark"), CodeTheme::dark) + } else { + *ctx.memory() + .id_data + .get_or_insert_with(egui::Id::new("light"), CodeTheme::light) + } + } + + pub fn store_in_memory(&self, ctx: &egui::Context) { + if self.dark_mode { + ctx.memory().id_data.insert(egui::Id::new("dark"), *self); + } else { + ctx.memory().id_data.insert(egui::Id::new("light"), *self); + } + } +} + +#[cfg(feature = "syntect")] +impl CodeTheme { + pub fn dark() -> Self { + Self { + dark_mode: true, + syntect_theme: SyntectTheme::Base16MochaDark, + } + } + + pub fn light() -> Self { + Self { + dark_mode: false, + syntect_theme: SyntectTheme::SolarizedLight, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + egui::widgets::global_dark_light_mode_buttons(ui); + + for theme in SyntectTheme::all() { + if theme.is_dark() == self.dark_mode { + ui.radio_value(&mut self.syntect_theme, theme, theme.name()); + } + } + } +} + +#[cfg(not(feature = "syntect"))] +impl CodeTheme { + pub fn dark() -> Self { + let text_style = egui::TextStyle::Monospace; + 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(87, 165, 171)), + 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), + ], + } + } + + pub fn light() -> Self { + let text_style = egui::TextStyle::Monospace; + use egui::{Color32, TextFormat}; + Self { + dark_mode: false, + #[cfg(not(feature = "syntect"))] + 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), + ], + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal_top(|ui| { + let mut selected_tt: TokenType = *ui.memory().data.get_or(TokenType::Comment); + + ui.vertical(|ui| { + ui.set_width(150.0); + egui::widgets::global_dark_light_mode_buttons(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.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); + } + }); + + let reset_value = if self.dark_mode { + CodeTheme::dark() + } else { + CodeTheme::light() + }; + + 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(feature = "syntect")] +struct Highligher { + ps: syntect::parsing::SyntaxSet, + ts: syntect::highlighting::ThemeSet, +} + +#[cfg(feature = "syntect")] +impl Default for Highligher { + fn default() -> Self { + Self { + ps: syntect::parsing::SyntaxSet::load_defaults_newlines(), + ts: syntect::highlighting::ThemeSet::load_defaults(), + } + } +} + +#[cfg(feature = "syntect")] +impl Highligher { + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + fn highlight(&self, theme: &CodeTheme, code: &str, lang: &str) -> LayoutJob { + self.highlight_impl(theme, code, lang).unwrap_or_else(|| { + // Fallback: + LayoutJob::simple( + code.into(), + egui::TextStyle::Monospace, + if theme.dark_mode { + egui::Color32::LIGHT_GRAY + } else { + egui::Color32::DARK_GRAY + }, + f32::INFINITY, + ) + }) + } + + fn highlight_impl(&self, theme: &CodeTheme, text: &str, language: &str) -> Option { + use syntect::easy::HighlightLines; + use syntect::highlighting::FontStyle; + use syntect::util::LinesWithEndings; + + let syntax = self + .ps + .find_syntax_by_name(language) + .or_else(|| self.ps.find_syntax_by_extension(language))?; + + let theme = theme.syntect_theme.syntect_key_name(); + let mut h = HighlightLines::new(syntax, &self.ts.themes[theme]); + + use egui::text::{LayoutSection, TextFormat}; + + let mut job = LayoutJob { + text: text.into(), + ..Default::default() + }; + + for line in LinesWithEndings::from(text) { + for (style, range) in h.highlight(line, &self.ps) { + let fg = style.foreground; + let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); + let italics = style.font_style.contains(FontStyle::ITALIC); + let underline = style.font_style.contains(FontStyle::ITALIC); + let underline = if underline { + egui::Stroke::new(1.0, text_color) + } else { + egui::Stroke::none() + }; + job.sections.push(LayoutSection { + leading_space: 0.0, + byte_range: as_byte_range(text, range), + format: TextFormat { + style: egui::TextStyle::Monospace, + color: text_color, + italics, + underline, + ..Default::default() + }, + }); + } + } + + Some(job) + } +} + +#[cfg(feature = "syntect")] +fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { + let whole_start = whole.as_ptr() as usize; + let range_start = range.as_ptr() as usize; + assert!(whole_start <= range_start); + assert!(range_start + range.len() <= whole_start + whole.len()); + let offset = range_start - whole_start; + offset..(offset + range.len()) +} + +// ---------------------------------------------------------------------------- + +#[cfg(not(feature = "syntect"))] +#[derive(Default)] +struct Highligher {} + +#[cfg(not(feature = "syntect"))] +impl Highligher { + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + fn highlight(&self, theme: &CodeTheme, mut text: &str, _language: &str) -> LayoutJob { + // Extremely simple syntax highlighter for when we compile without syntect + + 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, theme.formats[TokenType::Comment]); + text = &text[end..]; + } else if text.starts_with('"') { + let end = text[1..] + .find('"') + .map(|i| i + 2) + .or_else(|| text.find('\n')) + .unwrap_or_else(|| text.len()); + 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..] + .find(|c: char| !c.is_ascii_alphanumeric()) + .map(|i| i + 1) + .unwrap_or_else(|| text.len()); + let word = &text[..end]; + let tt = if is_keyword(word) { + TokenType::Keyword + } else { + 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, 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, theme.formats[TokenType::Punctuation]); + text = &text[end..]; + } + } + + job + } +} + +#[cfg(not(feature = "syntect"))] +fn is_keyword(word: &str) -> bool { + matches!( + word, + "as" | "async" + | "await" + | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + ) +}