diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index a5a9739..88850d0 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -16,7 +16,7 @@ use iced_widget::text_input; use iced_wgpu::core::text::highlighter::Format; use iced_wgpu::core::widget::Id as WidgetId; use crate::palette; -use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings}; +use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors}; #[derive(Debug, Clone)] #[allow(dead_code)] @@ -754,6 +754,7 @@ impl EditorState { let text = self.content.text(); let result_mask: Vec = text.lines().map(|l| is_result_line(l)).collect(); let source_line_count = result_mask.iter().filter(|r| !**r).count(); + let decors = compute_line_decors(&text); let gutter = Gutter { line_count: self.content.line_count(), source_line_count, @@ -762,6 +763,7 @@ impl EditorState { cursor_line: self.content.cursor().position.line, top_pad, result_mask, + line_decors: decors, }; let gw = gutter.gutter_width(); @@ -941,6 +943,7 @@ struct Gutter { cursor_line: usize, top_pad: f32, result_mask: Vec, + line_decors: Vec, } impl Gutter { @@ -997,12 +1000,47 @@ impl canvas::Program for Gutter { } continue; } + + let decor = if line_idx < self.line_decors.len() { + self.line_decors[line_idx] + } else { + LineDecor::None + }; + let p = palette::current(); + + match decor { + LineDecor::CodeBlock | LineDecor::FenceMarker => { + frame.fill_rectangle( + Point::new(0.0, y), + iced_wgpu::core::Size::new(gw, lh), + Color { a: 0.15, ..p.surface2 }, + ); + } + LineDecor::Blockquote => { + frame.fill_rectangle( + Point::new(gw - 3.0, y), + iced_wgpu::core::Size::new(3.0, lh), + p.lavender, + ); + } + LineDecor::HorizontalRule => { + let mid_y = y + lh / 2.0; + let path = canvas::Path::line( + Point::new(4.0, mid_y), + Point::new(gw - 4.0, mid_y), + ); + frame.stroke(&path, canvas::Stroke::default() + .with_width(1.0) + .with_color(p.overlay1)); + } + LineDecor::None => {} + } + let is_result = line_idx < self.result_mask.len() && self.result_mask[line_idx]; if is_result { continue; } source_num += 1; - let p = palette::current(); let color = if line_idx == self.cursor_line { p.overlay1 } else { diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index ff8cd76..efeea36 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -27,6 +27,9 @@ const MD_LIST_MARKER: u8 = 38; const MD_FENCE_MARKER: u8 = 39; const MD_CODE_BLOCK: u8 = 40; const MD_HR: u8 = 41; +const MD_TASK_OPEN: u8 = 42; +const MD_TASK_DONE: u8 = 43; +const MD_BOLD_ITALIC: u8 = 44; #[derive(Clone, PartialEq)] pub struct SyntaxSettings { @@ -39,6 +42,15 @@ pub struct SyntaxHighlight { pub kind: u8, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LineDecor { + None, + CodeBlock, + Blockquote, + HorizontalRule, + FenceMarker, +} + pub struct SyntaxHighlighter { lang: String, spans: Vec, @@ -46,6 +58,7 @@ pub struct SyntaxHighlighter { line_kinds: Vec, in_fenced_code: bool, current_line: usize, + line_decors: Vec, } impl SyntaxHighlighter { @@ -59,6 +72,31 @@ impl SyntaxHighlighter { } let classified = classify_document(source); self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); + + self.line_decors.clear(); + let mut in_fence = false; + for (i, raw_line) in source.split('\n').enumerate() { + let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown; + if is_md { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + self.line_decors.push(LineDecor::FenceMarker); + } else if in_fence { + self.line_decors.push(LineDecor::CodeBlock); + } else if is_horizontal_rule(trimmed) { + self.line_decors.push(LineDecor::HorizontalRule); + } else if trimmed.starts_with("> ") || trimmed == ">" { + self.line_decors.push(LineDecor::Blockquote); + } else { + self.line_decors.push(LineDecor::None); + } + } else { + if in_fence { in_fence = false; } + self.line_decors.push(LineDecor::None); + } + } + self.in_fenced_code = false; self.current_line = 0; } @@ -104,10 +142,15 @@ impl SyntaxHighlighter { return spans; } - if let Some(marker_len) = list_marker_len(trimmed) { + if let Some(list_info) = list_marker_info(trimmed) { + let (marker_len, marker_kind) = match list_info { + ListKind::TaskOpen(n) => (n, MD_TASK_OPEN), + ListKind::TaskDone(n) => (n, MD_TASK_DONE), + ListKind::Plain(n) => (n, MD_LIST_MARKER), + }; let marker_end = leading + marker_len; let mut spans = vec![ - (0..marker_end, SyntaxHighlight { kind: MD_LIST_MARKER }), + (0..marker_end, SyntaxHighlight { kind: marker_kind }), ]; if marker_end < line.len() { let content = &line[marker_end..]; @@ -144,22 +187,32 @@ fn is_horizontal_rule(trimmed: &str) -> bool { trimmed.bytes().all(|b| b == first || b == b' ') } -fn list_marker_len(trimmed: &str) -> Option { +#[derive(Clone, Copy, PartialEq)] +enum ListKind { + Plain(usize), + TaskOpen(usize), + TaskDone(usize), +} + +fn list_marker_info(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); + if trimmed.starts_with("- [ ] ") { + return Some(ListKind::TaskOpen(6)); } - return Some(2); + if trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") { + return Some(ListKind::TaskDone(6)); + } + return Some(ListKind::Plain(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); + return Some(ListKind::Plain(i + 2)); } } None @@ -172,11 +225,36 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> let mut i = 0; while i < len { + if bytes[i] == b'\\' && i + 1 < len && is_md_punctuation(bytes[i + 1]) { + i += 2; + continue; + } + + if i + 2 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' { + if let Some(end) = find_triple_star(bytes, i + 3) { + spans.push((base + i..base + i + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + 3 < end { + spans.push((base + i + 3..base + end, SyntaxHighlight { kind: MD_BOLD_ITALIC })); + } + spans.push((base + end..base + end + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = end + 3; + continue; + } + } + 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 })); + let inner = parse_inline(&text[i + 2..end], base + i + 2); + if inner.is_empty() { + spans.push((base + i + 2..base + end, SyntaxHighlight { kind: MD_BOLD })); + } else { + for (r, h) in inner { + let kind = if h.kind == MD_ITALIC { MD_BOLD_ITALIC } else { h.kind }; + spans.push((r, SyntaxHighlight { kind })); + } + } } spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER })); i = end + 2; @@ -186,24 +264,27 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> 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 })); + if end > i + 1 && bytes[end - 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; } - 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 })); + let tick_count = count_backticks(bytes, i); + if let Some(end) = find_backtick_close(bytes, i + tick_count, tick_count) { + spans.push((base + i..base + i + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + if i + tick_count < end { + spans.push((base + i + tick_count..base + end, SyntaxHighlight { kind: MD_INLINE_CODE })); } - spans.push((base + end..base + end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER })); - i = end + 1; + spans.push((base + end..base + end + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER })); + i = end + tick_count; continue; } } @@ -230,6 +311,40 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> spans } +fn is_md_punctuation(b: u8) -> bool { + matches!(b, b'\\' | b'`' | b'*' | b'_' | b'{' | b'}' | b'[' | b']' + | b'(' | b')' | b'#' | b'+' | b'-' | b'.' | b'!' | b'|') +} + +fn find_triple_star(bytes: &[u8], start: usize) -> Option { + let mut i = start; + while i + 2 < bytes.len() { + if bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' { + return Some(i); + } + i += 1; + } + None +} + +fn count_backticks(bytes: &[u8], start: usize) -> usize { + let mut n = 0; + while start + n < bytes.len() && bytes[start + n] == b'`' { n += 1; } + n +} + +fn find_backtick_close(bytes: &[u8], start: usize, count: usize) -> Option { + if count == 0 { return None; } + let mut i = start; + while i + count <= bytes.len() { + if count_backticks(bytes, i) == count { + return Some(i); + } + i += 1; + } + None +} + fn find_closing(bytes: &[u8], start: usize, c1: u8, c2: u8) -> Option { let mut i = start; while i + 1 < bytes.len() { @@ -287,6 +402,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { line_kinds: Vec::new(), in_fenced_code: false, current_line: 0, + line_decors: Vec::new(), }; h.rebuild(&settings.source); h @@ -412,6 +528,9 @@ pub fn highlight_color(kind: u8) -> Color { MD_FENCE_MARKER => p.overlay0, MD_CODE_BLOCK => p.text, MD_HR => p.overlay1, + MD_TASK_OPEN => p.overlay2, + MD_TASK_DONE => p.green, + MD_BOLD_ITALIC => p.peach, _ => p.text, } } @@ -419,14 +538,47 @@ pub fn highlight_color(kind: u8) -> Color { 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_H1 => Some(Font { weight: Weight::Black, ..Font::DEFAULT }), + MD_H2 => Some(Font { weight: Weight::Bold, ..Font::DEFAULT }), + MD_H3 => Some(Font { weight: Weight::Semibold, ..Font::DEFAULT }), MD_BOLD => Some(Font { weight: Weight::Bold, ..Font::DEFAULT }), MD_ITALIC => Some(Font { style: FontStyle::Italic, ..Font::DEFAULT }), + MD_BOLD_ITALIC => Some(Font { weight: Weight::Bold, 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), + MD_TASK_DONE => Some(Font { weight: Weight::Bold, ..Font::MONOSPACE }), _ => None, } } + +pub fn compute_line_decors(source: &str) -> Vec { + let classified = classify_document(source); + let line_kinds: Vec = classified.into_iter().map(|cl| cl.kind).collect(); + let mut decors = Vec::new(); + let mut in_fence = false; + for (i, raw_line) in source.split('\n').enumerate() { + let is_md = i < line_kinds.len() && line_kinds[i] == LineKind::Markdown; + if is_md { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + decors.push(LineDecor::FenceMarker); + } else if in_fence { + decors.push(LineDecor::CodeBlock); + } else if is_horizontal_rule(trimmed) { + decors.push(LineDecor::HorizontalRule); + } else if trimmed.starts_with("> ") || trimmed == ">" { + decors.push(LineDecor::Blockquote); + } else { + decors.push(LineDecor::None); + } + } else { + if in_fence { in_fence = false; } + decors.push(LineDecor::None); + } + } + decors +}