difft_lib/display/
style.rs

1//! Apply colours and styling to strings.
2
3use crate::{
4    constants::Side,
5    lines::{byte_len, split_on_newlines, LineNumber},
6    mainfn::FgColor,
7    options::DisplayOptions,
8    parse::syntax::{AtomKind, MatchKind, MatchedPos, TokenKind},
9    positions::SingleLineSpan,
10};
11use owo_colors::{OwoColorize, Style};
12use rustc_hash::FxHashMap;
13use std::cmp::{max, min};
14use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
15
16#[derive(Clone, Copy, Debug)]
17pub enum BackgroundColor {
18    Dark,
19    Light,
20}
21
22impl BackgroundColor {
23    pub fn is_dark(self) -> bool {
24        matches!(self, BackgroundColor::Dark)
25    }
26}
27
28/// Slice `s` from `start` inclusive to `end` exclusive by width.
29fn substring_by_width(s: &str, start: usize, end: usize) -> &str {
30    if start == end {
31        return &s[0..0];
32    }
33
34    assert!(end > start);
35
36    let mut idx_width_iter = s
37        .char_indices()
38        .scan(0, |w, (idx, ch)| {
39            let before = *w;
40            *w += ch.width().unwrap_or(0);
41            Some((idx, before, *w))
42        })
43        .skip_while(|(_, before, _)| *before < start);
44    let byte_start = idx_width_iter
45        .next()
46        .expect("Expected a width index inside `s`.")
47        .0;
48    match idx_width_iter
49        .skip_while(|(_, _, after)| *after <= end)
50        .next()
51    {
52        Some(byte_end) => &s[byte_start..byte_end.0],
53        None => &s[byte_start..],
54    }
55}
56
57fn substring_by_byte(s: &str, start: usize, end: usize) -> &str {
58    &s[start..end]
59}
60
61/// Split a string into equal length parts and how many spaces should be padded.
62///
63/// Return splitted strings and how many spaces each should be padded with.
64///
65/// ```
66/// split_string_by_width("fooba", 3, true) // vec![("foo", 0), ("ba", 1)]
67/// split_string_by_width("一个汉字两列宽", 8, false) // vec![("一个汉字", 0), ("两列宽", 0)]
68/// ```
69fn split_string_by_width(s: &str, max_width: usize, pad: bool) -> Vec<(&str, usize)> {
70    let mut res = vec![];
71    let mut s = s;
72
73    while s.width() > max_width {
74        let l = substring_by_width(s, 0, max_width);
75        let used = l.width();
76        let padding = if pad && used < max_width {
77            // a fullwidth char is followed
78            1
79        } else {
80            0
81        };
82        res.push((l, padding));
83        s = substring_by_width(s, used, s.width());
84    }
85
86    if res.is_empty() || !s.is_empty() {
87        let padding = if pad { max_width - s.width() } else { 0 };
88        res.push((s, padding));
89    }
90
91    res
92}
93
94fn highlight_missing_style_bug(s: &str) -> String {
95    s.on_purple().to_string()
96}
97
98/// Split `line` (from the source code) into multiple lines of
99/// `max_len` (i.e. word wrapping), and apply `styles` to each part
100/// according to its original position in `line`.
101pub fn split_and_apply(
102    line: &str,
103    max_len: usize,
104    use_color: bool,
105    styles: &[(SingleLineSpan, Style)],
106    side: Side,
107) -> Vec<String> {
108    if styles.is_empty() && !line.trim().is_empty() {
109        // Missing styles is a bug, so highlight in purple to make this obvious.
110        return split_string_by_width(line, max_len, matches!(side, Side::Left))
111            .into_iter()
112            .map(|(part, _)| {
113                if use_color {
114                    highlight_missing_style_bug(part)
115                } else {
116                    part.to_owned()
117                }
118            })
119            .collect();
120    }
121
122    let mut styled_parts = vec![];
123    let mut part_start = 0;
124
125    for (part, pad) in split_string_by_width(line, max_len, matches!(side, Side::Left)) {
126        let mut res = String::with_capacity(part.len() + pad);
127        let mut prev_style_end = 0;
128        for (span, style) in styles {
129            let start_col = span.start_col as usize;
130            let end_col = span.end_col as usize;
131
132            // The remaining spans are beyond the end of this part.
133            if start_col >= part_start + byte_len(&part) {
134                break;
135            }
136
137            // If there's an unstyled gap before the next span.
138            if start_col > part_start && prev_style_end < start_col {
139                // Then append that text without styling.
140                let unstyled_start = max(prev_style_end, part_start);
141                res.push_str(substring_by_byte(
142                    part,
143                    unstyled_start - part_start,
144                    start_col - part_start,
145                ));
146            }
147
148            // Apply style to the substring in this span.
149            if end_col > part_start {
150                let span_s = substring_by_byte(
151                    part,
152                    max(0, span.start_col as isize - part_start as isize) as usize,
153                    min(byte_len(part), end_col - part_start),
154                );
155                res.push_str(&span_s.style(*style).to_string());
156            }
157            prev_style_end = end_col;
158        }
159
160        // Ensure that prev_style_end is at least at the start of this
161        // part.
162        if prev_style_end < part_start {
163            prev_style_end = part_start;
164        }
165
166        // Unstyled text after the last span.
167        if prev_style_end < part_start + byte_len(part) {
168            let span_s = substring_by_byte(part, prev_style_end - part_start, byte_len(part));
169            res.push_str(span_s);
170        }
171        res.push_str(&" ".repeat(pad));
172
173        styled_parts.push(res);
174        part_start += byte_len(part);
175    }
176
177    styled_parts
178}
179
180#[allow(unused_variables)]
181pub fn tui_split_and_apply(
182    line: &str,
183    max_len: usize,
184    use_color: bool,
185    styles: &[(SingleLineSpan, Style)],
186    side: Side,
187) -> Vec<(String, FgColor)> {
188    if styles.is_empty() && !line.trim().is_empty() {
189        // Missing styles is a bug, so highlight in purple to make this obvious.
190        return split_string_by_width(line, max_len, matches!(side, Side::Left))
191            .into_iter()
192            .map(|(part, _)| (part.to_owned(), FgColor::White))
193            .collect();
194    }
195
196    let mut styled_parts: Vec<(String, FgColor)> = vec![];
197    let mut part_start = 0;
198
199    for (part, pad) in split_string_by_width(line, max_len, matches!(side, Side::Left)) {
200        let mut prev_style_end = 0;
201        for (span, style) in styles {
202            let start_col = span.start_col as usize;
203            let end_col = span.end_col as usize;
204
205            // The remaining spans are beyond the end of this part.
206            if start_col >= part_start + byte_len(&part) {
207                break;
208            }
209
210            // If there's an unstyled gap before the next span.
211            if start_col > part_start && prev_style_end < start_col {
212                // Then append that text without styling.
213                let unstyled_start = max(prev_style_end, part_start);
214                let span_s =
215                    substring_by_byte(part, unstyled_start - part_start, start_col - part_start);
216                styled_parts.push((span_s.to_owned(), FgColor::White));
217            }
218
219            // Apply style to the substring in this span.
220            if end_col > part_start {
221                let span_s = substring_by_byte(
222                    part,
223                    max(0, span.start_col as isize - part_start as isize) as usize,
224                    min(byte_len(part), end_col - part_start),
225                );
226                let styled_span_s = span_s.style(*style).to_string();
227
228                let mut color = FgColor::White;
229                if styled_span_s.starts_with("") {
230                    color = FgColor::Red;
231                } else if styled_span_s.starts_with("") {
232                    color = FgColor::Green;
233                }
234                styled_parts.push((span_s.to_owned(), color));
235            }
236            prev_style_end = end_col;
237        }
238
239        // Ensure that prev_style_end is at least at the start of this
240        // part.
241        if prev_style_end < part_start {
242            prev_style_end = part_start;
243        }
244
245        // Unstyled text after the last span.
246        if prev_style_end < part_start + byte_len(part) {
247            let span_s = substring_by_byte(part, prev_style_end - part_start, byte_len(part));
248            styled_parts.push((span_s.to_owned(), FgColor::White));
249        }
250
251        part_start += byte_len(part);
252    }
253
254    styled_parts
255}
256
257/// Return a copy of `line` with styles applied to all the spans
258/// specified.
259fn apply_line(line: &str, styles: &[(SingleLineSpan, Style)]) -> String {
260    if styles.is_empty() && !line.is_empty() {
261        return highlight_missing_style_bug(line);
262    }
263
264    let line_bytes = byte_len(line);
265    let mut res = String::with_capacity(line.len());
266    let mut i = 0;
267    for (span, style) in styles {
268        let start_col = span.start_col as usize;
269        let end_col = span.end_col as usize;
270
271        // The remaining spans are beyond the end of this line. This
272        // occurs when we truncate the line to fit on the display.
273        if start_col >= line_bytes {
274            break;
275        }
276
277        // Unstyled text before the next span.
278        if i < start_col {
279            res.push_str(substring_by_byte(line, i, start_col));
280        }
281
282        // Apply style to the substring in this span.
283        let span_s = substring_by_byte(line, start_col, min(line_bytes, end_col));
284        res.push_str(&span_s.style(*style).to_string());
285        i = end_col;
286    }
287
288    // Unstyled text after the last span.
289    if i < line_bytes {
290        let span_s = substring_by_byte(line, i, line_bytes);
291        res.push_str(span_s);
292    }
293    res
294}
295
296fn group_by_line(
297    ranges: &[(SingleLineSpan, Style)],
298) -> FxHashMap<LineNumber, Vec<(SingleLineSpan, Style)>> {
299    let mut ranges_by_line: FxHashMap<_, Vec<_>> = FxHashMap::default();
300    for range in ranges {
301        if let Some(matching_ranges) = ranges_by_line.get_mut(&range.0.line) {
302            (*matching_ranges).push(*range);
303        } else {
304            ranges_by_line.insert(range.0.line, vec![*range]);
305        }
306    }
307
308    ranges_by_line
309}
310
311/// Apply the `Style`s to the spans specified.
312///
313/// Tolerant against lines in `s` being shorter than the spans.
314fn style_lines(lines: &[&str], styles: &[(SingleLineSpan, Style)]) -> Vec<String> {
315    let mut ranges_by_line = group_by_line(styles);
316
317    let mut res = Vec::with_capacity(lines.len());
318    for (i, line) in lines.iter().enumerate() {
319        let mut styled_line = String::with_capacity(line.len());
320        let ranges = ranges_by_line
321            .remove(&(i as u32).into())
322            .unwrap_or_default();
323
324        styled_line.push_str(&apply_line(line, &ranges));
325        styled_line.push('\n');
326        res.push(styled_line);
327    }
328    res
329}
330
331pub fn novel_style(style: Style, is_lhs: bool, background: BackgroundColor) -> Style {
332    if background.is_dark() {
333        if is_lhs {
334            style.bright_red()
335        } else {
336            style.bright_green()
337        }
338    } else if is_lhs {
339        style.red()
340    } else {
341        style.green()
342    }
343}
344
345pub fn color_positions(
346    is_lhs: bool,
347    background: BackgroundColor,
348    syntax_highlight: bool,
349    positions: &[MatchedPos],
350) -> Vec<(SingleLineSpan, Style)> {
351    let mut styles = vec![];
352    for pos in positions {
353        let mut style = Style::new();
354        match pos.kind {
355            MatchKind::UnchangedToken { highlight, .. } => {
356                if syntax_highlight {
357                    if let TokenKind::Atom(atom_kind) = highlight {
358                        match atom_kind {
359                            AtomKind::String => {
360                                style = if background.is_dark() {
361                                    style.bright_magenta()
362                                } else {
363                                    style.magenta()
364                                };
365                            }
366                            AtomKind::Comment => {
367                                style = style.italic();
368                                style = if background.is_dark() {
369                                    style.bright_blue()
370                                } else {
371                                    style.blue()
372                                };
373                            }
374                            AtomKind::Keyword | AtomKind::Type => {
375                                style = style.bold();
376                            }
377                            AtomKind::Normal => {}
378                        }
379                    }
380                }
381            }
382            MatchKind::Novel { highlight, .. } => {
383                style = novel_style(style, is_lhs, background);
384                if syntax_highlight
385                    && matches!(
386                        highlight,
387                        TokenKind::Delimiter
388                            | TokenKind::Atom(AtomKind::Keyword)
389                            | TokenKind::Atom(AtomKind::Type)
390                    )
391                {
392                    style = style.bold();
393                }
394                if matches!(highlight, TokenKind::Atom(AtomKind::Comment)) {
395                    style = style.italic();
396                }
397            }
398            MatchKind::NovelWord { highlight } => {
399                style = novel_style(style, is_lhs, background).bold().underline();
400                if syntax_highlight && matches!(highlight, TokenKind::Atom(AtomKind::Comment)) {
401                    style = style.italic();
402                }
403            }
404            MatchKind::NovelLinePart { highlight, .. } => {
405                style = novel_style(style, is_lhs, background);
406                if syntax_highlight && matches!(highlight, TokenKind::Atom(AtomKind::Comment)) {
407                    style = style.italic();
408                }
409            }
410        };
411        styles.push((pos.pos, style));
412    }
413    styles
414}
415
416pub fn apply_colors(
417    s: &str,
418    is_lhs: bool,
419    syntax_highlight: bool,
420    background: BackgroundColor,
421    positions: &[MatchedPos],
422) -> Vec<String> {
423    let styles = color_positions(is_lhs, background, syntax_highlight, positions);
424    let lines = split_on_newlines(s);
425    style_lines(&lines, &styles)
426}
427
428fn apply_header_color(s: &str, use_color: bool, background: BackgroundColor) -> String {
429    if use_color {
430        if background.is_dark() {
431            s.bright_yellow().to_string()
432        } else {
433            s.yellow().to_string()
434        }
435        .bold()
436        .to_string()
437    } else {
438        s.to_string()
439    }
440}
441
442#[allow(unused_variables)]
443pub fn header2(
444    lhs_display_path: &str,
445    rhs_display_path: &str,
446    hunk_num: usize,
447    hunk_total: usize,
448    language_name: &str,
449    display_options: &DisplayOptions,
450) -> String {
451    let divider = if hunk_total == 1 {
452        "".to_owned()
453    } else {
454        format!("{}/{} --- ", hunk_num, hunk_total)
455    };
456    format!("--- {}{}", divider, language_name)
457}
458
459pub fn header(
460    lhs_display_path: &str,
461    rhs_display_path: &str,
462    hunk_num: usize,
463    hunk_total: usize,
464    language_name: &str,
465    display_options: &DisplayOptions,
466) -> String {
467    let divider = if hunk_total == 1 {
468        "".to_owned()
469    } else {
470        format!("{}/{} --- ", hunk_num, hunk_total)
471    };
472
473    let rhs_path_pretty = apply_header_color(
474        rhs_display_path,
475        display_options.use_color,
476        display_options.background_color,
477    );
478    let lhs_path_pretty = apply_header_color(
479        lhs_display_path,
480        display_options.use_color,
481        display_options.background_color,
482    );
483    if hunk_num == 1 && lhs_display_path != rhs_display_path && display_options.in_vcs {
484        let renamed = format!("Renamed {} to {}", lhs_path_pretty, rhs_path_pretty);
485        format!(
486            "{}\n{} --- {}{}",
487            renamed, rhs_path_pretty, divider, language_name
488        )
489    } else {
490        // Prefer showing the RHS path in the header unless it's
491        // /dev/null. Note that git calls the difftool with
492        // `DIFFTOOL /tmp/git-blob-abc/foo.py foo.py` in some cases.
493        let path_pretty = if rhs_display_path == "/dev/null" {
494            lhs_path_pretty
495        } else {
496            rhs_path_pretty
497        };
498        format!("{} --- {}{}", path_pretty, divider, language_name)
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use pretty_assertions::assert_eq;
506
507    #[test]
508    fn split_string_simple() {
509        assert_eq!(
510            split_string_by_width("fooba", 3, true),
511            vec![("foo", 0), ("ba", 1)]
512        );
513    }
514
515    #[test]
516    fn split_string_simple_no_pad() {
517        assert_eq!(
518            split_string_by_width("fooba", 3, false),
519            vec![("foo", 0), ("ba", 0)]
520        );
521    }
522
523    #[test]
524    fn split_string_unicode() {
525        assert_eq!(
526            split_string_by_width("ab📦def", 4, true),
527            vec![("ab📦", 0), ("def", 1)]
528        );
529    }
530
531    #[test]
532    fn split_string_cjk() {
533        assert_eq!(
534            split_string_by_width("一个汉字两列宽", 8, false),
535            vec![("一个汉字", 0), ("两列宽", 0)]
536        );
537    }
538
539    #[test]
540    fn split_string_cjk2() {
541        assert_eq!(
542            split_string_by_width("你好啊", 5, true),
543            vec![("你好", 1), ("啊", 3)]
544        );
545    }
546
547    #[test]
548    fn test_split_and_apply_missing() {
549        let res = split_and_apply("foo", 3, true, &[], Side::Left);
550        assert_eq!(res, vec![highlight_missing_style_bug("foo")])
551    }
552
553    #[test]
554    fn test_split_and_apply() {
555        let res = split_and_apply(
556            "foo",
557            3,
558            true,
559            &[(
560                SingleLineSpan {
561                    line: 0.into(),
562                    start_col: 0,
563                    end_col: 3,
564                },
565                Style::new(),
566            )],
567            Side::Left,
568        );
569        assert_eq!(res, vec!["foo"])
570    }
571
572    #[test]
573    fn test_split_and_apply_trailing_text() {
574        let res = split_and_apply(
575            "foobar",
576            6,
577            true,
578            &[(
579                SingleLineSpan {
580                    line: 0.into(),
581                    start_col: 0,
582                    end_col: 3,
583                },
584                Style::new(),
585            )],
586            Side::Left,
587        );
588        assert_eq!(res, vec!["foobar"])
589    }
590
591    #[test]
592    fn test_split_and_apply_gap_between_styles_on_wrap_boundary() {
593        let res = split_and_apply(
594            "foobar",
595            3,
596            true,
597            &[
598                (
599                    SingleLineSpan {
600                        line: 0.into(),
601                        start_col: 0,
602                        end_col: 2,
603                    },
604                    Style::new(),
605                ),
606                (
607                    SingleLineSpan {
608                        line: 0.into(),
609                        start_col: 4,
610                        end_col: 6,
611                    },
612                    Style::new(),
613                ),
614            ],
615            Side::Left,
616        );
617        assert_eq!(res, vec!["foo", "bar"])
618    }
619
620    #[test]
621    fn test_split_and_apply_trailing_text_newline() {
622        let res = split_and_apply(
623            "foobar      ",
624            6,
625            true,
626            &[(
627                SingleLineSpan {
628                    line: 0.into(),
629                    start_col: 0,
630                    end_col: 3,
631                },
632                Style::new(),
633            )],
634            Side::Left,
635        );
636        assert_eq!(res, vec!["foobar", "      "])
637    }
638}