use egui::text::{CCursorRange, LayoutJob}; use crate::{ easy_mark::easy_mark_parser, markdown::{ span::Span, tokenizer::{Token, TokenKind, tokenize}, }, }; /// Highlight easymark, memoizing previous output to save CPU. /// /// In practice, the highlighter is fast enough not to need any caching. #[derive(Default)] pub struct MemoizedHighlighter { style: egui::Style, code: String, output: LayoutJob, } impl MemoizedHighlighter { pub fn highlight( &mut self, egui_style: &egui::Style, code: &str, cursor: Option, ) -> LayoutJob { if (&self.style, self.code.as_str()) != (egui_style, code) { self.style = egui_style.clone(); code.clone_into(&mut self.code); self.output = highlight_easymark(egui_style, code, cursor); } self.output.clone() } } pub fn highlight_easymark( egui_style: &egui::Style, text: &str, // TODO: hide special characters where cursor isn't _cursor: Option, ) -> LayoutJob { let mut job = LayoutJob::default(); let mut style = easy_mark_parser::Style::default(); let mut prev = TokenKind::Newline; let tokens: Vec<_> = tokenize(text).collect(); let mut tokens = &tokens[..]; const CODE_INDENT: f32 = 10.0; while !tokens.is_empty() { let token = tokens.first().unwrap(); tokens = &tokens[1..]; let start_of_line = prev == TokenKind::Newline; prev = token.kind; match token.kind { TokenKind::CodeBlock if start_of_line => { let astyle = format_from_style( egui_style, &easy_mark_parser::Style { code: true, ..Default::default() }, ); let span = collect_until( token, &mut tokens, series([TokenKind::Newline, TokenKind::CodeBlock]), ); job.append(&*span, CODE_INDENT, astyle.clone()); style = Default::default(); continue; } TokenKind::Newline => style = easy_mark_parser::Style::default(), TokenKind::Strong => style.strong ^= true, TokenKind::Italic => style.italics ^= true, TokenKind::Strikethrough => style.strikethrough ^= true, TokenKind::Heading(_h) if start_of_line => style.heading = true, TokenKind::Quote if start_of_line => style.quoted = true, TokenKind::CodeBlock | TokenKind::Mono => { style.code = true; let span = collect_until( token, &mut tokens, any_of([TokenKind::Mono, TokenKind::CodeBlock, TokenKind::Newline]), ); job.append(&*span, 0.0, format_from_style(egui_style, &style)); style.code = false; continue; } TokenKind::Heading(..) | TokenKind::Quote | TokenKind::Text => {} } job.append(&token.span, 0.0, format_from_style(egui_style, &style)); } job } fn series<'a, const N: usize>(of: [TokenKind; N]) -> impl FnMut(&[Token<'a>; N]) -> bool { move |token| { of.iter() .zip(token) .all(|(kind, token)| kind == &token.kind) } } fn any_of<'a, const N: usize>(these: [TokenKind; N]) -> impl FnMut(&[Token<'a>; 1]) -> bool { move |[token]| these.contains(&token.kind) } /// Collect all tokens up to and including `pattern`, and merge them into a signle span. /// /// `N` determines how many specific and consecutive tokens we are looking for. /// i.e. if we were looking for a [TokenKind::Newline] followed by a [TokenKind::Quote], `N` /// would equal `2`. /// /// `pattern` is a function that accepts an array of `N` tokens and returns `true` if they match, /// i.e. if we should stop collecting. [any_of] and [series] can help to construct this function. /// /// The collected tokens will be split off the head of the slice referred to by `tokens`. /// /// # Panic /// Panics if `tokens` does not contain only consecutive adjacent spans. fn collect_until<'a, const N: usize>( token: &Token<'a>, tokens: &mut &[Token<'a>], pattern: impl FnMut(&[Token<'a>; N]) -> bool, ) -> Span<'a> where for<'b> &'b [Token<'a>; N]: TryFrom<&'b [Token<'a>]>, { let mut windows = tokens .windows(N) .map(|slice| <&[Token<'a>; N]>::try_from(slice).ok().unwrap()); let split_at = match windows.position(pattern) { Some(i) => i + N, None => tokens.len(), // consume everything }; let (consume, keep) = tokens.split_at(split_at); *tokens = keep; consume .iter() .fold(token.span.clone(), |span: Span<'_>, token| { span.try_merge(&token.span).unwrap() }) } pub fn highlight_easymark_old( egui_style: &egui::Style, mut text: &str, // TODO: hide special characters where cursor isn't _cursor: Option, ) -> LayoutJob { let mut job = LayoutJob::default(); let mut style = easy_mark_parser::Style::default(); let mut start_of_line = true; const CODE_INDENT: f32 = 10.0; while !text.is_empty() { if start_of_line && text.starts_with("```") { let astyle = format_from_style( egui_style, &easy_mark_parser::Style { code: true, ..Default::default() }, ); // Render the initial backticks as spaces text = &text[3..]; job.append(" ", CODE_INDENT, astyle.clone()); match text.find("\n```") { Some(n) => { for line in text[..n + 1].lines() { job.append(line, CODE_INDENT, astyle.clone()); job.append("\n", 0.0, astyle.clone()); } // Render the final backticks as spaces job.append(" ", CODE_INDENT, astyle); text = &text[n + 4..]; } None => { job.append(text, 0.0, astyle.clone()); text = ""; } }; style = Default::default(); continue; } if text.starts_with('`') { style.code = true; let end = text[1..] .find(&['`', '\n'][..]) .map_or_else(|| text.len(), |i| i + 2); job.append(&text[..end], 0.0, format_from_style(egui_style, &style)); text = &text[end..]; style.code = false; continue; } let skip; // zero-width space let _zws = "\u{200b}"; let mut apply_basic_style = |text: &mut &str, style: &mut easy_mark_parser::Style, access: fn(&mut easy_mark_parser::Style) -> &mut bool| { let skip = if *access(style) { // Include the character that is ending this style: job.append(&text[..1], 0.0, format_from_style(egui_style, style)); *text = &text[1..]; 0 } else { 1 }; *access(style) ^= true; skip }; if text.starts_with('*') { skip = apply_basic_style(&mut text, &mut style, |style| &mut style.strong); } else if text.starts_with('/') { skip = apply_basic_style(&mut text, &mut style, |style| &mut style.italics); } else if text.starts_with('_') { skip = apply_basic_style(&mut text, &mut style, |style| &mut style.underline); } else if text.starts_with('$') { skip = apply_basic_style(&mut text, &mut style, |style| &mut style.small); } else if text.starts_with('~') { skip = apply_basic_style(&mut text, &mut style, |style| &mut style.strikethrough); } else if text.starts_with('^') { skip = apply_basic_style(&mut text, &mut style, |style| &mut style.raised); } else if text.starts_with('\\') && text.len() >= 2 { skip = 2; } else if start_of_line && text.starts_with(' ') { // we don't preview indentation, because it is confusing skip = 1; } else if start_of_line && text.starts_with("###### ") { style.heading = true; skip = 7; } else if start_of_line && text.starts_with("##### ") { style.heading = true; skip = 6; } else if start_of_line && text.starts_with("#### ") { style.heading = true; skip = 5; } else if start_of_line && text.starts_with("### ") { style.heading = true; skip = 4; } else if start_of_line && text.starts_with("## ") { style.heading = true; skip = 3; } else if start_of_line && text.starts_with("# ") { style.heading = true; skip = 2; } else if start_of_line && text.starts_with("> ") { style.quoted = true; skip = 2; // we don't preview indentation, because it is confusing } else if start_of_line && text.trim_start().starts_with("- ") { job.append("• ", 0.0, format_from_style(egui_style, &style)); text = &text[2..]; skip = 0; // we don't preview indentation, because it is confusing } else { skip = 0; } // Note: we don't preview underline, strikethrough and italics because it confuses things. // Swallow everything up to the next special character: let line_end = text[skip..] .find('\n') .map_or_else(|| text.len(), |i| (skip + i + 1)); let end = text[skip..] .find(&['*', '`', '~', '_', '/', '$', '^', '\\', '<', '['][..]) .map_or_else(|| text.len(), |i| (skip + i).max(1)); if line_end <= end { job.append( &text[..line_end], 0.0, format_from_style(egui_style, &style), ); text = &text[line_end..]; start_of_line = true; style = Default::default(); } else { job.append(&text[..end], 0.0, format_from_style(egui_style, &style)); text = &text[end..]; start_of_line = false; } } job } fn format_from_style( egui_style: &egui::Style, emark_style: &easy_mark_parser::Style, ) -> egui::text::TextFormat { use egui::{Align, Color32, Stroke, TextStyle}; let color = if emark_style.strong || emark_style.heading { egui_style.visuals.strong_text_color() } else if emark_style.quoted { egui_style.visuals.weak_text_color() } else { egui_style.visuals.text_color() }; let text_style = if emark_style.heading { TextStyle::Heading } else if emark_style.code { TextStyle::Monospace } else if emark_style.small | emark_style.raised { TextStyle::Small } else { TextStyle::Body }; let background = if emark_style.code { egui_style.visuals.code_bg_color } else { Color32::TRANSPARENT }; let underline = if emark_style.underline { Stroke::new(1.0, color) } else { Stroke::NONE }; let strikethrough = if emark_style.strikethrough { Stroke::new(1.0, color) } else { Stroke::NONE }; let valign = if emark_style.raised { Align::TOP } else { Align::BOTTOM }; egui::text::TextFormat { font_id: text_style.resolve(egui_style), color, background, italics: emark_style.italics, underline, strikethrough, valign, ..Default::default() } }