notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_dave/src/ui/diff.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_dave/src/ui/markdown_ui.rs | 10+++++-----
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)]; };