226 lines
7.5 KiB
Rust
226 lines
7.5 KiB
Rust
//! The rows a diff viewer draws, and their colors.
|
||
|
||
use std::ops::Range;
|
||
|
||
use gpui::{App, HighlightStyle, Hsla, SharedString, StyledText};
|
||
|
||
use super::{
|
||
diff::{DiffLine, LineKind, Stretch, pair_places},
|
||
viewer::DiffLayout,
|
||
};
|
||
use crate::{
|
||
editor::{code_colors_with, stack},
|
||
forms::Syntax,
|
||
theme::Palette,
|
||
};
|
||
|
||
/// A line's place: its stretch, then its line there.
|
||
pub type Place = (usize, usize);
|
||
|
||
/// A row as the viewer draws it.
|
||
#[derive(Clone)]
|
||
pub(super) enum Shown {
|
||
/// A hunk's header and its place among the stretches.
|
||
Header(usize, SharedString),
|
||
Line(Place, DiffLine),
|
||
Pair(Option<(Place, DiffLine)>, Option<(Place, DiffLine)>),
|
||
/// A folded stretch: its place and how many lines.
|
||
Fold(usize, usize),
|
||
}
|
||
|
||
pub(super) fn shown(stretches: &[Stretch], layout: DiffLayout, open: &[usize]) -> Vec<Shown> {
|
||
let mut rows = Vec::new();
|
||
for (ix, stretch) in stretches.iter().enumerate() {
|
||
match stretch {
|
||
Stretch::Hunk { header, lines } => {
|
||
rows.push(Shown::Header(ix, header.clone()));
|
||
let at = |line: usize| ((ix, line), lines[line].clone());
|
||
match layout {
|
||
DiffLayout::Unified => rows.extend((0..lines.len()).map(|line| {
|
||
let (place, line) = at(line);
|
||
Shown::Line(place, line)
|
||
})),
|
||
DiffLayout::Split => rows.extend(
|
||
pair_places(lines)
|
||
.into_iter()
|
||
.map(|(left, right)| Shown::Pair(left.map(at), right.map(at))),
|
||
),
|
||
}
|
||
}
|
||
Stretch::Folded(lines) if open.contains(&ix) => {
|
||
rows.extend(lines.iter().enumerate().map(|(at, line)| {
|
||
let place = (ix, at);
|
||
match layout {
|
||
DiffLayout::Unified => Shown::Line(place, line.clone()),
|
||
DiffLayout::Split => {
|
||
Shown::Pair(Some((place, line.clone())), Some((place, line.clone())))
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
Stretch::Folded(lines) => rows.push(Shown::Fold(ix, lines.len())),
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// The wash behind a line, and behind its changed words.
|
||
pub(super) fn washes(kind: LineKind, colors: &Palette) -> (Option<Hsla>, Hsla) {
|
||
match kind {
|
||
LineKind::Same => (None, colors.hover),
|
||
LineKind::Added => (
|
||
Some(colors.success.opacity(0.08)),
|
||
colors.success.opacity(0.25),
|
||
),
|
||
LineKind::Removed => (
|
||
Some(colors.danger.opacity(0.08)),
|
||
colors.danger.opacity(0.25),
|
||
),
|
||
}
|
||
}
|
||
|
||
/// A line's syntax colors; none for prose.
|
||
pub(super) fn colors_of(
|
||
text: &str,
|
||
syntax: Option<&Syntax>,
|
||
cx: &App,
|
||
) -> Vec<(Range<usize>, HighlightStyle)> {
|
||
syntax.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx))
|
||
}
|
||
|
||
/// A line colored as its file reads, changed words washed.
|
||
pub(super) fn code(
|
||
line: &DiffLine,
|
||
colors: &Palette,
|
||
syntax: Option<&Syntax>,
|
||
cx: &App,
|
||
) -> StyledText {
|
||
let (_, word) = washes(line.kind, colors);
|
||
let mut styles = colors_of(&line.text, syntax, cx);
|
||
styles.extend(line.words.iter().map(|range| {
|
||
(
|
||
range.clone(),
|
||
HighlightStyle {
|
||
background_color: Some(word),
|
||
..HighlightStyle::default()
|
||
},
|
||
)
|
||
}));
|
||
styles.sort_by_key(|(range, _)| range.start);
|
||
StyledText::new(line.text.clone()).with_highlights(stack(styles))
|
||
}
|
||
|
||
/// A line as assistive tech reads it: number, change, text.
|
||
pub(super) fn spoken(line: &DiffLine) -> SharedString {
|
||
let (number, sign) = match line.kind {
|
||
LineKind::Same => (line.new, ""),
|
||
LineKind::Added => (line.new, "+ "),
|
||
LineKind::Removed => (line.old, "− "),
|
||
};
|
||
let number = number.map_or_else(String::new, |n| format!("{n} "));
|
||
format!("{number}{sign}{}", line.text).into()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#[gpui::test]
|
||
fn each_file_colors_as_its_kind_reads(cx: &mut gpui::TestAppContext) {
|
||
cx.update(|cx| {
|
||
crate::init_for_tests(cx);
|
||
let syntax = crate::theme::ActiveTheme::theme(cx).colors.syntax.clone();
|
||
let color = |text: &str, path: &str, cx: &gpui::App| {
|
||
let syntax = crate::forms::syntax_of(path);
|
||
super::colors_of(text, syntax, cx)
|
||
.into_iter()
|
||
.map(|(range, style)| (range, style.color))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
for path in [
|
||
"docs/README.md",
|
||
"notes.MARKDOWN",
|
||
"LICENSE",
|
||
"a/b.txt",
|
||
"data.csv",
|
||
] {
|
||
assert!(
|
||
color("# return 42", path, cx).is_empty(),
|
||
"{path} reads plain"
|
||
);
|
||
}
|
||
for path in [
|
||
"script.py",
|
||
"config.toml",
|
||
"run.sh",
|
||
".gitignore",
|
||
"Makefile",
|
||
"CMakeLists.txt",
|
||
"build.cmake",
|
||
] {
|
||
assert_eq!(
|
||
color("# return 42", path, cx),
|
||
[(0..11, Some(syntax.comment))],
|
||
"{path}"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
color("-- return 42", "q.sql", cx),
|
||
[(0..12, Some(syntax.comment))]
|
||
);
|
||
let rust = color("return 42 // done", "src/main.rs", cx);
|
||
assert_eq!(rust[0], (0..6, Some(syntax.keyword)), "{rust:?}");
|
||
assert_eq!(rust.last(), Some(&(10..17, Some(syntax.comment))));
|
||
// Each language's own words.
|
||
let python = color("assert ready and True", "a.py", cx);
|
||
assert_eq!(
|
||
[python[0].1, python[2].1, python[3].1],
|
||
[
|
||
Some(syntax.keyword),
|
||
Some(syntax.keyword),
|
||
Some(syntax.constant)
|
||
]
|
||
);
|
||
assert_ne!(
|
||
color("return = 42", "config.toml", cx)[0].1,
|
||
Some(syntax.keyword),
|
||
"a TOML key"
|
||
);
|
||
// Shell comments start words only.
|
||
let shell = color("echo foo#bar 42", "run.sh", cx);
|
||
assert!(
|
||
shell.iter().all(|(_, c)| *c != Some(syntax.comment)),
|
||
"{shell:?}"
|
||
);
|
||
assert_eq!(
|
||
color("echo x # note", "run.sh", cx).last(),
|
||
Some(&(7..13, Some(syntax.comment)))
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn a_viewer_reads_its_file_once_built() {
|
||
let viewer = |path: &str| {
|
||
super::super::viewer::DiffViewer::new("d", path.to_string(), "a\n", "b\n").syntax
|
||
};
|
||
assert_eq!(viewer("notes.md"), None);
|
||
assert_eq!(viewer("a.py"), crate::forms::syntax_of("a.py"));
|
||
assert_eq!(
|
||
viewer("CMakeLists.txt"),
|
||
crate::forms::syntax_of("Makefile")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_line_speaks_its_number_change_and_text() {
|
||
let stretches = super::super::diff::diff("a\nb\n", "a\nc\n", 1);
|
||
let super::Stretch::Hunk { lines, .. } = &stretches[0] else {
|
||
panic!("a hunk")
|
||
};
|
||
let said: Vec<String> = lines
|
||
.iter()
|
||
.map(|line| super::spoken(line).to_string())
|
||
.collect();
|
||
assert_eq!(said, ["1 a", "2 − b", "2 + c"]);
|
||
}
|
||
}
|