use std::rc::Rc;
use crate::base::{Point, Rgba};
use crate::layout::Style as LayoutStyle;
use crate::render::rich::{RichLine, Span};
use crate::render::{Attrs, Style};
use crate::text::{
CLikeLexer, DataKind, DiffKind, DiffLexer, Highlighter, JsonLexer, TokenKind, YamlLexer,
};
use crate::theme::TokenSet;
use crate::ui::Element;
pub fn code_token_color(kind: TokenKind, t: &TokenSet) -> Rgba {
match kind {
TokenKind::Keyword => t.syntax_keyword,
TokenKind::String => t.syntax_string,
TokenKind::Number => t.syntax_number,
TokenKind::Comment => t.syntax_comment,
TokenKind::Punct => t.syntax_punct,
TokenKind::Ident => t.text,
}
}
pub fn diff_token_color(kind: DiffKind, t: &TokenSet) -> Rgba {
match kind {
DiffKind::Added => t.ok,
DiffKind::Removed => t.error,
DiffKind::HunkHeader => t.info,
DiffKind::FileHeader => t.text,
DiffKind::Meta => t.text_muted,
DiffKind::Context => t.text,
}
}
pub fn data_token_color(kind: DataKind, t: &TokenSet) -> Rgba {
match kind {
DataKind::Key => t.syntax_func,
DataKind::String => t.syntax_string,
DataKind::Number => t.syntax_number,
DataKind::Literal => t.syntax_keyword,
DataKind::Comment => t.syntax_comment,
DataKind::Punct => t.syntax_punct,
DataKind::Tag => t.syntax_type,
}
}
pub(crate) fn diff_rich_line(line: &str, lexer: &DiffLexer, base: Style, t: &TokenSet) -> RichLine {
let mut out = RichLine::new();
let mut cursor = 0;
for (range, kind) in lexer.spans(line) {
if range.start > cursor {
out.push(Span::new(&line[cursor..range.start], base));
}
let mut style = Style::new().fg(diff_token_color(kind, t));
if kind == DiffKind::FileHeader {
style = style.attrs(Attrs::BOLD);
}
out.push(Span::new(&line[range.clone()], style));
cursor = range.end;
}
if cursor < line.len() {
out.push(Span::new(&line[cursor..], base));
}
out
}
pub(crate) fn data_rich_line(
line: &str,
spans: Vec<(std::ops::Range<usize>, DataKind)>,
base: Style,
t: &TokenSet,
) -> RichLine {
let mut out = RichLine::new();
let mut cursor = 0;
for (range, kind) in spans {
if range.start > cursor {
out.push(Span::new(&line[cursor..range.start], base));
}
out.push(Span::new(
&line[range.clone()],
Style::new().fg(data_token_color(kind, t)),
));
cursor = range.end;
}
if cursor < line.len() {
out.push(Span::new(&line[cursor..], base));
}
out
}
enum Tinter {
Syntax(Rc<dyn Highlighter>),
Diff(DiffLexer),
Json(JsonLexer),
Yaml(YamlLexer),
}
pub struct CodeView {
source: String,
tinter: Tinter,
line_numbers: bool,
scroll_offset: i32,
layout: Option<LayoutStyle>,
}
impl CodeView {
pub fn new(source: impl Into<String>) -> CodeView {
CodeView {
source: source.into(),
tinter: Tinter::Syntax(Rc::new(CLikeLexer::default())),
line_numbers: true,
scroll_offset: 0,
layout: None,
}
}
pub fn lexer(mut self, lexer: impl Highlighter + 'static) -> CodeView {
self.tinter = Tinter::Syntax(Rc::new(lexer));
self
}
pub fn lang(mut self, label: &str) -> CodeView {
let first = label.split_whitespace().next().unwrap_or("");
self.tinter = if DiffLexer::matches_lang(first) {
Tinter::Diff(DiffLexer::new())
} else if JsonLexer::matches_lang(first) {
Tinter::Json(JsonLexer::new())
} else if YamlLexer::matches_lang(first) {
Tinter::Yaml(YamlLexer::new())
} else if first.eq_ignore_ascii_case("c") {
Tinter::Syntax(Rc::new(CLikeLexer::c()))
} else {
Tinter::Syntax(Rc::new(CLikeLexer::default()))
};
self
}
pub fn line_numbers(mut self, on: bool) -> CodeView {
self.line_numbers = on;
self
}
pub fn scroll_offset(mut self, lines: i32) -> CodeView {
self.scroll_offset = lines.max(0);
self
}
pub fn layout(mut self, layout: LayoutStyle) -> CodeView {
self.layout = Some(layout);
self
}
pub fn line_count(source: &str) -> usize {
source.lines().count()
}
pub fn view(self, cx: crate::reactive::Scope) -> crate::ui::View {
let t = crate::widgets::theme_tokens(cx);
self.element(&t).build()
}
pub fn element(self, t: &TokenSet) -> Element {
let tokens = *t;
let ground = t.surface_raised;
let gutter_fg = t.text_faint;
let rule_fg = t.border;
let base = Style::new().fg(t.text);
let tinter = self.tinter;
let line_numbers = self.line_numbers;
let offset = self.scroll_offset as usize;
let lines: Vec<String> = self.source.lines().map(|s| s.to_string()).collect();
let layout = self.layout.unwrap_or_else(|| {
LayoutStyle::default()
.grow(1.0)
.basis(crate::layout::Dimension::Cells(0))
});
let total = lines.len();
let natural_w = {
let gutter = if line_numbers { digits(total) + 3 } else { 0 };
let longest = lines
.iter()
.map(|l| l.chars().count() as i32)
.max()
.unwrap_or(0);
gutter + longest
};
Element::new()
.style(layout)
.measure(move |avail| {
if avail.w <= 0 {
return crate::base::Size::ZERO;
}
crate::base::Size::new(natural_w.min(avail.w), total as i32)
})
.draw(move |canvas, rect| {
if rect.w <= 0 || rect.h <= 0 {
return;
}
canvas.fill(rect, ' ', tokens.text, ground);
let total = lines.len();
let gutter_w = if line_numbers {
(digits(total) + 1).min(rect.w - 2)
} else {
0
};
let offset = offset.min(total.saturating_sub(1));
for (row, line) in lines.iter().skip(offset).enumerate() {
let y = rect.y + row as i32;
if y >= rect.bottom() {
break;
}
let mut x = rect.x;
if gutter_w > 0 {
let num = format!("{:>w$} ", offset + row + 1, w = (gutter_w - 1) as usize);
x += canvas.print(Point::new(x, y), &num, gutter_fg, ground);
canvas.put(Point::new(x, y), '│', rule_fg, ground);
x += 2;
}
let rich = match &tinter {
Tinter::Syntax(lexer) => {
RichLine::from_highlighted(line, &**lexer, base, |kind| {
Style::new().fg(code_token_color(kind, &tokens))
})
}
Tinter::Diff(lexer) => diff_rich_line(line, lexer, base, &tokens),
Tinter::Json(lexer) => {
data_rich_line(line, lexer.spans(line), base, &tokens)
}
Tinter::Yaml(lexer) => {
data_rich_line(line, lexer.spans(line), base, &tokens)
}
};
for span in &rich.spans {
x += crate::widgets::richtext::print_span_clipped(
canvas,
x,
y,
rect.right(),
&span.text,
&span.style,
);
if x >= rect.right() {
break;
}
}
}
})
}
}
fn digits(n: usize) -> i32 {
n.max(1).ilog10() as i32 + 1
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Size;
use crate::theme::default_theme;
use crate::widgets::test_util::{draw_into, row};
const SRC: &str = "fn main() {\n // greet\n print(\"hi\", 42);\n}";
fn cell_of(row: &str, needle: &str) -> i32 {
let byte = row.find(needle).unwrap();
row[..byte].chars().count() as i32
}
#[test]
fn token_kinds_map_to_syntax_inks() {
let t = default_theme().tokens;
assert_eq!(code_token_color(TokenKind::Keyword, &t), t.syntax_keyword);
assert_eq!(code_token_color(TokenKind::String, &t), t.syntax_string);
assert_eq!(code_token_color(TokenKind::Number, &t), t.syntax_number);
assert_eq!(code_token_color(TokenKind::Comment, &t), t.syntax_comment);
assert_eq!(code_token_color(TokenKind::Punct, &t), t.syntax_punct);
assert_eq!(code_token_color(TokenKind::Ident, &t), t.text);
}
#[test]
fn renders_highlighted_cells_on_the_code_ground() {
let t = default_theme().tokens;
let c = draw_into(CodeView::new(SRC).element(&t), Size::new(28, 4));
assert!(row(&c, 0).starts_with("1 │ fn main"), "{:?}", row(&c, 0));
assert_eq!(c.cell(Point::new(0, 0)).unwrap().1, t.text_faint);
assert_eq!(c.cell(Point::new(2, 0)).unwrap().1, t.border);
let (ch, fg, bg) = c.cell(Point::new(4, 0)).unwrap();
assert_eq!(ch, 'f');
assert_eq!(fg, t.syntax_keyword);
assert_eq!(bg, t.surface_raised);
let comment_x = cell_of(&row(&c, 1), "//");
assert_eq!(
c.cell(Point::new(comment_x, 1)).unwrap().1,
t.syntax_comment
);
let quote_x = cell_of(&row(&c, 2), "\"");
assert_eq!(c.cell(Point::new(quote_x, 2)).unwrap().1, t.syntax_string);
let num_x = cell_of(&row(&c, 2), "42");
assert_eq!(c.cell(Point::new(num_x, 2)).unwrap().1, t.syntax_number);
}
#[test]
fn scroll_clamps_and_numbers_track() {
let t = default_theme().tokens;
let c = draw_into(
CodeView::new(SRC).scroll_offset(2).element(&t),
Size::new(24, 2),
);
assert!(row(&c, 0).starts_with("3 │"), "{:?}", row(&c, 0));
let c = draw_into(
CodeView::new(SRC).scroll_offset(999).element(&t),
Size::new(24, 2),
);
assert!(row(&c, 0).starts_with("4 │"), "{:?}", row(&c, 0));
assert_eq!(CodeView::line_count(SRC), 4);
}
const DIFF: &str = "diff --git a/x.rs b/x.rs\n@@ -1,2 +1,2 @@ fn main() {\n-let a = 0;\n+let a = 1;\n context\n\\ No newline at end of file";
#[test]
fn diff_kinds_map_to_semantic_inks() {
let t = default_theme().tokens;
assert_eq!(diff_token_color(DiffKind::Added, &t), t.ok);
assert_eq!(diff_token_color(DiffKind::Removed, &t), t.error);
assert_eq!(diff_token_color(DiffKind::HunkHeader, &t), t.info);
assert_eq!(diff_token_color(DiffKind::FileHeader, &t), t.text);
assert_eq!(diff_token_color(DiffKind::Meta, &t), t.text_muted);
assert_eq!(diff_token_color(DiffKind::Context, &t), t.text);
}
#[test]
fn lang_diff_renders_added_removed_inks_through_the_real_draw_path() {
let t = default_theme().tokens;
let c = draw_into(
CodeView::new(DIFF).lang("diff").element(&t),
Size::new(36, 6),
);
let (ch, fg, bg) = c.cell(Point::new(4, 0)).unwrap();
assert_eq!(ch, 'd');
assert_eq!(fg, t.text_muted);
assert_eq!(bg, t.surface_raised);
let hunk_x = cell_of(&row(&c, 1), "@@");
assert_eq!(c.cell(Point::new(hunk_x, 1)).unwrap().1, t.info);
let fn_x = cell_of(&row(&c, 1), "fn main");
assert_eq!(c.cell(Point::new(fn_x, 1)).unwrap().1, t.text);
let minus_x = cell_of(&row(&c, 2), "-let");
assert_eq!(c.cell(Point::new(minus_x, 2)).unwrap().1, t.error);
assert_eq!(c.cell(Point::new(minus_x + 6, 2)).unwrap().1, t.error);
let plus_x = cell_of(&row(&c, 3), "+let");
assert_eq!(c.cell(Point::new(plus_x, 3)).unwrap().1, t.ok);
let ctx_x = cell_of(&row(&c, 4), "context");
assert_eq!(c.cell(Point::new(ctx_x, 4)).unwrap().1, t.text);
let nn_x = cell_of(&row(&c, 5), "\\ No newline");
assert_eq!(c.cell(Point::new(nn_x, 5)).unwrap().1, t.text_muted);
}
#[test]
fn data_kinds_map_to_syntax_inks() {
let t = default_theme().tokens;
assert_eq!(data_token_color(DataKind::Key, &t), t.syntax_func);
assert_eq!(data_token_color(DataKind::String, &t), t.syntax_string);
assert_eq!(data_token_color(DataKind::Number, &t), t.syntax_number);
assert_eq!(data_token_color(DataKind::Literal, &t), t.syntax_keyword);
assert_eq!(data_token_color(DataKind::Comment, &t), t.syntax_comment);
assert_eq!(data_token_color(DataKind::Punct, &t), t.syntax_punct);
assert_eq!(data_token_color(DataKind::Tag, &t), t.syntax_type);
}
#[test]
fn lang_json_and_yaml_render_key_value_inks_through_the_draw_path() {
let t = default_theme().tokens;
let json = "{\n \"name\": \"Ada\",\n \"age\": 36\n}";
let c = draw_into(
CodeView::new(json)
.lang("json")
.line_numbers(false)
.element(&t),
Size::new(24, 4),
);
let kx = cell_of(&row(&c, 1), "\"name\"");
let (_, fg, bg) = c.cell(Point::new(kx, 1)).unwrap();
assert_eq!(fg, t.syntax_func, "json keys ride the member ink");
assert_eq!(bg, t.surface_raised);
let vx = cell_of(&row(&c, 1), "\"Ada\"");
assert_eq!(c.cell(Point::new(vx, 1)).unwrap().1, t.syntax_string);
let nx = cell_of(&row(&c, 2), "36");
assert_eq!(c.cell(Point::new(nx, 2)).unwrap().1, t.syntax_number);
let yaml = "service: web # edge\nready: true\ntag: !!str x";
let c = draw_into(
CodeView::new(yaml)
.lang("yaml")
.line_numbers(false)
.element(&t),
Size::new(26, 3),
);
let kx = cell_of(&row(&c, 0), "service");
assert_eq!(c.cell(Point::new(kx, 0)).unwrap().1, t.syntax_func);
let cx = cell_of(&row(&c, 0), "# edge");
assert_eq!(c.cell(Point::new(cx, 0)).unwrap().1, t.syntax_comment);
let lx = cell_of(&row(&c, 1), "true");
assert_eq!(c.cell(Point::new(lx, 1)).unwrap().1, t.syntax_keyword);
let tx = cell_of(&row(&c, 2), "!!str");
assert_eq!(c.cell(Point::new(tx, 2)).unwrap().1, t.syntax_type);
}
#[test]
fn lang_labels_route_and_unknown_labels_keep_todays_rendering() {
let t = default_theme().tokens;
for label in ["rust", "elixir"] {
let c = draw_into(CodeView::new(SRC).lang(label).element(&t), Size::new(28, 4));
assert_eq!(c.cell(Point::new(4, 0)).unwrap().1, t.syntax_keyword);
}
let c = draw_into(
CodeView::new("+x")
.lang("diff filename=a.patch")
.line_numbers(false)
.element(&t),
Size::new(8, 1),
);
assert_eq!(c.cell(Point::new(0, 0)).unwrap().1, t.ok);
}
#[test]
fn diff_inks_clear_contrast_on_the_code_ground_in_every_theme() {
use crate::theme::{contrast_ratio, themes};
for theme in themes() {
let t = &theme.tokens;
for (name, ink) in [
("ok", t.ok),
("error", t.error),
("info", t.info),
("text_muted", t.text_muted),
] {
let ratio = contrast_ratio(ink, t.surface_raised);
assert!(
ratio >= 3.0,
"{}: diff ink `{name}` measures {ratio:.2}:1 on surface_raised",
theme.id
);
}
}
}
#[test]
fn no_gutter_mode_and_tiny_rects() {
let t = default_theme().tokens;
let c = draw_into(
CodeView::new(SRC).line_numbers(false).element(&t),
Size::new(16, 2),
);
assert!(row(&c, 0).starts_with("fn main"), "{:?}", row(&c, 0));
for size in [Size::new(0, 0), Size::new(2, 1), Size::new(5, 1)] {
let _ = draw_into(CodeView::new(SRC).element(&t), size);
}
}
}