commit 6822dbfcf0e5978f9b58a826ae85478f1ae96ea4
parent 2836d0d271e5d899fef9750fd896116ffdd498a5
Author: William Casarin <jb55@jb55.com>
Date: Mon, 23 Feb 2026 15:27:37 -0800
diff: add syntax highlighting to file diffs
Reuse the sand-themed tokenizer from markdown_ui to syntax-highlight
diff line content. Insert/delete lines get a subtle background tint
(green/red) while the code text uses per-token syntax colors for
keywords, strings, comments, etc. Language is detected from the
file extension.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
2 files changed, 64 insertions(+), 16 deletions(-)
diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs
@@ -1,5 +1,7 @@
use super::super::file_update::{DiffLine, DiffTag, FileUpdate, FileUpdateType};
-use egui::{Color32, RichText, Ui};
+use super::markdown_ui::{tokenize_code, SandCodeTheme};
+use egui::text::LayoutJob;
+use egui::{Color32, FontId, RichText, TextFormat, Ui};
/// Colors for diff rendering
const DELETE_COLOR: Color32 = Color32::from_rgb(200, 60, 60);
@@ -7,6 +9,11 @@ const INSERT_COLOR: Color32 = Color32::from_rgb(60, 180, 60);
const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128);
const EXPAND_LINES_PER_CLICK: usize = 3;
+/// Soft background tints for syntax-highlighted diff lines.
+/// Uses premultiplied alpha: rgb(200,60,60) @ alpha=40 and rgb(60,180,60) @ alpha=40.
+const DELETE_BG: Color32 = Color32::from_rgba_premultiplied(31, 9, 9, 40);
+const INSERT_BG: Color32 = Color32::from_rgba_premultiplied(9, 28, 9, 40);
+
/// Render a file update diff view.
///
/// When `is_local` is true and the update is an Edit, expand-context
@@ -54,7 +61,13 @@ pub fn file_update_ui(update: &FileUpdate, is_local: bool, ui: &mut Ui) {
.chain(ctx.below.iter())
.collect();
- render_diff_lines(&combined, &update.update_type, ctx.start_line, ui);
+ render_diff_lines(
+ &combined,
+ &update.update_type,
+ ctx.start_line,
+ &update.file_path,
+ ui,
+ );
// "Expand below" button
if ctx.has_more_below && expand_button(ui, false) {
@@ -68,7 +81,7 @@ pub fn file_update_ui(update: &FileUpdate, is_local: bool, ui: &mut Ui) {
} else {
// No expansion available: render as before (line numbers from 1)
let refs: Vec<&DiffLine> = update.diff_lines().iter().collect();
- render_diff_lines(&refs, &update.update_type, 1, ui);
+ render_diff_lines(&refs, &update.update_type, 1, &update.file_path, ui);
}
});
});
@@ -94,18 +107,23 @@ fn expand_button(ui: &mut Ui, is_above: bool) -> bool {
.clicked()
}
-/// Render the diff lines with proper coloring.
+/// Render the diff lines with syntax highlighting.
///
/// `start_line` is the 1-based file line number of the first displayed line.
fn render_diff_lines(
lines: &[&DiffLine],
update_type: &FileUpdateType,
start_line: usize,
+ file_path: &str,
ui: &mut Ui,
) {
let mut old_line = start_line;
let mut new_line = start_line;
+ let font_id = FontId::new(12.0, egui::FontFamily::Monospace);
+ let theme = SandCodeTheme::from_visuals(ui.visuals());
+ let lang = file_extension(file_path).unwrap_or("text");
+
for diff_line in lines {
ui.horizontal(|ui| {
// Line number gutter
@@ -145,8 +163,8 @@ fn render_diff_lines(
);
}
- // Render the prefix and content
- let (prefix, color) = match diff_line.tag {
+ // Prefix character and its strong diff color
+ let (prefix, prefix_color) = match diff_line.tag {
DiffTag::Equal => (" ", ui.visuals().text_color()),
DiffTag::Delete => ("-", DELETE_COLOR),
DiffTag::Insert => ("+", INSERT_COLOR),
@@ -155,16 +173,46 @@ fn render_diff_lines(
// Remove trailing newline for display
let content = diff_line.content.trim_end_matches('\n');
- ui.label(
- RichText::new(format!("{} {}", prefix, content))
- .monospace()
- .size(12.0)
- .color(color),
+ // Background tint signals diff status
+ let line_bg = match diff_line.tag {
+ DiffTag::Equal => Color32::TRANSPARENT,
+ DiffTag::Delete => DELETE_BG,
+ DiffTag::Insert => INSERT_BG,
+ };
+
+ let mut job = LayoutJob::default();
+
+ // Prefix with diff color
+ job.append(
+ &format!("{} ", prefix),
+ 0.0,
+ TextFormat {
+ font_id: font_id.clone(),
+ color: prefix_color,
+ background: line_bg,
+ ..Default::default()
+ },
);
+
+ // Syntax-highlighted content
+ for (token, text) in tokenize_code(content, lang) {
+ let mut fmt = theme.format(token, &font_id);
+ fmt.background = line_bg;
+ job.append(text, 0.0, fmt);
+ }
+
+ ui.label(job);
});
}
}
+/// Extract file extension from a path.
+fn file_extension(path: &str) -> Option<&str> {
+ std::path::Path::new(path)
+ .extension()
+ .and_then(|ext| ext.to_str())
+}
+
/// Render the file path header (call within a horizontal layout)
pub fn file_path_header(update: &FileUpdate, ui: &mut Ui) {
let type_label = match &update.update_type {
diff --git a/crates/notedeck_dave/src/ui/markdown_ui.rs b/crates/notedeck_dave/src/ui/markdown_ui.rs
@@ -226,7 +226,7 @@ fn render_inlines(inlines: &[InlineElement], theme: &MdTheme, buffer: &str, ui:
}
/// Sand-themed syntax highlighting colors (warm, Claude-Code-esque palette)
-struct SandCodeTheme {
+pub(crate) struct SandCodeTheme {
comment: Color32,
keyword: Color32,
literal: Color32,
@@ -236,7 +236,7 @@ struct SandCodeTheme {
}
impl SandCodeTheme {
- fn from_visuals(visuals: &egui::Visuals) -> Self {
+ pub(crate) fn from_visuals(visuals: &egui::Visuals) -> Self {
if visuals.dark_mode {
Self {
comment: Color32::from_rgb(0x8A, 0x80, 0x72), // Warm gray-brown
@@ -258,7 +258,7 @@ impl SandCodeTheme {
}
}
- fn format(&self, token: SandToken, font_id: &FontId) -> TextFormat {
+ pub(crate) fn format(&self, token: SandToken, font_id: &FontId) -> TextFormat {
let color = match token {
SandToken::Comment => self.comment,
SandToken::Keyword => self.keyword,
@@ -273,7 +273,7 @@ impl SandCodeTheme {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum SandToken {
+pub(crate) enum SandToken {
Comment,
Keyword,
Literal,
@@ -384,7 +384,7 @@ impl<'a> LangConfig<'a> {
/// Tokenize source code into (token_type, text_slice) pairs.
/// Separated from rendering so it can be unit tested.
-fn tokenize_code<'a>(code: &'a str, language: &str) -> Vec<(SandToken, &'a str)> {
+pub(crate) fn tokenize_code<'a>(code: &'a str, language: &str) -> Vec<(SandToken, &'a str)> {
let Some(lang) = LangConfig::from_language(language) else {
return vec![(SandToken::Plain, code)];
};