From cdd243c8ffa4222353c4d1d19416098a3e52829f Mon Sep 17 00:00:00 2001 From: jess Date: Wed, 8 Apr 2026 03:08:01 -0700 Subject: [PATCH] markdown syntax highlighting in text editor --- viewport/src/editor.rs | 2 +- viewport/src/syntax.rs | 305 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 299 insertions(+), 8 deletions(-) diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 30cbb47..4da2972 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -365,7 +365,7 @@ impl EditorState { settings, |highlight, _theme| Format { color: Some(syntax::highlight_color(highlight.kind)), - font: None, + font: syntax::highlight_font(highlight.kind), }, ) .into(); diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index dd18345..a1077a2 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -1,13 +1,32 @@ use std::ops::Range; use iced_wgpu::core::text::highlighter; -use iced_wgpu::core::Color; +use iced_wgpu::core::{Color, Font}; +use iced_wgpu::core::font::{Weight, Style as FontStyle}; use acord_core::highlight::{highlight_source, HighlightSpan}; +use acord_core::doc::{classify_document, LineKind}; use crate::editor::{RESULT_PREFIX, ERROR_PREFIX}; pub const EVAL_RESULT_KIND: u8 = 24; pub const EVAL_ERROR_KIND: u8 = 25; +const MD_HEADING_MARKER: u8 = 26; +const MD_H1: u8 = 27; +const MD_H2: u8 = 28; +const MD_H3: u8 = 29; +const MD_BOLD: u8 = 30; +const MD_ITALIC: u8 = 31; +const MD_INLINE_CODE: u8 = 32; +const MD_FORMAT_MARKER: u8 = 33; +const MD_LINK_TEXT: u8 = 34; +const MD_LINK_URL: u8 = 35; +const MD_BLOCKQUOTE_MARKER: u8 = 36; +const MD_BLOCKQUOTE: u8 = 37; +const MD_LIST_MARKER: u8 = 38; +const MD_FENCE_MARKER: u8 = 39; +const MD_CODE_BLOCK: u8 = 40; +const MD_HR: u8 = 41; + #[derive(Clone, PartialEq)] pub struct SyntaxSettings { pub lang: String, @@ -23,6 +42,8 @@ pub struct SyntaxHighlighter { lang: String, spans: Vec, line_offsets: Vec, + line_kinds: Vec, + in_fenced_code: bool, current_line: usize, } @@ -35,8 +56,221 @@ impl SyntaxHighlighter { self.line_offsets.push(offset); offset += line.len() + 1; } + let classified = classify_document(source); + self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); + self.in_fenced_code = false; self.current_line = 0; } + + fn highlight_markdown(&self, line: &str) -> Vec<(Range, SyntaxHighlight)> { + let trimmed = line.trim_start(); + let leading = line.len() - trimmed.len(); + + if is_horizontal_rule(trimmed) { + return vec![(0..line.len(), SyntaxHighlight { kind: MD_HR })]; + } + + if let Some(level) = heading_level(trimmed) { + let marker_end = leading + level + 1; + let kind = match level { + 1 => MD_H1, + 2 => MD_H2, + _ => MD_H3, + }; + let mut spans = vec![ + (0..marker_end, SyntaxHighlight { kind: MD_HEADING_MARKER }), + ]; + if marker_end < line.len() { + spans.push((marker_end..line.len(), SyntaxHighlight { kind })); + } + return spans; + } + + if trimmed.starts_with("> ") || trimmed == ">" { + let marker_end = leading + if trimmed.len() > 1 { 2 } else { 1 }; + let mut spans = vec![ + (0..marker_end, SyntaxHighlight { kind: MD_BLOCKQUOTE_MARKER }), + ]; + if marker_end < line.len() { + let content = &line[marker_end..]; + let inner = parse_inline(content, marker_end); + if inner.is_empty() { + spans.push((marker_end..line.len(), SyntaxHighlight { kind: MD_BLOCKQUOTE })); + } else { + spans.extend(inner); + } + } + return spans; + } + + if let Some(marker_len) = list_marker_len(trimmed) { + let marker_end = leading + marker_len; + let mut spans = vec![ + (0..marker_end, SyntaxHighlight { kind: MD_LIST_MARKER }), + ]; + if marker_end < line.len() { + let content = &line[marker_end..]; + let inner = parse_inline(content, marker_end); + if inner.is_empty() { + return spans; + } + spans.extend(inner); + } + return spans; + } + + parse_inline(line, 0) + } +} + +fn heading_level(trimmed: &str) -> Option { + let bytes = trimmed.as_bytes(); + if bytes.is_empty() || bytes[0] != b'#' { return None; } + let mut level = 0; + while level < bytes.len() && bytes[level] == b'#' { level += 1; } + if level > 3 { return None; } + if level < bytes.len() && bytes[level] == b' ' { + Some(level) + } else { + None + } +} + +fn is_horizontal_rule(trimmed: &str) -> bool { + if trimmed.len() < 3 { return false; } + let first = trimmed.as_bytes()[0]; + if !matches!(first, b'-' | b'*' | b'_') { return false; } + trimmed.bytes().all(|b| b == first || b == b' ') +} + +fn list_marker_len(trimmed: &str) -> Option { + let bytes = trimmed.as_bytes(); + if bytes.is_empty() { return None; } + + if matches!(bytes[0], b'-' | b'*' | b'+') && bytes.get(1) == Some(&b' ') { + if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") { + return Some(6); + } + return Some(2); + } + + let mut i = 0; + while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } + if i > 0 && i < bytes.len() && matches!(bytes[i], b'.' | b')') { + if bytes.get(i + 1) == Some(&b' ') { + return Some(i + 2); + } + } + None +} + +fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> { + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut spans = Vec::new(); + let mut i = 0; + + while i < len { + if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' { + if let Some(end) = find_closing(bytes, i + 2, b'*', b'*') { + spans.push((base + i..base + i + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 2 < end { + spans.push((base + i + 2..base + end, SyntaxHighlight { kind: MD_BOLD })); + } + spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = end + 2; + continue; + } + } + + if bytes[i] == b'*' && (i + 1 >= len || bytes[i + 1] != b'*') { + if let Some(end) = find_single_closing(bytes, i + 1, b'*') { + spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 1 < end { + spans.push((base + i + 1..base + end, SyntaxHighlight { kind: MD_ITALIC })); + } + spans.push((base + end..base + end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = end + 1; + continue; + } + } + + if bytes[i] == b'`' { + if let Some(end) = find_single_closing(bytes, i + 1, b'`') { + spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 1 < end { + spans.push((base + i + 1..base + end, SyntaxHighlight { kind: MD_INLINE_CODE })); + } + spans.push((base + end..base + end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = end + 1; + continue; + } + } + + if bytes[i] == b'[' { + if let Some((text_end, url_end)) = find_link(bytes, i) { + spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 1 < text_end { + spans.push((base + i + 1..base + text_end, SyntaxHighlight { kind: MD_LINK_TEXT })); + } + spans.push((base + text_end..base + text_end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if text_end + 2 < url_end { + spans.push((base + text_end + 2..base + url_end, SyntaxHighlight { kind: MD_LINK_URL })); + } + spans.push((base + url_end..base + url_end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = url_end + 1; + continue; + } + } + + i += 1; + } + + spans +} + +fn find_closing(bytes: &[u8], start: usize, c1: u8, c2: u8) -> Option { + let mut i = start; + while i + 1 < bytes.len() { + if bytes[i] == c1 && bytes[i + 1] == c2 { + return Some(i); + } + i += 1; + } + None +} + +fn find_single_closing(bytes: &[u8], start: usize, ch: u8) -> Option { + let mut i = start; + while i < bytes.len() { + if bytes[i] == ch { + return Some(i); + } + i += 1; + } + None +} + +fn find_link(bytes: &[u8], open: usize) -> Option<(usize, usize)> { + let mut i = open + 1; + while i < bytes.len() { + if bytes[i] == b']' { + if i + 1 < bytes.len() && bytes[i + 1] == b'(' { + let text_end = i; + let mut j = i + 2; + while j < bytes.len() { + if bytes[j] == b')' { + return Some((text_end, j)); + } + j += 1; + } + } + return None; + } + if bytes[i] == b'\n' { return None; } + i += 1; + } + None } impl highlighter::Highlighter for SyntaxHighlighter { @@ -49,6 +283,8 @@ impl highlighter::Highlighter for SyntaxHighlighter { lang: settings.lang.clone(), spans: Vec::new(), line_offsets: Vec::new(), + line_kinds: Vec::new(), + in_fenced_code: false, current_line: 0, }; h.rebuild(&settings.source); @@ -62,18 +298,42 @@ impl highlighter::Highlighter for SyntaxHighlighter { fn change_line(&mut self, line: usize) { self.current_line = self.current_line.min(line); + if line == 0 { + self.in_fenced_code = false; + } } - fn highlight_line(&mut self, _line: &str) -> Self::Iterator<'_> { + fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { let ln = self.current_line; self.current_line += 1; - let trimmed = _line.trim_start(); + let trimmed = line.trim_start(); if trimmed.starts_with(RESULT_PREFIX) { - return vec![(0.._line.len(), SyntaxHighlight { kind: EVAL_RESULT_KIND })].into_iter(); + return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_RESULT_KIND })].into_iter(); } if trimmed.starts_with(ERROR_PREFIX) { - return vec![(0.._line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter(); + return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter(); + } + + let is_markdown = ln < self.line_kinds.len() + && self.line_kinds[ln] == LineKind::Markdown; + + if is_markdown { + if trimmed.starts_with("```") { + self.in_fenced_code = !self.in_fenced_code; + return vec![(0..line.len(), SyntaxHighlight { kind: MD_FENCE_MARKER })].into_iter(); + } + + if self.in_fenced_code { + return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter(); + } + + let md_spans = self.highlight_markdown(line); + if !md_spans.is_empty() { + return md_spans.into_iter(); + } + } else if self.in_fenced_code { + self.in_fenced_code = false; } if ln >= self.line_offsets.len() { @@ -84,7 +344,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { let line_end = if ln + 1 < self.line_offsets.len() { self.line_offsets[ln + 1] - 1 } else { - line_start + _line.len() + line_start + line.len() }; let mut result = Vec::new(); @@ -134,6 +394,37 @@ pub fn highlight_color(kind: u8) -> Color { 23 => Color::from_rgb(0.804, 0.839, 0.957), // embedded - text 24 => Color::from_rgb(0.651, 0.890, 0.631), // eval result - green 25 => Color::from_rgb(0.890, 0.400, 0.400), // eval error - muted red - _ => Color::from_rgb(0.804, 0.839, 0.957), // default text + MD_HEADING_MARKER => Color::from_rgb(0.424, 0.443, 0.537), + MD_H1 => Color::from_rgb(0.961, 0.878, 0.863), + MD_H2 => Color::from_rgb(0.988, 0.702, 0.529), + MD_H3 => Color::from_rgb(0.976, 0.827, 0.522), + MD_BOLD => Color::from_rgb(0.988, 0.702, 0.529), + MD_ITALIC => Color::from_rgb(0.804, 0.569, 0.945), + MD_INLINE_CODE => Color::from_rgb(0.651, 0.890, 0.631), + MD_FORMAT_MARKER => Color::from_rgb(0.424, 0.443, 0.537), + MD_LINK_TEXT => Color::from_rgb(0.537, 0.706, 0.980), + MD_LINK_URL => Color::from_rgb(0.502, 0.525, 0.639), + MD_BLOCKQUOTE_MARKER => Color::from_rgb(0.424, 0.443, 0.537), + MD_BLOCKQUOTE => Color::from_rgb(0.604, 0.831, 0.898), + MD_LIST_MARKER => Color::from_rgb(0.604, 0.831, 0.898), + MD_FENCE_MARKER => Color::from_rgb(0.424, 0.443, 0.537), + MD_CODE_BLOCK => Color::from_rgb(0.804, 0.839, 0.957), + MD_HR => Color::from_rgb(0.502, 0.525, 0.639), + _ => Color::from_rgb(0.804, 0.839, 0.957), + } +} + +pub fn highlight_font(kind: u8) -> Option { + match kind { + MD_HEADING_MARKER => Some(Font { weight: Weight::Bold, ..Font::MONOSPACE }), + MD_H1 | MD_H2 | MD_H3 => Some(Font { weight: Weight::Bold, ..Font::DEFAULT }), + MD_BOLD => Some(Font { weight: Weight::Bold, ..Font::DEFAULT }), + MD_ITALIC => Some(Font { style: FontStyle::Italic, ..Font::DEFAULT }), + MD_INLINE_CODE => Some(Font::MONOSPACE), + MD_FORMAT_MARKER => Some(Font::MONOSPACE), + MD_BLOCKQUOTE => Some(Font { style: FontStyle::Italic, ..Font::DEFAULT }), + MD_FENCE_MARKER => Some(Font::MONOSPACE), + MD_CODE_BLOCK => Some(Font::MONOSPACE), + _ => None, } }