| 1 | //! Diff: a diff the caller has already parsed, painted so its meaning never |
| 2 | //! depends on color. |
| 3 | //! |
| 4 | //! The kit does not diff and does not highlight. The caller hands in |
| 5 | //! [`DiffLine`]s (or [`parse_unified`] reads unified-diff text into them), |
| 6 | //! optionally with intraline [`DiffLine::emphasis`] ranges its own word-diff |
| 7 | //! produced and a [`DiffHighlight`] hook from its own highlighter. What the |
| 8 | //! kit owns is the paint: |
| 9 | //! |
| 10 | //! - every changed line carries `+` or `−` (`-` in ASCII) in its own |
| 11 | //! column, so nothing depends on a ground; |
| 12 | //! - where grounds paint (truecolor, 256 colors) the two tint roles sit |
| 13 | //! behind the whole row, under `Foreground`, `Muted` and the line's own hue |
| 14 | //! only (the inks the tints are audited for); at 16 colors and `NO_COLOR` |
| 15 | //! the tint is dropped and emphasis falls back to bold and underline; |
| 16 | //! - long lines are cut with a continuation mark or wrapped (a setting); |
| 17 | //! - tabs expand, and control and bidi characters are removed by |
| 18 | //! [`text::display_safe`] before anything is measured. |
| 19 | //! |
| 20 | //! Modelled on the engine's `crates/tui/src/tui/diff_render.rs` |
| 21 | //! (`Hmbown/CodeWhale` `58b1dd3dd`). |
| 22 | |
| 23 | use std::{borrow::Cow, ops::Range}; |
| 24 | |
| 25 | use ratatui::{ |
| 26 | buffer::Buffer, |
| 27 | layout::Rect, |
| 28 | style::{Modifier, Style}, |
| 29 | text::{Line, Span}, |
| 30 | widgets::Widget, |
| 31 | }; |
| 32 | use unicode_segmentation::UnicodeSegmentation; |
| 33 | |
| 34 | use crate::{Paint, Role, Theme, glyphs, text}; |
| 35 | |
| 36 | /// What a [`DiffLine`] is. |
| 37 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 38 | pub enum DiffKind { |
| 39 | Context, |
| 40 | Added, |
| 41 | Removed, |
| 42 | /// `@@ -12,7 +12,8 @@ fn main()`. |
| 43 | HunkHeader, |
| 44 | /// `diff --git`, `index`, `---`, `+++`, `rename from`, `Binary files`. |
| 45 | FileHeader, |
| 46 | /// `\ No newline at end of file`: about the line above it. |
| 47 | Note, |
| 48 | } |
| 49 | |
| 50 | /// One line of a parsed diff. `text` excludes the leading `+`, `-` or space; |
| 51 | /// `emphasis` holds byte ranges into `text` the caller's word-diff marked as |
| 52 | /// the part that changed. |
| 53 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 54 | pub struct DiffLine<'a> { |
| 55 | pub kind: DiffKind, |
| 56 | pub old_no: Option<u32>, |
| 57 | pub new_no: Option<u32>, |
| 58 | pub text: &'a str, |
| 59 | pub emphasis: &'a [Range<usize>], |
| 60 | } |
| 61 | |
| 62 | impl<'a> DiffLine<'a> { |
| 63 | #[must_use] |
| 64 | pub const fn new(kind: DiffKind, text: &'a str) -> Self { |
| 65 | Self { |
| 66 | kind, |
| 67 | old_no: None, |
| 68 | new_no: None, |
| 69 | text, |
| 70 | emphasis: &[], |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | #[must_use] |
| 75 | pub const fn context(old_no: u32, new_no: u32, text: &'a str) -> Self { |
| 76 | Self { |
| 77 | old_no: Some(old_no), |
| 78 | new_no: Some(new_no), |
| 79 | ..Self::new(DiffKind::Context, text) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | #[must_use] |
| 84 | pub const fn added(new_no: u32, text: &'a str) -> Self { |
| 85 | Self { |
| 86 | new_no: Some(new_no), |
| 87 | ..Self::new(DiffKind::Added, text) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | #[must_use] |
| 92 | pub const fn removed(old_no: u32, text: &'a str) -> Self { |
| 93 | Self { |
| 94 | old_no: Some(old_no), |
| 95 | ..Self::new(DiffKind::Removed, text) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | #[must_use] |
| 100 | pub const fn hunk(text: &'a str) -> Self { |
| 101 | Self::new(DiffKind::HunkHeader, text) |
| 102 | } |
| 103 | |
| 104 | #[must_use] |
| 105 | pub const fn file(text: &'a str) -> Self { |
| 106 | Self::new(DiffKind::FileHeader, text) |
| 107 | } |
| 108 | |
| 109 | /// The byte ranges of `text` to emphasise. |
| 110 | #[must_use] |
| 111 | pub const fn emphasis(mut self, emphasis: &'a [Range<usize>]) -> Self { |
| 112 | self.emphasis = emphasis; |
| 113 | self |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// `(added, removed)` line counts, for a summary beside the diff. |
| 118 | #[must_use] |
| 119 | pub fn diff_counts(lines: &[DiffLine<'_>]) -> (usize, usize) { |
| 120 | let count = |kind| lines.iter().filter(|l| l.kind == kind).count(); |
| 121 | (count(DiffKind::Added), count(DiffKind::Removed)) |
| 122 | } |
| 123 | |
| 124 | // --------------------------------------------------------------------------- |
| 125 | // Parsing |
| 126 | // --------------------------------------------------------------------------- |
| 127 | |
| 128 | /// The numbers of a hunk header: `(old_start, old_len, new_start, new_len)`. |
| 129 | fn hunk_numbers(header: &str) -> Option<(u32, u32, u32, u32)> { |
| 130 | let mut parts = header.strip_prefix("@@")?.split_whitespace(); |
| 131 | let span = |part: &str, sign: char| -> Option<(u32, u32)> { |
| 132 | let body = part.strip_prefix(sign)?; |
| 133 | let (start, len) = match body.split_once(',') { |
| 134 | Some((start, len)) => (start, len), |
| 135 | None => (body, "1"), |
| 136 | }; |
| 137 | Some((start.parse().ok()?, len.parse().ok()?)) |
| 138 | }; |
| 139 | let (old_start, old_len) = span(parts.next()?, '-')?; |
| 140 | let (new_start, new_len) = span(parts.next()?, '+')?; |
| 141 | Some((old_start, old_len, new_start, new_len)) |
| 142 | } |
| 143 | |
| 144 | /// Read unified-diff text (`git diff`, `diff -u`, a patch file) into lines. |
| 145 | /// |
| 146 | /// Inside a hunk the header's counts decide what a line is, so a removed |
| 147 | /// line that reads `-- note` (`--- note` in the diff) is a removed line, not |
| 148 | /// a file header. Renames, copies, mode changes and binary notices are |
| 149 | /// [`DiffKind::FileHeader`]; `\ No newline at end of file` is |
| 150 | /// [`DiffKind::Note`]. CRLF line endings are dropped, blank lines between |
| 151 | /// files are skipped, and the result borrows from `diff`. |
| 152 | #[must_use] |
| 153 | pub fn parse_unified(diff: &str) -> Vec<DiffLine<'_>> { |
| 154 | let mut out = Vec::new(); |
| 155 | let (mut old_left, mut new_left) = (0u32, 0u32); |
| 156 | let (mut old_no, mut new_no): (Option<u32>, Option<u32>) = (None, None); |
| 157 | for raw in diff.lines() { |
| 158 | if raw.starts_with('\\') { |
| 159 | out.push(DiffLine::new(DiffKind::Note, raw)); |
| 160 | continue; |
| 161 | } |
| 162 | let in_hunk = old_left > 0 || new_left > 0; |
| 163 | let header = !in_hunk && (raw.starts_with("--- ") || raw.starts_with("+++ ")); |
| 164 | if !header && !raw.starts_with("@@") && !raw.starts_with("diff ") { |
| 165 | let body = match raw.as_bytes().first() { |
| 166 | Some(b'+') => Some((DiffKind::Added, &raw[1..])), |
| 167 | Some(b'-') => Some((DiffKind::Removed, &raw[1..])), |
| 168 | Some(b' ') => Some((DiffKind::Context, &raw[1..])), |
| 169 | // Editors strip the trailing space of an empty context line. |
| 170 | None if in_hunk => Some((DiffKind::Context, "")), |
| 171 | _ => None, |
| 172 | }; |
| 173 | if let Some((kind, text)) = body { |
| 174 | let (old, new) = match kind { |
| 175 | DiffKind::Added => (None, new_no), |
| 176 | DiffKind::Removed => (old_no, None), |
| 177 | _ => (old_no, new_no), |
| 178 | }; |
| 179 | if kind != DiffKind::Added { |
| 180 | old_left = old_left.saturating_sub(1); |
| 181 | old_no = old_no.map(|n| n.saturating_add(1)); |
| 182 | } |
| 183 | if kind != DiffKind::Removed { |
| 184 | new_left = new_left.saturating_sub(1); |
| 185 | new_no = new_no.map(|n| n.saturating_add(1)); |
| 186 | } |
| 187 | out.push(DiffLine { |
| 188 | kind, |
| 189 | old_no: old, |
| 190 | new_no: new, |
| 191 | text, |
| 192 | emphasis: &[], |
| 193 | }); |
| 194 | continue; |
| 195 | } |
| 196 | } |
| 197 | if raw.starts_with("@@") { |
| 198 | match hunk_numbers(raw) { |
| 199 | Some((old_start, old_len, new_start, new_len)) => { |
| 200 | (old_left, new_left) = (old_len, new_len); |
| 201 | (old_no, new_no) = (Some(old_start), Some(new_start)); |
| 202 | } |
| 203 | None => { |
| 204 | (old_left, new_left) = (0, 0); |
| 205 | (old_no, new_no) = (None, None); |
| 206 | } |
| 207 | } |
| 208 | out.push(DiffLine::hunk(raw)); |
| 209 | } else if raw.is_empty() { |
| 210 | // Spacing between files in a patch series. |
| 211 | } else { |
| 212 | if raw.starts_with("diff ") { |
| 213 | (old_left, new_left) = (0, 0); |
| 214 | (old_no, new_no) = (None, None); |
| 215 | } |
| 216 | out.push(DiffLine::file(raw)); |
| 217 | } |
| 218 | } |
| 219 | out |
| 220 | } |
| 221 | |
| 222 | // --------------------------------------------------------------------------- |
| 223 | // Settings |
| 224 | // --------------------------------------------------------------------------- |
| 225 | |
| 226 | /// What a line longer than the room does. |
| 227 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] |
| 228 | pub enum DiffWrap { |
| 229 | /// Cut it and end the row with a continuation mark (`…`). |
| 230 | #[default] |
| 231 | Truncate, |
| 232 | /// Continue it on the next row, under the same gutter. |
| 233 | Wrap, |
| 234 | } |
| 235 | |
| 236 | /// Which line-number columns to draw. |
| 237 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 238 | pub enum DiffGutter { |
| 239 | /// Old and new numbers side by side. |
| 240 | Both, |
| 241 | /// One number: the old for a removed line, the new for the rest. |
| 242 | Single, |
| 243 | /// No numbers; the sign column stays. |
| 244 | Off, |
| 245 | } |
| 246 | |
| 247 | /// A caller's syntax highlighter for one line: byte ranges of the line's |
| 248 | /// text and the [`Role`] to ink each with. On changed lines under a tint only |
| 249 | /// `Foreground` and `Muted` are honoured, the inks the tints are audited |
| 250 | /// for; everything else falls back to `Foreground`. |
| 251 | pub type DiffHighlight = fn(&str) -> Vec<(Range<usize>, Role)>; |
| 252 | |
| 253 | /// Words a diff prints. `Default` is English. |
| 254 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 255 | pub struct DiffWords { |
| 256 | /// `+3 more`: lines that did not fit, counted exactly. |
| 257 | pub more: Cow<'static, str>, |
| 258 | } |
| 259 | |
| 260 | impl Default for DiffWords { |
| 261 | fn default() -> Self { |
| 262 | Self { |
| 263 | more: Cow::Borrowed("more"), |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /// A parsed diff, ready to paint. |
| 269 | #[derive(Clone, Debug)] |
| 270 | pub struct Diff<'a> { |
| 271 | pub lines: Vec<DiffLine<'a>>, |
| 272 | pub wrap: DiffWrap, |
| 273 | /// Spaces per tab stop. |
| 274 | pub tab_width: u8, |
| 275 | /// `None` picks by width: both numbers from 56 columns, one below. A |
| 276 | /// gutter that does not fit gives way (both numbers, one, none) so the |
| 277 | /// `+`/`-` sign always shows, down to a single column. |
| 278 | pub gutter: Option<DiffGutter>, |
| 279 | pub highlight: Option<DiffHighlight>, |
| 280 | /// The first line to show: the host's scroll position. |
| 281 | pub scroll: usize, |
| 282 | pub words: DiffWords, |
| 283 | } |
| 284 | |
| 285 | impl<'a> Diff<'a> { |
| 286 | #[must_use] |
| 287 | pub fn new(lines: Vec<DiffLine<'a>>) -> Self { |
| 288 | Self { |
| 289 | lines, |
| 290 | wrap: DiffWrap::default(), |
| 291 | tab_width: 4, |
| 292 | gutter: None, |
| 293 | highlight: None, |
| 294 | scroll: 0, |
| 295 | words: DiffWords::default(), |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | #[must_use] |
| 300 | pub fn wrap(mut self, wrap: DiffWrap) -> Self { |
| 301 | self.wrap = wrap; |
| 302 | self |
| 303 | } |
| 304 | |
| 305 | #[must_use] |
| 306 | pub fn tab_width(mut self, tab_width: u8) -> Self { |
| 307 | self.tab_width = tab_width.max(1); |
| 308 | self |
| 309 | } |
| 310 | |
| 311 | #[must_use] |
| 312 | pub fn gutter(mut self, gutter: DiffGutter) -> Self { |
| 313 | self.gutter = Some(gutter); |
| 314 | self |
| 315 | } |
| 316 | |
| 317 | #[must_use] |
| 318 | pub fn highlight(mut self, highlight: DiffHighlight) -> Self { |
| 319 | self.highlight = Some(highlight); |
| 320 | self |
| 321 | } |
| 322 | |
| 323 | #[must_use] |
| 324 | pub fn scroll(mut self, scroll: usize) -> Self { |
| 325 | self.scroll = scroll; |
| 326 | self |
| 327 | } |
| 328 | |
| 329 | #[must_use] |
| 330 | pub fn words(mut self, words: DiffWords) -> Self { |
| 331 | self.words = words; |
| 332 | self |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | // --------------------------------------------------------------------------- |
| 337 | // Layout |
| 338 | // --------------------------------------------------------------------------- |
| 339 | |
| 340 | /// One displayed grapheme: tabs already expanded, controls already gone. |
| 341 | struct Piece { |
| 342 | text: String, |
| 343 | width: usize, |
| 344 | /// Byte offset of the source grapheme in the line's `text`. |
| 345 | at: usize, |
| 346 | } |
| 347 | |
| 348 | /// `text` as pieces. Offsets stay those of the original string, so a |
| 349 | /// caller's emphasis and highlight ranges still land on the right cells |
| 350 | /// after control characters are removed and tabs are widened. |
| 351 | fn pieces(text: &str, tab_width: usize) -> Vec<Piece> { |
| 352 | let mut out = Vec::new(); |
| 353 | let mut col = 0; |
| 354 | for (at, g) in text.grapheme_indices(true) { |
| 355 | if g == "\t" { |
| 356 | let n = tab_width - col % tab_width; |
| 357 | out.extend((0..n).map(|_| Piece { |
| 358 | text: " ".to_string(), |
| 359 | width: 1, |
| 360 | at, |
| 361 | })); |
| 362 | col += n; |
| 363 | continue; |
| 364 | } |
| 365 | let safe = text::display_safe(g); |
| 366 | if safe.is_empty() { |
| 367 | continue; |
| 368 | } |
| 369 | let width = text::width(&safe); |
| 370 | col += width; |
| 371 | out.push(Piece { |
| 372 | text: safe.into_owned(), |
| 373 | width, |
| 374 | at, |
| 375 | }); |
| 376 | } |
| 377 | out |
| 378 | } |
| 379 | |
| 380 | /// The cut points of `pieces` for rows `avail` wide: `(start, end, cut)`, |
| 381 | /// where `cut` means the row ends in the continuation mark. |
| 382 | fn split_rows( |
| 383 | pieces: &[Piece], |
| 384 | avail: usize, |
| 385 | wrap: DiffWrap, |
| 386 | mark_w: usize, |
| 387 | ) -> Vec<(usize, usize, bool)> { |
| 388 | let avail = avail.max(1); |
| 389 | let total: usize = pieces.iter().map(|p| p.width).sum(); |
| 390 | if total <= avail { |
| 391 | return vec![(0, pieces.len(), false)]; |
| 392 | } |
| 393 | let take = |from: usize, budget: usize| -> usize { |
| 394 | let (mut used, mut end) = (0, from); |
| 395 | while end < pieces.len() && (used + pieces[end].width <= budget || end == from) { |
| 396 | used += pieces[end].width; |
| 397 | end += 1; |
| 398 | } |
| 399 | end |
| 400 | }; |
| 401 | match wrap { |
| 402 | DiffWrap::Truncate => { |
| 403 | let end = take(0, avail.saturating_sub(mark_w)); |
| 404 | vec![(0, end, true)] |
| 405 | } |
| 406 | DiffWrap::Wrap => { |
| 407 | let mut rows = Vec::new(); |
| 408 | let mut start = 0; |
| 409 | while start < pieces.len() { |
| 410 | let end = take(start, avail); |
| 411 | rows.push((start, end, false)); |
| 412 | start = end; |
| 413 | } |
| 414 | rows |
| 415 | } |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | /// A row ready to paint. |
| 420 | struct Row { |
| 421 | /// Index into the diff's lines. |
| 422 | line: usize, |
| 423 | spans: Vec<Span<'static>>, |
| 424 | tint: Style, |
| 425 | } |
| 426 | |
| 427 | fn digits(n: u32) -> usize { |
| 428 | n.max(1).ilog10() as usize + 1 |
| 429 | } |
| 430 | |
| 431 | /// Cells the number columns take, with the space after each. |
| 432 | fn numbers_width(gutter: DiffGutter, nw: usize) -> usize { |
| 433 | match gutter { |
| 434 | DiffGutter::Both => 2 * nw + 2, |
| 435 | DiffGutter::Single => nw + 1, |
| 436 | DiffGutter::Off => 0, |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | /// The richest gutter that leaves the sign, the space after it and a cell of |
| 441 | /// text in `width` columns, and whether the space fits. Numbers are shed |
| 442 | /// first (`Both`, then `Single`, then none); the sign is the last thing to go, |
| 443 | /// so a changed line says what it is at any width. |
| 444 | fn fit_gutter(wanted: DiffGutter, nw: usize, width: usize) -> (DiffGutter, bool) { |
| 445 | let ladder = match wanted { |
| 446 | DiffGutter::Both => &[DiffGutter::Both, DiffGutter::Single, DiffGutter::Off][..], |
| 447 | DiffGutter::Single => &[DiffGutter::Single, DiffGutter::Off][..], |
| 448 | DiffGutter::Off => &[DiffGutter::Off][..], |
| 449 | }; |
| 450 | match ladder.iter().find(|g| numbers_width(**g, nw) + 3 <= width) { |
| 451 | Some(g) => (*g, true), |
| 452 | None => (DiffGutter::Off, false), |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | impl Diff<'_> { |
| 457 | fn number_width(&self) -> usize { |
| 458 | let top = self |
| 459 | .lines |
| 460 | .iter() |
| 461 | .flat_map(|l| [l.old_no, l.new_no]) |
| 462 | .flatten() |
| 463 | .max() |
| 464 | .unwrap_or(0); |
| 465 | digits(top).max(2) |
| 466 | } |
| 467 | |
| 468 | fn rows(&self, width: u16, theme: &Theme) -> Vec<Row> { |
| 469 | let ascii = theme.ascii(); |
| 470 | let width = usize::from(width); |
| 471 | let gutter = self.gutter.unwrap_or(if width >= 56 { |
| 472 | DiffGutter::Both |
| 473 | } else { |
| 474 | DiffGutter::Single |
| 475 | }); |
| 476 | let nw = self.number_width(); |
| 477 | let (gutter, gap) = fit_gutter(gutter, nw, width); |
| 478 | // Number columns, the sign, and the space after it when there is room. |
| 479 | let gutter_w = numbers_width(gutter, nw) + 1 + usize::from(gap); |
| 480 | let mut rows = Vec::new(); |
| 481 | for (index, line) in self.lines.iter().enumerate().skip(self.scroll) { |
| 482 | let changed = matches!(line.kind, DiffKind::Added | DiffKind::Removed); |
| 483 | let tint = match line.kind { |
| 484 | DiffKind::Added => theme.bg(Role::DiffAddedTint), |
| 485 | DiffKind::Removed => theme.bg(Role::DiffRemovedTint), |
| 486 | _ => Style::default(), |
| 487 | }; |
| 488 | let tinted = changed && tint.bg.is_some(); |
| 489 | let hue = match line.kind { |
| 490 | DiffKind::Added => Role::Live, |
| 491 | DiffKind::Removed => Role::Danger, |
| 492 | _ => Role::Foreground, |
| 493 | }; |
| 494 | let sign = match line.kind { |
| 495 | DiffKind::Added => "+", |
| 496 | DiffKind::Removed if ascii => "-", |
| 497 | DiffKind::Removed => "\u{2212}", |
| 498 | _ => " ", |
| 499 | }; |
| 500 | let own_gutter = matches!( |
| 501 | line.kind, |
| 502 | DiffKind::Context | DiffKind::Added | DiffKind::Removed |
| 503 | ); |
| 504 | let prefix_w = if own_gutter || line.kind == DiffKind::Note { |
| 505 | gutter_w |
| 506 | } else { |
| 507 | 0 |
| 508 | }; |
| 509 | // Zero when even the sign takes the whole row: no body is shown. |
| 510 | let avail = width.saturating_sub(prefix_w); |
| 511 | let all = pieces(line.text, usize::from(self.tab_width.max(1))); |
| 512 | // The one ellipsis; ASCII spells it out where there is room. |
| 513 | let mark = match (ascii, avail) { |
| 514 | (false, _) => glyphs::ELLIPSIS, |
| 515 | (true, 4..) => "...", |
| 516 | (true, _) => ".", |
| 517 | }; |
| 518 | let mark_w = text::width(mark); |
| 519 | let cuts = if avail == 0 { |
| 520 | vec![(0, 0, false)] |
| 521 | } else { |
| 522 | split_rows(&all, avail, self.wrap, mark_w) |
| 523 | }; |
| 524 | let highlights = match (self.highlight, own_gutter) { |
| 525 | (Some(highlight), true) => highlight(line.text), |
| 526 | _ => Vec::new(), |
| 527 | }; |
| 528 | for (n, (start, end, cut)) in cuts.iter().enumerate() { |
| 529 | let mut spans: Vec<Span<'static>> = Vec::new(); |
| 530 | let number = |v: Option<u32>| match (n, v) { |
| 531 | (0, Some(v)) => format!("{v:>nw$}"), |
| 532 | _ => " ".repeat(nw), |
| 533 | }; |
| 534 | if own_gutter { |
| 535 | let (old, new) = (number(line.old_no), number(line.new_no)); |
| 536 | let shown = match gutter { |
| 537 | DiffGutter::Both => format!("{old} {new} "), |
| 538 | DiffGutter::Single => { |
| 539 | let one = if line.kind == DiffKind::Removed { |
| 540 | old |
| 541 | } else { |
| 542 | number(line.new_no.or(line.old_no)) |
| 543 | }; |
| 544 | format!("{one} ") |
| 545 | } |
| 546 | DiffGutter::Off => String::new(), |
| 547 | }; |
| 548 | spans.push(Span::styled(shown, theme.fg(Role::Muted))); |
| 549 | let sign_style = if changed { |
| 550 | theme.fg(hue).add_modifier(Modifier::BOLD) |
| 551 | } else { |
| 552 | theme.fg(Role::Foreground) |
| 553 | }; |
| 554 | spans.push(Span::styled(sign.to_string(), sign_style)); |
| 555 | if gap { |
| 556 | spans.push(Span::raw(" ")); |
| 557 | } |
| 558 | } else if line.kind == DiffKind::Note { |
| 559 | spans.push(Span::raw(" ".repeat(gutter_w))); |
| 560 | } |
| 561 | let (base_role, base_mod) = match line.kind { |
| 562 | DiffKind::FileHeader |
| 563 | if line.text.starts_with("diff ") || line.text.starts_with("+++ ") => |
| 564 | { |
| 565 | (Role::Foreground, Modifier::BOLD) |
| 566 | } |
| 567 | DiffKind::FileHeader => (Role::Foreground, Modifier::empty()), |
| 568 | DiffKind::HunkHeader => (Role::Muted, Modifier::empty()), |
| 569 | DiffKind::Note => (Role::Hint, Modifier::empty()), |
| 570 | _ => (Role::Foreground, Modifier::empty()), |
| 571 | }; |
| 572 | let mut run: Option<(Style, String)> = None; |
| 573 | for piece in &all[*start..*end] { |
| 574 | let emphasised = line.emphasis.iter().any(|r| r.contains(&piece.at)); |
| 575 | let mut role = highlights |
| 576 | .iter() |
| 577 | .find(|(r, _)| r.contains(&piece.at)) |
| 578 | .map(|(_, role)| *role) |
| 579 | .filter(|role| !tinted || matches!(role, Role::Foreground | Role::Muted)) |
| 580 | .unwrap_or(base_role); |
| 581 | let mut modifier = base_mod; |
| 582 | if emphasised { |
| 583 | modifier |= Modifier::BOLD; |
| 584 | if tinted || (changed && theme.color(hue).is_some()) { |
| 585 | role = hue; |
| 586 | } |
| 587 | if !tinted { |
| 588 | modifier |= Modifier::UNDERLINED; |
| 589 | } |
| 590 | } |
| 591 | let style = theme.fg(role).add_modifier(modifier); |
| 592 | match &mut run { |
| 593 | Some((s, buf)) if *s == style => buf.push_str(&piece.text), |
| 594 | _ => { |
| 595 | if let Some((s, buf)) = run.take() { |
| 596 | spans.push(Span::styled(buf, s)); |
| 597 | } |
| 598 | run = Some((style, piece.text.clone())); |
| 599 | } |
| 600 | } |
| 601 | } |
| 602 | if let Some((s, buf)) = run { |
| 603 | spans.push(Span::styled(buf, s)); |
| 604 | } |
| 605 | if *cut { |
| 606 | // `Hint` is not audited behind a tint; `Muted` is. |
| 607 | let ink = if tinted { Role::Muted } else { Role::Hint }; |
| 608 | spans.push(Span::styled(mark.to_string(), theme.fg(ink))); |
| 609 | } |
| 610 | rows.push(Row { |
| 611 | line: index, |
| 612 | spans, |
| 613 | tint: if changed { tint } else { Style::default() }, |
| 614 | }); |
| 615 | } |
| 616 | } |
| 617 | rows |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | impl Paint for Diff<'_> { |
| 622 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 623 | let area = area.intersection(buf.area); |
| 624 | if area.is_empty() { |
| 625 | return; |
| 626 | } |
| 627 | let rows = self.rows(area.width, theme); |
| 628 | let height = usize::from(area.height); |
| 629 | // One row cannot hold both a line and the count of the rest: the |
| 630 | // first line wins. |
| 631 | let clipped = rows.len() > height && height > 1; |
| 632 | let shown = if clipped { |
| 633 | height - 1 |
| 634 | } else { |
| 635 | rows.len().min(height) |
| 636 | }; |
| 637 | for (y, row) in rows.iter().take(shown).enumerate() { |
| 638 | let rect = Rect::new(area.x, area.y + y as u16, area.width, 1); |
| 639 | buf.set_style(rect, row.tint); |
| 640 | Line::from(row.spans.clone()).render(rect, buf); |
| 641 | } |
| 642 | if clipped { |
| 643 | // Lines with a row that is not on screen, counted once each: a |
| 644 | // wrapped line cut off part-way is not fully shown either. |
| 645 | let mut hidden_lines: Vec<usize> = rows.iter().skip(shown).map(|r| r.line).collect(); |
| 646 | hidden_lines.dedup(); |
| 647 | let more = format!( |
| 648 | "+{} {}", |
| 649 | hidden_lines.len(), |
| 650 | text::display_safe(&self.words.more) |
| 651 | ); |
| 652 | let more = text::truncate(&more, usize::from(area.width), theme.ascii()); |
| 653 | let rect = Rect::new(area.x, area.y + shown as u16, area.width, 1); |
| 654 | Line::from(Span::styled(more.into_owned(), theme.fg(Role::Muted))).render(rect, buf); |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | fn height(&self, width: u16, theme: &Theme) -> u16 { |
| 659 | u16::try_from(self.rows(width, theme).len()).unwrap_or(u16::MAX) |
| 660 | } |
| 661 | } |
| 662 |