use std::ops::Range;
use crate::EditorBuffer;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Rgba {
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
pub const fn hex(hex: u32) -> Self {
let r = ((hex >> 16) & 0xFF) as u8;
let g = ((hex >> 8) & 0xFF) as u8;
let b = (hex & 0xFF) as u8;
Self { r, g, b, a: 255 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UnderlineDecoration {
Solid,
Wavy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HighlightTag {
Keyword,
Function,
Type,
String,
Number,
Comment,
Operator,
Punctuation,
Heading(u8),
Bold,
Italic,
Code,
Link,
Blockquote,
HorizontalRule,
TaskUnchecked,
TaskChecked,
Dimmed,
Hidden,
Custom(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct TextStyle {
pub color: Option<Rgba>,
pub background: Option<Rgba>,
pub bold: bool,
pub italic: bool,
pub underline: Option<UnderlineDecoration>,
pub strikethrough: bool,
}
impl TextStyle {
pub const fn new() -> Self {
Self {
color: None,
background: None,
bold: false,
italic: false,
underline: None,
strikethrough: false,
}
}
pub const fn color(mut self, color: Rgba) -> Self {
self.color = Some(color);
self
}
pub const fn background(mut self, background: Rgba) -> Self {
self.background = Some(background);
self
}
pub const fn bold(mut self) -> Self {
self.bold = true;
self
}
pub const fn italic(mut self) -> Self {
self.italic = true;
self
}
pub const fn underline(mut self, underline: UnderlineDecoration) -> Self {
self.underline = Some(underline);
self
}
pub const fn strikethrough(mut self) -> Self {
self.strikethrough = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StyleValue {
Tag(HighlightTag),
Direct(TextStyle),
}
impl From<HighlightTag> for StyleValue {
fn from(tag: HighlightTag) -> Self {
Self::Tag(tag)
}
}
impl From<TextStyle> for StyleValue {
fn from(style: TextStyle) -> Self {
Self::Direct(style)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyleSpan {
pub range: Range<usize>,
pub style: StyleValue,
}
impl StyleSpan {
pub fn new(range: Range<usize>, style: impl Into<StyleValue>) -> Self {
Self {
range,
style: style.into(),
}
}
pub fn tag(range: Range<usize>, tag: HighlightTag) -> Self {
Self {
range,
style: StyleValue::Tag(tag),
}
}
pub fn direct(range: Range<usize>, style: TextStyle) -> Self {
Self {
range,
style: StyleValue::Direct(style),
}
}
}
pub trait SyntaxHighlighter: Send + Sync + 'static {
fn highlight_line(&self, buffer: &EditorBuffer, row: usize, line_text: &str) -> Vec<StyleSpan>;
fn extract_links(
&self,
_buffer: &EditorBuffer,
_row: usize,
_line_text: &str,
) -> Vec<(Range<usize>, String)> {
Vec::new()
}
fn expand_line(
&self,
_buffer: &EditorBuffer,
_row: usize,
_concealed: &ConcealedLine,
) -> Vec<DisplayPad> {
Vec::new()
}
fn should_wrap_line(&self, _buffer: &EditorBuffer, _row: usize) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyledSegment<'a> {
pub range: Range<usize>,
pub style: Option<&'a StyleValue>,
pub is_selected: bool,
}
pub fn split_line_intervals<'a>(
line_len: usize,
spans: &'a [StyleSpan],
selection_range: Option<(usize, usize)>,
) -> Vec<StyledSegment<'a>> {
if line_len == 0 {
return Vec::new();
}
let mut boundaries = Vec::with_capacity(spans.len() * 2 + 4);
boundaries.push(0);
boundaries.push(line_len);
if let Some((s_start, s_end)) = selection_range {
boundaries.push(s_start.min(line_len));
boundaries.push(s_end.min(line_len));
}
for span in spans {
boundaries.push(span.range.start.min(line_len));
boundaries.push(span.range.end.min(line_len));
}
boundaries.sort_unstable();
boundaries.dedup();
let mut segments = Vec::with_capacity(boundaries.len());
for window in boundaries.windows(2) {
let start = window[0];
let end = window[1];
if start >= end {
continue;
}
let is_selected = if let Some((s_start, s_end)) = selection_range {
start >= s_start && end <= s_end
} else {
false
};
let style = spans
.iter()
.rev()
.find(|s| s.range.start <= start && end <= s.range.end)
.map(|s| &s.style);
segments.push(StyledSegment {
range: start..end,
style,
is_selected,
});
}
segments
}
pub fn display_width(s: &str) -> usize {
use unicode_width::UnicodeWidthStr;
s.width()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisplayPad {
pub display_at: usize,
pub fill: char,
pub len: usize,
}
#[derive(Debug, Clone)]
pub struct ConcealedLine {
pub display_text: String,
pub spans: Vec<StyleSpan>,
byte_map: Vec<usize>,
}
impl ConcealedLine {
pub fn build(line_text: &str, spans: &[StyleSpan]) -> Self {
let has_hidden = spans
.iter()
.any(|s| matches!(s.style, StyleValue::Tag(HighlightTag::Hidden)));
if !has_hidden {
let byte_map = (0..=line_text.len()).collect();
return Self {
display_text: line_text.to_string(),
spans: spans.to_vec(),
byte_map,
};
}
let mut display_text = String::with_capacity(line_text.len());
let mut byte_map = Vec::with_capacity(line_text.len() + 1);
for (byte_idx, ch) in line_text.char_indices() {
let is_hidden = spans.iter().any(|s| {
matches!(s.style, StyleValue::Tag(HighlightTag::Hidden))
&& s.range.contains(&byte_idx)
});
if !is_hidden {
let ch_len = ch.len_utf8();
for b in 0..ch_len {
byte_map.push(byte_idx + b);
}
display_text.push(ch);
}
}
byte_map.push(line_text.len());
let mut new_spans = Vec::new();
for span in spans {
if matches!(span.style, StyleValue::Tag(HighlightTag::Hidden)) {
continue;
}
let new_start = byte_map
.iter()
.position(|&src_idx| src_idx >= span.range.start)
.unwrap_or(display_text.len());
let new_end = byte_map
.iter()
.position(|&src_idx| src_idx >= span.range.end)
.unwrap_or(display_text.len());
if new_start < new_end {
new_spans.push(StyleSpan {
range: new_start..new_end,
style: span.style.clone(),
});
}
}
Self {
display_text,
spans: new_spans,
byte_map,
}
}
pub fn expanded(&self, pads: &[DisplayPad]) -> Self {
if pads.is_empty() {
return self.clone();
}
let mut sorted: Vec<DisplayPad> = pads.to_vec();
sorted.sort_by_key(|p| p.display_at);
let total_pad: usize = sorted.iter().map(|p| p.len * p.fill.len_utf8()).sum();
let mut display_text = String::with_capacity(self.display_text.len() + total_pad);
let mut byte_map = Vec::with_capacity(self.byte_map.len() + total_pad);
let mut consumed = 0;
for pad in &sorted {
if pad.len == 0 {
continue;
}
let mut at = pad.display_at.min(self.display_text.len());
while at < self.display_text.len() && !self.display_text.is_char_boundary(at) {
at += 1;
}
if at < consumed {
continue;
}
display_text.push_str(&self.display_text[consumed..at]);
byte_map.extend_from_slice(&self.byte_map[consumed..at]);
let anchor = self.byte_map[at];
let fill: String = std::iter::repeat_n(pad.fill, pad.len).collect();
display_text.push_str(&fill);
byte_map.extend(std::iter::repeat_n(anchor, fill.len()));
consumed = at;
}
display_text.push_str(&self.display_text[consumed..]);
byte_map.extend_from_slice(&self.byte_map[consumed..]);
let spans = self
.spans
.iter()
.map(|span| {
let shift = |b: usize| {
let mut out = b;
for pad in &sorted {
if pad.display_at <= b {
out += pad.len * pad.fill.len_utf8();
} else {
break;
}
}
out
};
StyleSpan {
range: shift(span.range.start)..shift(span.range.end),
style: span.style.clone(),
}
})
.collect();
Self {
display_text,
spans,
byte_map,
}
}
pub fn display_to_source(&self, display_col: usize) -> usize {
if display_col >= self.byte_map.len() {
*self.byte_map.last().unwrap_or(&0)
} else {
self.byte_map[display_col]
}
}
pub fn source_to_display(&self, source_col: usize) -> usize {
self.byte_map
.partition_point(|&src_idx| src_idx < source_col)
.min(self.display_text.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockHighlighter;
impl SyntaxHighlighter for MockHighlighter {
fn highlight_line(
&self,
_buffer: &EditorBuffer,
_row: usize,
line_text: &str,
) -> Vec<StyleSpan> {
if line_text.starts_with("# ") {
vec![StyleSpan::tag(0..line_text.len(), HighlightTag::Heading(1))]
} else {
vec![]
}
}
}
#[test]
fn test_syntax_highlighter_trait() {
let buffer = EditorBuffer::new("# Title\nBody");
let highlighter = MockHighlighter;
let spans_0 = highlighter.highlight_line(&buffer, 0, "# Title");
assert_eq!(spans_0.len(), 1);
assert_eq!(spans_0[0].range, 0..7);
assert_eq!(spans_0[0].style, StyleValue::Tag(HighlightTag::Heading(1)));
let spans_1 = highlighter.highlight_line(&buffer, 1, "Body");
assert!(spans_1.is_empty());
}
#[test]
fn test_rgba_hex_conversion() {
let red = Rgba::hex(0xFF0000);
assert_eq!(red, Rgba::new(255, 0, 0, 255));
let custom = Rgba::hex(0x123456);
assert_eq!(custom, Rgba::new(0x12, 0x34, 0x56, 255));
}
#[test]
fn test_split_line_empty() {
let segments = split_line_intervals(0, &[], None);
assert!(segments.is_empty());
}
#[test]
fn test_split_line_plain_text() {
let segments = split_line_intervals(11, &[], None);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].range, 0..11);
assert_eq!(segments[0].style, None);
assert!(!segments[0].is_selected);
}
#[test]
fn test_split_line_with_single_span() {
let spans = vec![StyleSpan::tag(0..5, HighlightTag::Keyword)];
let segments = split_line_intervals(11, &spans, None);
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].range, 0..5);
assert_eq!(
segments[0].style,
Some(&StyleValue::Tag(HighlightTag::Keyword))
);
assert!(!segments[0].is_selected);
assert_eq!(segments[1].range, 5..11);
assert_eq!(segments[1].style, None);
assert!(!segments[1].is_selected);
}
#[test]
fn test_split_line_with_overlapping_selection() {
let spans = vec![StyleSpan::tag(0..5, HighlightTag::Keyword)];
let segments = split_line_intervals(11, &spans, Some((3, 8)));
assert_eq!(segments.len(), 4);
assert_eq!(segments[0].range, 0..3);
assert_eq!(
segments[0].style,
Some(&StyleValue::Tag(HighlightTag::Keyword))
);
assert!(!segments[0].is_selected);
assert_eq!(segments[1].range, 3..5);
assert_eq!(
segments[1].style,
Some(&StyleValue::Tag(HighlightTag::Keyword))
);
assert!(segments[1].is_selected);
assert_eq!(segments[2].range, 5..8);
assert_eq!(segments[2].style, None);
assert!(segments[2].is_selected);
assert_eq!(segments[3].range, 8..11);
assert_eq!(segments[3].style, None);
assert!(!segments[3].is_selected);
}
#[test]
fn test_concealed_line_headings_align_and_collapse() {
let line1 = "# hello";
let spans1 = vec![
StyleSpan::tag(0..2, HighlightTag::Hidden),
StyleSpan::tag(2..7, HighlightTag::Heading(1)),
];
let concealed1 = ConcealedLine::build(line1, &spans1);
assert_eq!(concealed1.display_text, "hello");
assert_eq!(concealed1.spans.len(), 1);
assert_eq!(concealed1.spans[0].range, 0..5);
assert_eq!(
concealed1.spans[0].style,
StyleValue::Tag(HighlightTag::Heading(1))
);
assert_eq!(concealed1.display_to_source(0), 2);
assert_eq!(concealed1.source_to_display(2), 0);
let line2 = "## hello";
let spans2 = vec![
StyleSpan::tag(0..3, HighlightTag::Hidden),
StyleSpan::tag(3..8, HighlightTag::Heading(2)),
];
let concealed2 = ConcealedLine::build(line2, &spans2);
assert_eq!(concealed2.display_text, "hello");
assert_eq!(concealed2.spans.len(), 1);
assert_eq!(concealed2.spans[0].range, 0..5);
assert_eq!(
concealed2.spans[0].style,
StyleValue::Tag(HighlightTag::Heading(2))
);
assert_eq!(concealed2.display_to_source(0), 3);
assert_eq!(concealed2.source_to_display(3), 0);
assert_eq!(concealed1.display_text, concealed2.display_text);
let line_inline = "Hi **bold**!";
let spans_inline = vec![
StyleSpan::tag(3..5, HighlightTag::Hidden),
StyleSpan::tag(5..9, HighlightTag::Bold),
StyleSpan::tag(9..11, HighlightTag::Hidden),
];
let concealed_inline = ConcealedLine::build(line_inline, &spans_inline);
assert_eq!(concealed_inline.display_text, "Hi bold!");
assert_eq!(concealed_inline.spans.len(), 1);
assert_eq!(concealed_inline.spans[0].range, 3..7);
assert_eq!(
concealed_inline.spans[0].style,
StyleValue::Tag(HighlightTag::Bold)
);
assert_eq!(concealed_inline.display_to_source(3), 5);
assert_eq!(concealed_inline.source_to_display(5), 3);
}
#[test]
fn test_display_width_columns() {
assert_eq!(display_width(""), 0);
assert_eq!(display_width("abc |"), 5);
assert_eq!(display_width("日本"), 4);
assert_eq!(display_width("a日本b"), 6);
}
#[test]
fn test_expanded_line_pads_and_maps() {
let line = "| a | b |";
let spans = vec![
StyleSpan::tag(1..3, HighlightTag::Custom("cell")),
StyleSpan::tag(4..5, HighlightTag::Punctuation),
];
let base = ConcealedLine::build(line, &spans);
let padded = base.expanded(&[DisplayPad {
display_at: 4,
fill: ' ',
len: 2,
}]);
assert_eq!(padded.display_text, "| a | b |");
assert!(
padded
.spans
.contains(&StyleSpan::tag(1..3, HighlightTag::Custom("cell")))
);
assert!(
padded
.spans
.contains(&StyleSpan::tag(6..7, HighlightTag::Punctuation))
);
assert_eq!(padded.display_to_source(4), 4);
assert_eq!(padded.display_to_source(5), 4);
assert_eq!(padded.display_to_source(6), 4);
assert_eq!(padded.source_to_display(4), 4);
assert_eq!(padded.source_to_display(5), 7);
let same = base.expanded(&[]);
assert_eq!(same.display_text, base.display_text);
assert_eq!(same.spans, base.spans);
}
#[test]
fn test_highlighter_expansion_defaults_are_noops() {
let buffer = EditorBuffer::new("hello");
let highlighter = MockHighlighter;
let concealed = ConcealedLine::build("hello", &[]);
assert!(highlighter.expand_line(&buffer, 0, &concealed).is_empty());
assert!(highlighter.should_wrap_line(&buffer, 0));
}
}