use rich::cells::cell_len;
use rich::measure::Measurement;
#[cfg(feature = "syntax")]
use rich::Syntax;
use rich::{Console, ConsoleOptions, Justify, Overflow, Renderable, Segment, Style, Text, Theme};
#[derive(Clone, Debug)]
pub struct SourceView {
code: String,
#[cfg_attr(not(feature = "syntax"), allow(dead_code))]
language: String,
theme: Option<String>,
line_numbers: bool,
start_line: usize,
search: Option<String>,
tab_size: usize,
}
impl SourceView {
pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
Self {
code: code.into(),
language: language.into(),
theme: None,
line_numbers: true,
start_line: 1,
search: None,
tab_size: 4,
}
}
pub fn line_numbers(mut self, show: bool) -> Self {
self.line_numbers = show;
self
}
pub fn start_line(mut self, line: usize) -> Self {
self.start_line = line.max(1);
self
}
pub fn search(mut self, pattern: impl Into<String>) -> Self {
let pattern = pattern.into();
self.search = (!pattern.is_empty()).then_some(pattern);
self
}
pub fn theme(mut self, theme: impl Into<String>) -> Self {
self.theme = Some(theme.into());
self
}
pub fn tab_size(mut self, size: usize) -> Self {
self.tab_size = size;
self
}
fn source_lines(&self) -> Vec<&str> {
let code = self.code.strip_suffix('\n').unwrap_or(&self.code);
let code = code.strip_suffix('\r').unwrap_or(code);
if code.is_empty() && self.code.is_empty() {
return Vec::new();
}
code.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
.collect()
}
fn match_ranges(&self, line: &str) -> Vec<(usize, usize)> {
let Some(pattern) = &self.search else {
return Vec::new();
};
let needle = pattern.to_ascii_lowercase();
let haystack = line.to_ascii_lowercase();
let mut ranges = Vec::new();
let mut from = 0;
while let Some(found) = haystack[from..].find(&needle) {
let start = from + found;
ranges.push((start, start + needle.len()));
from = start + needle.len();
}
ranges
}
pub fn matches(&self) -> Vec<(usize, usize)> {
self.source_lines()
.iter()
.enumerate()
.filter_map(|(index, line)| {
let count = self.match_ranges(&expand_tabs(line, self.tab_size)).len();
(count > 0).then_some((self.start_line.saturating_add(index), count))
})
.collect()
}
fn gutter_width(&self, lines: usize) -> usize {
if !self.line_numbers {
return 0;
}
let last = self.start_line.saturating_add(lines.saturating_sub(1));
last.to_string().len() + 3
}
#[cfg(feature = "syntax")]
fn highlighted(&self, console: &Console) -> Text {
let mut syntax =
Syntax::new(self.code.clone(), self.language.clone()).tab_size(self.tab_size);
if let Some(theme) = &self.theme {
syntax = syntax.theme(theme.clone());
}
syntax.highlight_for(console)
}
#[cfg(not(feature = "syntax"))]
fn highlighted(&self, _console: &Console) -> Text {
let mut text = Text::new(self.code.clone());
text.expand_tabs(self.tab_size);
text
}
fn highlighted_lines(&self, console: &Console) -> Vec<Text> {
let lines = self.source_lines();
let highlighted = self.highlighted(console);
let mut texts = highlighted.split("\n", false, true);
texts.truncate(lines.len());
texts
.into_iter()
.map(|text| {
if text.plain().ends_with('\r') {
let keep = text.plain().len() - 1;
text.divide(&[keep]).into_iter().next().unwrap_or(text)
} else {
text
}
})
.collect()
}
}
fn expand_tabs(line: &str, tab_size: usize) -> String {
if !line.contains('\t') || tab_size == 0 {
return line.to_string();
}
let mut out = String::new();
let mut column = 0;
for c in line.chars() {
if c == '\t' {
let spaces = tab_size - column % tab_size;
out.push_str(&" ".repeat(spaces));
column += spaces;
} else {
out.push(c);
column += rich::cells::char_cell_width(c);
}
}
out
}
fn wrap_rows(text: &Text, theme: &Theme, width: usize) -> Vec<Vec<Segment>> {
let plain = text.plain();
if plain.contains(['\t', '\n']) || width == 0 {
return text.render_lines_wrapped(
theme,
&Style::new(),
Some(width),
Justify::Left,
Overflow::Fold,
false,
);
}
let len = plain.len();
let spans = text.spans();
let resolved: Vec<Style> = spans
.iter()
.map(|span| theme.get_style_or_null(&span.style))
.collect();
let base = {
let mut probe = text.blank_copy();
probe.append("x", None);
probe
.render(theme, &Style::new())
.into_iter()
.next()
.and_then(|segment| segment.style)
.unwrap_or_default()
};
let mut cuts = Vec::new();
cuts.push(0);
let mut chars = plain.char_indices().map(|(at, _)| at).peekable();
let mut char_index = 0;
for offset in rich::wrap::divide_line(plain, width, true) {
while char_index < offset && chars.next().is_some() {
char_index += 1;
}
cuts.push(chars.peek().copied().unwrap_or(len));
}
cuts.push(len);
let mut points: Vec<usize> = cuts.clone();
for span in spans {
points.push(span.start.min(len));
points.push(span.end.min(len));
}
points.sort_unstable();
points.dedup();
let mut by_start: Vec<usize> = (0..spans.len())
.filter(|&i| spans[i].start < spans[i].end)
.collect();
by_start.sort_by_key(|&i| spans[i].start);
let mut next = 0;
let mut active: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
let mut changed = true;
let mut style = base.clone();
let mut pieces: Vec<(usize, usize, Style)> = Vec::with_capacity(points.len());
for pair in points.windows(2) {
let (a, b) = (pair[0], pair[1]);
while next < by_start.len() && spans[by_start[next]].start <= a {
active.insert(by_start[next]);
next += 1;
changed = true;
}
let before = active.len();
active.retain(|&i| spans[i].end > a);
changed |= active.len() != before;
if changed {
style = active
.iter()
.fold(base.clone(), |style, &i| style.combine(&resolved[i]));
changed = false;
}
pieces.push((a, b, style.clone()));
}
let mut rows = Vec::with_capacity(cuts.len() - 1);
let mut piece = 0;
for pair in cuts.windows(2) {
let (start, end) = (pair[0], pair[1]);
let mut line = Vec::new();
let mut offsets = Vec::new();
while piece < pieces.len() && pieces[piece].1 <= end {
let (a, b, style) = &pieces[piece];
if *a >= start {
line.push(Segment::new(&plain[*a..*b], Some(style.clone())));
offsets.push(*a);
}
piece += 1;
}
rstrip_end(&mut line, width);
let excess = width.saturating_sub(line.iter().map(Segment::cell_length).sum());
if excess > 0 {
let padding = Segment::new(" ".repeat(excess), Some(base.clone()));
let last_char = line.last().and_then(|last| {
let offset = offsets[line.len() - 1];
last.text.char_indices().last().map(|(at, _)| offset + at)
});
let join = last_char.is_none_or(|position| {
!spans
.iter()
.any(|span| span.start <= position && position < span.end)
});
match line.last_mut() {
Some(last) if join && last.style == padding.style => {
last.text.push_str(&padding.text);
}
_ => line.push(padding),
}
}
rows.push(fold_to(line, width));
}
rows
}
fn rstrip_end(line: &mut Vec<Segment>, size: usize) {
let length: usize = line.iter().map(|s| s.text.chars().count()).sum();
let Some(excess) = length.checked_sub(size).filter(|excess| *excess > 0) else {
return;
};
let mut whitespace = 0;
for segment in line.iter().rev() {
let trimmed = segment.text.trim_end();
whitespace += segment.text[trimmed.len()..].chars().count();
if !trimmed.is_empty() {
break;
}
}
let mut remaining = whitespace.min(excess);
while remaining > 0 {
let Some(last) = line.last_mut() else { break };
let length = last.text.chars().count();
if length <= remaining {
remaining -= length;
line.pop();
} else {
let keep = last
.text
.char_indices()
.nth(length - remaining)
.map_or(last.text.len(), |(at, _)| at);
last.text.truncate(keep);
remaining = 0;
}
}
}
fn fold_to(line: Vec<Segment>, width: usize) -> Vec<Segment> {
let plain: String = line.iter().map(|s| s.text.as_str()).collect();
if cell_len(&plain) <= width {
return line;
}
let kept = rich::cells::set_cell_size(&plain, width);
let mut out = Vec::new();
let mut offset = 0;
for segment in line {
if offset >= kept.len() {
break;
}
let end = (offset + segment.text.len()).min(kept.len());
if end > offset {
out.push(Segment::new(&kept[offset..end], segment.style.clone()));
}
if offset + segment.text.len() > kept.len() {
break;
}
offset = end;
}
out
}
impl Renderable for SourceView {
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
let lines = self.source_lines();
let widest = lines
.iter()
.map(|line| cell_len(&expand_tabs(line, self.tab_size)))
.max()
.unwrap_or(0);
let gutter = self.gutter_width(lines.len());
Measurement::new((gutter + 1).min(options.max_width), gutter + widest)
.with_maximum(options.max_width)
}
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let texts = self.highlighted_lines(console);
let gutter = self.gutter_width(texts.len());
let width = options.max_width.saturating_sub(gutter).max(1);
let number_width = gutter.saturating_sub(3);
let dim = Style::parse("dim").unwrap_or_default();
let marked = Style::parse("bold yellow").unwrap_or_default();
let found = Style::parse("black on yellow").unwrap_or_default();
let rule = if console.ascii_only() { "|" } else { "│" };
let mut out = Vec::new();
for (index, mut text) in texts.into_iter().enumerate() {
let ranges = self.match_ranges(text.plain());
for &(start, end) in &ranges {
text.stylize(found.clone(), start, end);
}
let rows = wrap_rows(&text, console.theme(), width);
let rows = if rows.is_empty() {
vec![Vec::new()]
} else {
rows
};
for (row_index, row) in rows.into_iter().enumerate() {
if !out.is_empty() {
out.push(Segment::line());
}
if gutter > 0 {
let number = if row_index == 0 {
format!("{:>number_width$}", self.start_line.saturating_add(index))
} else {
" ".repeat(number_width)
};
let style = if ranges.is_empty() { &dim } else { &marked };
out.push(Segment::new(number, Some(style.clone())));
out.push(Segment::new(format!(" {rule} "), Some(dim.clone())));
}
out.extend(row);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn core_rows(text: &Text, theme: &Theme, width: usize) -> Vec<Vec<Segment>> {
text.render_lines_wrapped(
theme,
&Style::new(),
Some(width),
Justify::Left,
Overflow::Fold,
false,
)
}
fn check(text: &Text, theme: &Theme) {
for width in 1..=24 {
assert_eq!(
wrap_rows(text, theme, width),
core_rows(text, theme, width),
"{:?} at width {width}",
text.plain()
);
}
}
#[test]
fn wrap_rows_matches_core() {
let theme = Theme::default();
let plains = [
"",
" ",
"a",
"hello world",
"hello world ",
" leading and trailing ",
"averyveryverylongwordthatmustfold and more",
"宽字符的文本需要折叠 和 空格",
"emoji 👨👩👧 family 🇬🇧 flag e\u{301}",
"zero\u{200d}width\u{2060}joiners and\u{fe0f} selectors",
r#"{"k0":0,"k1":1,"k2":[true,false,null],"k3":"x y z"}"#,
"a b c d e f g h i j k l m n o p q r s t u v w x y z",
];
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut next = |n: usize| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 33) as usize % n.max(1)
};
let styles = [
"bold",
"red",
"on blue",
"italic underline",
"repr.number",
"dim",
];
for plain in plains {
let boundaries: Vec<usize> = (0..=plain.len())
.filter(|&i| plain.is_char_boundary(i))
.collect();
check(&Text::new(plain), &theme);
check(&Text::styled(plain, "on green"), &theme);
for _ in 0..12 {
let mut text = Text::new(plain);
if next(3) == 0 {
text.set_base_style("yellow");
}
for _ in 0..next(12) {
let a = boundaries[next(boundaries.len())];
let b = boundaries[next(boundaries.len())];
text.stylize(styles[next(styles.len())], a.min(b), a.max(b));
}
check(&text, &theme);
}
}
#[cfg(feature = "syntax")]
for (code, language) in [
(
r#"fn main() { let x = "hi there"; println!("{x}"); } // done"#,
"rust",
),
(
r#"{"a": [1, 2, {"b": "c d e"}], "f": null, "g": 3.5e10}"#,
"json",
),
("def f(x): return x ** 2 # squares", "python"),
] {
let text = Syntax::new(code, language).highlight();
let mut text = text.split("\n", false, true).remove(0);
let at = text.plain().find(' ').unwrap_or(0);
text.stylize("black on yellow", at, at + 3);
check(&text, &theme);
}
}
}