diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java index a21e0137502..c75547bac4b 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java @@ -20,7 +20,6 @@ import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffText.mapOffsetToTabExpanded; import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffText.mergeStyleRanges; import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffText.removeLeadingNewLines; -import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffText.removeTrailingNewLines; import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffText.replaceTabWithSpaces; import java.lang.reflect.Method; @@ -30,6 +29,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -48,6 +48,8 @@ import org.eclipse.jface.text.ITextViewer; import org.eclipse.jface.text.Position; import org.eclipse.jface.text.Region; +import org.eclipse.jface.text.TextViewer; +import org.eclipse.jface.text.WhitespaceCharacterPainter; import org.eclipse.jface.text.codemining.AbstractCodeMiningProvider; import org.eclipse.jface.text.codemining.DocumentFooterCodeMining; import org.eclipse.jface.text.codemining.ICodeMining; @@ -60,6 +62,7 @@ import org.eclipse.jface.text.source.inlined.LineFooterAnnotation; import org.eclipse.jface.text.source.inlined.LineHeaderAnnotation; import org.eclipse.jface.text.source.projection.ProjectionAnnotation; +import org.eclipse.jface.util.IPropertyChangeListener; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyleRange; @@ -85,6 +88,8 @@ import org.eclipse.ui.texteditor.AbstractTextEditor; public class UnifiedDiffCodeMiningProvider extends AbstractCodeMiningProvider { + // Zero-width space keeps a final blank row in the framework's label layout. + private static final String EMPTY_LINE_LAYOUT_MARKER = "\u200B"; //$NON-NLS-1$ private Color deletionBackgroundColor; private Color detailedDiffColor; @@ -506,6 +511,37 @@ interface IUnifiedDiffCodeMining { int getTabWidth(); UnifiedDiff getUnifiedDiff(); String getLabel(); + String getRawLabel(); + } + + private record CodeMiningLabel(String raw, String expanded, String layout, boolean trailingCarriageReturn, + boolean trailingLineFeed) { + } + + /** + * Removes exactly one final delimiter from the drawable label and records it + * for separate glyph painting. Earlier delimiters remain in the label because + * they represent real trailing blank lines and therefore require band height. + */ + private static CodeMiningLabel codeMiningLabel(String text, int tabWidth) { + boolean trailingLineFeed = text.endsWith("\n"); //$NON-NLS-1$ + boolean trailingCarriageReturn = text.endsWith("\r") //$NON-NLS-1$ + || trailingLineFeed && text.length() > 1 && text.charAt(text.length() - 2) == '\r'; + int delimiterLength = 0; + if (trailingLineFeed) { + delimiterLength = trailingCarriageReturn ? 2 : 1; + } else if (trailingCarriageReturn) { + delimiterLength = 1; + } + String raw = text.substring(0, text.length() - delimiterLength); + String expanded = replaceTabWithSpaces(raw, tabWidth); + // CodeMiningLineHeaderAnnotation uses Pattern.split without a negative limit, + // which drops the empty segment after a final delimiter. Keep that last row in + // its layout calculation with an invisible marker; painting still uses expanded. + String layout = raw.endsWith("\r") || raw.endsWith("\n") //$NON-NLS-1$ //$NON-NLS-2$ + ? expanded + EMPTY_LINE_LAYOUT_MARKER + : expanded; + return new CodeMiningLabel(raw, expanded, layout, trailingCarriageReturn, trailingLineFeed); } /** Draws the thin top and bottom border of a code-mining band. */ @@ -539,30 +575,94 @@ public void accept(MouseEvent t) { return; } StyledText st = viewer.getTextWidget(); - StyledText overlay = new StyledText(st, SWT.NONE); + TextViewer overlayViewer = new TextViewer(st, SWT.NONE); + StyledText overlay = overlayViewer.getTextWidget(); overlay.setBounds(mining.getLastRectangle()); overlay.setFont(st.getFont()); overlay.setBackground(mining.getDeletionBackgroundColor()); overlay.setLineSpacing(st.getLineSpacing()); - String txt = mining.getLabel().stripTrailing(); - overlay.setText(txt); - overlay.setFocus(); - List backgrounds = createDetailedDiffBackgroundRanges(mining.getUnifiedDiff(), + overlay.setTabs(mining.getTabWidth()); + String txt = mining.getRawLabel(); + overlayViewer.setDocument(new Document(txt)); + List backgrounds = createDetailedDiffBackgroundRangesRaw(mining.getUnifiedDiff(), mining.getTabWidth(), mining.getDetailedDiffColor()); List foregrounds = computeStyleRanges(viewer, mining.getUnifiedDiff().leftStart, txt); List ranges = mergeStyleRanges(backgrounds, foregrounds); overlay.setStyleRanges(ranges.toArray(new StyleRange[] {})); + installWhitespacePainter(overlayViewer); + overlay.setFocus(); openOverlay(overlay, viewer); } + + private static void installWhitespacePainter(TextViewer overlayViewer) { + IPreferenceStore store = EditorsUI.getPreferenceStore(); + if (!store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS)) { + return; + } + WhitespaceCharacterPainter painter = new WhitespaceCharacterPainter(overlayViewer, + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LEADING_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_TRAILING_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LEADING_IDEOGRAPHIC_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_IDEOGRAPHIC_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_TRAILING_IDEOGRAPHIC_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LEADING_TABS), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_TABS), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_TRAILING_TABS), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED), + store.getInt(AbstractTextEditor.PREFERENCE_WHITESPACE_CHARACTER_ALPHA_VALUE)); + overlayViewer.addPainter(painter); + overlayViewer.getTextWidget().addDisposeListener(_ -> { + painter.deactivate(false); + painter.dispose(); + }); + } + } + + /** + * Line range of a detailed diff, resolved once so painting does not scan the + * hunk text. + */ + private record DetailedDiffRange(String diffStr, int start, int length, int fromLine, int toLine, + boolean coversTrailingDelimiter) { + } + + private static List computeDetailedDiffRanges(UnifiedDiff diff, String diffStr) { + boolean useRight = diff.mode.equals(UnifiedDiffMode.OVERLAY_MODE) + || diff.mode.equals(UnifiedDiffMode.OVERLAY_READ_ONLY_MODE) + || diff.mode.equals(UnifiedDiffMode.REVERT_MODE); + List result = new ArrayList<>(); + for (var detailedDiff : diff.detailedDiffs) { + int detailedDiffStart = useRight ? detailedDiff.rightStart : detailedDiff.leftStart; + int detailedDiffLength = useRight ? detailedDiff.rightLength : detailedDiff.leftLength; + boolean coversTrailingDelimiter = detailedDiffStart + detailedDiffLength > diffStr.length(); + detailedDiffLength = clampDetailedDiffLength(detailedDiffStart, detailedDiffLength, diffStr.length()); + if (detailedDiffLength <= 0) { + continue; + } + // String#split drops trailing empty strings, so it must not be used to + // count lines: a prefix ending with \n starts the next line + int fromLine = countLines(diffStr, detailedDiffStart); + int toLine = countLines(diffStr, detailedDiffStart + detailedDiffLength); + result.add(new DetailedDiffRange(diffStr, detailedDiffStart, detailedDiffLength, fromLine, toLine, + coversTrailingDelimiter)); + } + return result; } public static class UnifiedDiffFooterCodeMining extends DocumentFooterCodeMining implements IUnifiedDiffCodeMining { private final String unifiedDiffLabel; + private final String layoutLabel; + private final String rawLabel; + private final boolean trailingCarriageReturn; + private final boolean trailingLineFeed; private final Color deletionBackgroundColor; private final Color detailedDiffColor; private final Color borderColor; private final int tabWidth; private final ITextViewer viewer; + private final List detailedDiffRanges; private UnifiedDiff diff; private List styleRanges; private final HashMap> styledFonts = new HashMap<>(); @@ -578,11 +678,14 @@ public UnifiedDiffFooterCodeMining(IDocument document, ICodeMiningProvider provi this.borderColor = borderColor; this.tabWidth = tabWidth; this.viewer = viewer; - if (diff.mode.equals(UnifiedDiffMode.REPLACE_MODE)) { - this.unifiedDiffLabel = removeTrailingNewLines(replaceTabWithSpaces(diff.leftStr, tabWidth)); - } else { - this.unifiedDiffLabel = removeTrailingNewLines(replaceTabWithSpaces(diff.rightStr, tabWidth)); - } + String diffStr = diff.mode.equals(UnifiedDiffMode.REPLACE_MODE) ? diff.leftStr : diff.rightStr; + CodeMiningLabel label = codeMiningLabel(diffStr, tabWidth); + this.rawLabel = label.raw(); + this.unifiedDiffLabel = label.expanded(); + this.layoutLabel = label.layout(); + this.trailingCarriageReturn = label.trailingCarriageReturn(); + this.trailingLineFeed = label.trailingLineFeed(); + this.detailedDiffRanges = computeDetailedDiffRanges(diff, this.rawLabel); this.diff = diff; ((MouseClickConsumer) getAction()).setCodeMining(this); } @@ -614,7 +717,12 @@ public UnifiedDiff getUnifiedDiff() { @Override public String getLabel() { - return this.unifiedDiffLabel; + return this.layoutLabel; + } + + @Override + public String getRawLabel() { + return this.rawLabel; } @Override @@ -661,7 +769,7 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { // the same! gc.fillRectangle(0, y, textWidget.getBounds().width /* result.x */, result.y); - String label = getLabel(); + String label = this.unifiedDiffLabel; List ranges = styleRanges(label); if (ranges.isEmpty()) { // no syntax coloring available; fall back to plain rendering @@ -672,9 +780,11 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { // paint the word-level detailed-diff backgrounds first, then draw the // syntax-colored text transparently on top - same order as the header band - fillDetailedDiffBackgrounds(gc, textWidget, label, ranges, x, y); + WhitespaceConfig whitespace = readWhitespaceConfig(tabWidth); + fillDetailedDiffBackgrounds(gc, textWidget, label, ranges, whitespace, x, y); gc.setFont(font); - drawStyleRanges(gc, textWidget, ranges, label, styledFonts, x, y, null); + drawStyleRanges(gc, textWidget, ranges, label, rawLabel, styledFonts, x, y, null, + whitespace, trailingCarriageReturn, trailingLineFeed); drawBandBorder(gc, this.borderColor, y, textWidget.getBounds().width, lastRectangle.height); return result; } @@ -685,17 +795,17 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { * paint a range background itself. */ private void fillDetailedDiffBackgrounds(GC gc, StyledText textWidget, String label, List ranges, - int x, int y) { - List backgrounds = createDetailedDiffBackgroundRanges(diff, tabWidth, detailedDiffColor); - if (backgrounds.isEmpty()) { + WhitespaceConfig whitespace, int x, int y) { + if (detailedDiffRanges.isEmpty()) { return; } int lineHeight = textWidget.getLineHeight(); int lineSpacing = textWidget.getLineSpacing(); gc.setBackground(this.detailedDiffColor); - for (StyleRange bg : backgrounds) { - int pos = bg.start; - int end = bg.start + bg.length; + for (DetailedDiffRange detailedDiff : detailedDiffRanges) { + int rawEnd = detailedDiff.start() + detailedDiff.length(); + int pos = mapOffsetToTabExpanded(detailedDiff.diffStr(), detailedDiff.start(), tabWidth); + int end = mapOffsetToTabExpanded(detailedDiff.diffStr(), rawEnd, tabWidth); // a detailed diff may span lines; fill each line segment on its own line while (pos < end) { int lineStart = label.lastIndexOf('\n', pos - 1) + 1; @@ -711,6 +821,18 @@ private void fillDetailedDiffBackgrounds(GC gc, StyledText textWidget, String la } pos = segmentEnd == lineEnd ? segmentEnd + 1 : segmentEnd; } + if (detailedDiff.coversTrailingDelimiter() && rawEnd == rawLabel.length()) { + String marker = lineDelimiterMarker(whitespace, trailingCarriageReturn, trailingLineFeed); + int width = gc.stringExtent(marker).x; + if (width > 0) { + int lineIndex = countLines(rawLabel, rawLabel.length()) - 1; + int lineStart = rawLabel.lastIndexOf('\n') + 1; + int markerX = rawLabel.endsWith("\n") ? 0 //$NON-NLS-1$ + : styledWidth(gc, label, ranges, lineStart, label.length()); + int rectY = y + lineIndex * (lineHeight + lineSpacing); + gc.fillRectangle(x + markerX, rectY, width, lineHeight); + } + } } } @@ -766,38 +888,54 @@ private int measure(GC gc, Font font, String label, int from, int to) { } public static List createDetailedDiffBackgroundRanges(UnifiedDiff diff, int tabWidth, Color detailedDiffColor) { + return createDetailedDiffBackgroundRanges(diff, tabWidth, detailedDiffColor, true); + } + + /** + * Detailed-diff background ranges in raw (unexpanded) coordinates, for the + * click-through overlay whose document keeps the original tabs. + */ + public static List createDetailedDiffBackgroundRangesRaw(UnifiedDiff diff, int tabWidth, + Color detailedDiffColor) { + return createDetailedDiffBackgroundRanges(diff, tabWidth, detailedDiffColor, false); + } + + private static List createDetailedDiffBackgroundRanges(UnifiedDiff diff, int tabWidth, + Color detailedDiffColor, boolean expandTabs) { List ranges = new ArrayList<>(); String diffStr = diff.mode.equals(UnifiedDiffMode.REPLACE_MODE) ? diff.leftStr : diff.rightStr; - String trimmedDiffStr = removeTrailingNewLines(diffStr); - int labelLength = replaceTabWithSpaces(trimmedDiffStr, tabWidth).stripTrailing().length(); + CodeMiningLabel label = codeMiningLabel(diffStr, tabWidth); + int labelLength = label.expanded().length(); + int rawLabelLength = label.raw().length(); for (var detailedDiff : diff.detailedDiffs) { int detailedDiffStart; int detailedDiffLength; - String detailedDiffStr; if (diff.mode.equals(UnifiedDiffMode.REPLACE_MODE)) { detailedDiffStart = detailedDiff.leftStart; detailedDiffLength = detailedDiff.leftLength; - detailedDiffStr = detailedDiff.leftStr; } else { detailedDiffStart = detailedDiff.rightStart; detailedDiffLength = detailedDiff.rightLength; - detailedDiffStr = detailedDiff.rightStr; } - if (detailedDiffStr.trim().length() == 0) { - continue; - } - detailedDiffLength = clampDetailedDiffLength(detailedDiffStart, detailedDiffLength, trimmedDiffStr.length()); + detailedDiffLength = clampDetailedDiffLength(detailedDiffStart, detailedDiffLength, rawLabelLength); if (detailedDiffLength <= 0) { continue; } - int expandedStart = mapOffsetToTabExpanded(diffStr, detailedDiffStart, tabWidth); - int expandedEnd = mapOffsetToTabExpanded(diffStr, detailedDiffStart + detailedDiffLength, tabWidth); - int expandedLength = expandedEnd - expandedStart; - expandedLength = clampDetailedDiffLength(expandedStart, expandedLength, labelLength); - if (expandedStart >= 0 && expandedLength > 0) { + int start; + int length; + if (expandTabs) { + int expandedStart = mapOffsetToTabExpanded(diffStr, detailedDiffStart, tabWidth); + int expandedEnd = mapOffsetToTabExpanded(diffStr, detailedDiffStart + detailedDiffLength, tabWidth); + start = expandedStart; + length = clampDetailedDiffLength(expandedStart, expandedEnd - expandedStart, labelLength); + } else { + start = detailedDiffStart; + length = clampDetailedDiffLength(detailedDiffStart, detailedDiffLength, rawLabelLength); + } + if (start >= 0 && length > 0) { StyleRange bgRange = new StyleRange(); - bgRange.start = expandedStart; - bgRange.length = expandedLength; + bgRange.start = start; + bgRange.length = length; bgRange.background = detailedDiffColor; ranges.add(bgRange); } @@ -805,7 +943,208 @@ public static List createDetailedDiffBackgroundRanges(UnifiedDiff di return ranges; } - record ForegroundInfo(int x, int y, String str, Font font, Color background, Color foreground) { + record ForegroundInfo(int x, int y, String str, Font font, Color background, Color foreground, int alpha, int clipWidth) { + ForegroundInfo(int x, int y, String str, Font font, Color background, Color foreground) { + this(x, y, str, font, background, foreground, 255, -1); + } + + ForegroundInfo(int x, int y, String str, Font font, Color background, Color foreground, int alpha) { + this(x, y, str, font, background, foreground, alpha, -1); + } + } + + /** + * Captures which whitespace characters are shown as visible glyphs, mirroring + * the settings read by {@link org.eclipse.jface.text.WhitespaceCharacterPainter}. + *

+ * This duplicates the painter's glyph logic on purpose: the painter needs an + * {@link org.eclipse.jface.text.ITextViewer} with its own {@link org.eclipse.swt.custom.StyledText}, + * but a code mining only receives the editor's shared {@code GC} and draws into + * it directly, so the painter cannot be reused here. The click-through overlay, + * which does own a widget, uses the real painter instead. Keep the two in sync + * when the editor's whitespace rendering changes. + */ + public record WhitespaceConfig(boolean showLeadingSpaces, boolean showEnclosedSpaces, boolean showTrailingSpaces, + boolean showLeadingIdeographicSpaces, boolean showEnclosedIdeographicSpaces, + boolean showTrailingIdeographicSpaces, boolean showLeadingTabs, boolean showEnclosedTabs, + boolean showTrailingTabs, boolean showCarriageReturn, boolean showLineFeed, int alpha, int tabWidth) { + + static final char SPACE_SIGN = '·'; + static final char IDEOGRAPHIC_SPACE_SIGN = '°'; + static final char TAB_SIGN = '»'; + static final char CARRIAGE_RETURN_SIGN = '¤'; + static final char LINE_FEED_SIGN = '¶'; + + static final char IDEOGRAPHIC_SPACE = ' '; + + private static final int OTHER = 0; + private static final int TAB_FIRST = 1; + private static final int TAB_FILLER = 2; + + /** + * Replaces whitespace characters in the segment {@code [from, to)} of + * {@code fullLine} with visible glyphs. {@code fullLine} is the complete, + * tab-expanded label line (no newline characters) and is used to determine + * whether a whitespace character is leading, enclosed, or trailing. + * {@code rawFullLine} is the same line before tabs were expanded; it tells + * which expanded columns originate from a tab. A tab is drawn as a single tab + * sign on the first column of its expanded run with the remaining + * {@code tabWidth - 1} columns left blank, matching + * {@link org.eclipse.jface.text.WhitespaceCharacterPainter}. Returns the segment + * characters with substitutions applied; its length is always {@code to - from}. + */ + public String applySegment(String fullLine, String rawFullLine, int from, int to) { + if (from >= to) { + return ""; //$NON-NLS-1$ + } + int textBegin = -1; + for (int i = 0; i < fullLine.length(); i++) { + if (!isWhitespace(fullLine.charAt(i))) { + textBegin = i; + break; + } + } + boolean emptyLine = textBegin == -1; + int textEnd = fullLine.length() - 1; + if (!emptyLine) { + for (int i = fullLine.length() - 1; i >= 0; i--) { + if (!isWhitespace(fullLine.charAt(i))) { + textEnd = i; + break; + } + } + } + int[] kind = classifyColumns(rawFullLine, to); + StringBuilder sb = new StringBuilder(to - from); + for (int i = from; i < to; i++) { + char c = fullLine.charAt(i); + boolean leading = !emptyLine && i < textBegin; + boolean trailing = !emptyLine && i > textEnd; + boolean enclosed = !emptyLine && !leading && !trailing; + int columnKind = kind == null ? OTHER : kind[i]; + if (columnKind == TAB_FIRST) { + // on an all-whitespace line the painter shows a tab when any of the + // three tab positions is enabled, matching WhitespaceCharacterPainter + boolean show = emptyLine ? (showLeadingTabs || showEnclosedTabs || showTrailingTabs) + : (leading && showLeadingTabs) || (enclosed && showEnclosedTabs) + || (trailing && showTrailingTabs); + sb.append(show ? TAB_SIGN : ' '); + } else if (columnKind == TAB_FILLER) { + sb.append(' '); + } else if (c == ' ') { + boolean show = emptyLine ? (showLeadingSpaces || showEnclosedSpaces || showTrailingSpaces) + : (leading && showLeadingSpaces) || (enclosed && showEnclosedSpaces) + || (trailing && showTrailingSpaces); + sb.append(show ? SPACE_SIGN : c); + } else if (c == IDEOGRAPHIC_SPACE) { + boolean show = emptyLine + ? (showLeadingIdeographicSpaces || showEnclosedIdeographicSpaces + || showTrailingIdeographicSpaces) + : (leading && showLeadingIdeographicSpaces) || (enclosed && showEnclosedIdeographicSpaces) + || (trailing && showTrailingIdeographicSpaces); + sb.append(show ? IDEOGRAPHIC_SPACE_SIGN : c); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** + * Classifies the first {@code limit} expanded columns of the line as a genuine + * character ({@code OTHER}), the first column of an expanded tab run + * ({@code TAB_FIRST}), or a filler column of such a run ({@code TAB_FILLER}), + * by walking the raw (unexpanded) line. {@code replaceTabWithSpaces} emits a + * fixed {@code tabWidth} spaces per tab, so each raw tab maps to exactly that + * many expanded columns. Returns {@code null} when the line has no tab, so the + * common tab-free case allocates nothing and the caller treats every column as + * {@code OTHER}. + */ + private int[] classifyColumns(String rawFullLine, int limit) { + if (rawFullLine == null || rawFullLine.indexOf('\t') < 0) { + return null; + } + int[] kind = new int[limit]; + int col = 0; + for (int r = 0; r < rawFullLine.length() && col < limit; r++) { + if (rawFullLine.charAt(r) == '\t') { + kind[col++] = TAB_FIRST; + for (int k = 1; k < tabWidth && col < limit; k++) { + kind[col++] = TAB_FILLER; + } + } else { + kind[col++] = OTHER; + } + } + return kind; + } + + private static boolean isWhitespace(char c) { + return c == ' ' || c == IDEOGRAPHIC_SPACE || c == '\t' || c == '\r' || c == '\n'; + } + } + + /** + * Cached snapshot of the global whitespace settings (every field except the + * per-mining {@code tabWidth}). Reading all the preferences on every paint of + * every mining showed up as a measurable cost, so the values are cached and + * invalidated by {@link #whitespaceConfigListener} whenever the store changes. + * {@code HAS_NO_CACHE} distinguishes "not yet read" from a cached {@code null} + * (whitespace rendering off). + */ + private static final WhitespaceConfig HAS_NO_CACHE = new WhitespaceConfig(false, false, false, false, false, false, + false, false, false, false, false, -1, -1); + private static volatile WhitespaceConfig cachedWhitespaceConfig = HAS_NO_CACHE; + // The listener is a process-wide singleton registered once and never removed: + // it invalidates a static cache shared by every provider instance, so there is + // no per-instance lifetime to tie its removal to. volatile so the lazy + // registration below is seen consistently across threads. + private static volatile IPropertyChangeListener whitespaceConfigListener; + + /** + * Reads the whitespace rendering settings from the preference store, returning + * {@code null} when whitespace rendering is off. The global settings are cached; + * only {@code tabWidth} is stamped per call. + */ + static WhitespaceConfig readWhitespaceConfig(int tabWidth) { + WhitespaceConfig cached = cachedWhitespaceConfig; + if (cached == HAS_NO_CACHE) { + cached = readWhitespaceConfigUncached(); + cachedWhitespaceConfig = cached; + } + if (cached == null) { + return null; + } + return cached.tabWidth() == tabWidth ? cached : new WhitespaceConfig(cached.showLeadingSpaces(), + cached.showEnclosedSpaces(), cached.showTrailingSpaces(), cached.showLeadingIdeographicSpaces(), + cached.showEnclosedIdeographicSpaces(), cached.showTrailingIdeographicSpaces(), cached.showLeadingTabs(), + cached.showEnclosedTabs(), cached.showTrailingTabs(), cached.showCarriageReturn(), cached.showLineFeed(), + cached.alpha(), tabWidth); + } + + private static WhitespaceConfig readWhitespaceConfigUncached() { + IPreferenceStore store = EditorsUI.getPreferenceStore(); + if (whitespaceConfigListener == null) { + whitespaceConfigListener = _ -> cachedWhitespaceConfig = HAS_NO_CACHE; + store.addPropertyChangeListener(whitespaceConfigListener); + } + if (!store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS)) { + return null; + } + return new WhitespaceConfig( + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LEADING_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_TRAILING_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LEADING_IDEOGRAPHIC_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_IDEOGRAPHIC_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_TRAILING_IDEOGRAPHIC_SPACES), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LEADING_TABS), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_TABS), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_TRAILING_TABS), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN), + store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED), + store.getInt(AbstractTextEditor.PREFERENCE_WHITESPACE_CHARACTER_ALPHA_VALUE), + -1 /* tabWidth stamped in per call by readWhitespaceConfig */); } /** @@ -813,10 +1152,26 @@ record ForegroundInfo(int x, int y, String str, Font font, Color background, Col * cursor position range by range. The {@code onForeground} consumer is called * for each drawn segment and may be {@code null}; the header mining uses it to * populate its foreground cache so subsequent repaints skip this path. + * When {@code whitespace} is non-null, whitespace in the drawn text is shown + * with visible indicator glyphs, consistent with the editor's rendering. */ static void drawStyleRanges(GC gc, StyledText textWidget, List ranges, String label, HashMap> styledFonts, int x, int y, Consumer onForeground) { + drawStyleRanges(gc, textWidget, ranges, label, null, styledFonts, x, y, onForeground, null); + } + + static void drawStyleRanges(GC gc, StyledText textWidget, List ranges, String label, String rawLabel, + HashMap> styledFonts, int x, int y, + Consumer onForeground, WhitespaceConfig whitespace) { + drawStyleRanges(gc, textWidget, ranges, label, rawLabel, styledFonts, x, y, onForeground, whitespace, false, + false); + } + + private static void drawStyleRanges(GC gc, StyledText textWidget, List ranges, String label, + String rawLabel, HashMap> styledFonts, int x, int y, + Consumer onForeground, WhitespaceConfig whitespace, boolean trailingCarriageReturn, + boolean trailingLineFeed) { Font font = gc.getFont(); int textWidgetLineHeight = textWidget.getLineHeight(); int cx = x; @@ -837,19 +1192,26 @@ static void drawStyleRanges(GC gc, StyledText textWidget, List range } String[] lines = sub.split("\n"); //$NON-NLS-1$ if (lines.length > 1) { + // segStart tracks where each line-piece starts within label + int segStart = range.start; for (int i = 0; i < lines.length; i++) { String line = lines[i].replace("\r", ""); //$NON-NLS-1$ //$NON-NLS-2$ - gc.drawString(line, cx, cy, true); - if (onForeground != null) { - onForeground.accept(new ForegroundInfo(cx - x, cy - y, line, gc.getFont(), - gc.getBackground(), gc.getForeground())); - } + String fullLine = whitespace != null ? getLabelLine(label, segStart) : null; + String rawFullLine = whitespace != null ? getRawLabelLine(rawLabel, label, segStart) : null; + int fromInLine = whitespace != null ? getFromInLine(label, segStart) : 0; + drawSegment(gc, fullLine, rawFullLine, fromInLine, line, whitespace, cx, cy, onForeground, x, y); Point p = gc.stringExtent(line); + // advance past lines[i] (raw, may include \r) plus the \n separator + segStart += lines[i].length() + 1; if (i < lines.length - 1) { + drawLineDelimiter(gc, whitespace, cx + p.x, cy, onForeground, x, y, + lines[i].endsWith("\r")); //$NON-NLS-1$ cy += textWidgetLineHeight + textWidget.getLineSpacing(); cx = x; } else { if (sub.endsWith("\n")) { //$NON-NLS-1$ + drawLineDelimiter(gc, whitespace, cx + p.x, cy, onForeground, x, y, + lines[i].endsWith("\r")); //$NON-NLS-1$ cy += textWidgetLineHeight + textWidget.getLineSpacing(); cx = x; } else { @@ -858,13 +1220,15 @@ static void drawStyleRanges(GC gc, StyledText textWidget, List range } } } else { - gc.drawString(sub, cx, cy, true); - if (onForeground != null) { - onForeground.accept(new ForegroundInfo(cx - x, cy - y, sub, gc.getFont(), - gc.getBackground(), gc.getForeground())); - } - Point p = gc.stringExtent(sub); + String fullLine = whitespace != null ? getLabelLine(label, range.start) : null; + String rawFullLine = whitespace != null ? getRawLabelLine(rawLabel, label, range.start) : null; + int fromInLine = whitespace != null ? getFromInLine(label, range.start) : 0; + String subDisplay = sub.replace("\r", "").replace("\n", ""); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + drawSegment(gc, fullLine, rawFullLine, fromInLine, subDisplay, whitespace, cx, cy, onForeground, x, y); + Point p = gc.stringExtent(subDisplay); if (sub.endsWith("\n")) { //$NON-NLS-1$ + drawLineDelimiter(gc, whitespace, cx + p.x, cy, onForeground, x, y, + sub.endsWith("\r\n")); //$NON-NLS-1$ cy += textWidgetLineHeight + textWidget.getLineSpacing(); cx = x; } else { @@ -873,22 +1237,223 @@ static void drawStyleRanges(GC gc, StyledText textWidget, List range } gc.setFont(currentFont); } else { - int lfCount = 0; - if (sub.contains("\n")) { //$NON-NLS-1$ - lfCount = sub.split("\n", -1).length - 1; //$NON-NLS-1$ - sub = sub.substring(sub.lastIndexOf("\n") + 1); //$NON-NLS-1$ - } - Point p = gc.stringExtent(sub); - if (lfCount > 0) { - cy += lfCount * (textWidgetLineHeight + textWidget.getLineSpacing()); + int segmentStart = 0; + while (segmentStart < sub.length()) { + int lf = sub.indexOf('\n', segmentStart); + int segmentEnd = lf < 0 ? sub.length() : lf; + String rawSegment = sub.substring(segmentStart, segmentEnd); + boolean hasCarriageReturn = rawSegment.endsWith("\r"); //$NON-NLS-1$ + String subDisplay = hasCarriageReturn ? rawSegment.substring(0, rawSegment.length() - 1) : rawSegment; + int labelOffset = range.start + segmentStart; + String fullLine = whitespace != null ? getLabelLine(label, labelOffset) : null; + String rawFullLine = whitespace != null ? getRawLabelLine(rawLabel, label, labelOffset) : null; + int fromInLine = whitespace != null ? getFromInLine(label, labelOffset) : 0; + String drawn; + if (whitespace != null) { + // clamp like drawSegment: a segment may extend past the known + // fullLine, in which case the extra characters are drawn unchanged + drawn = whitespace.applySegment(fullLine, rawFullLine, fromInLine, + Math.min(fromInLine + subDisplay.length(), fullLine.length())); + if (drawn.length() < subDisplay.length()) { + drawn = drawn + subDisplay.substring(drawn.length()); + } + } else { + drawn = subDisplay; + } + if (!subDisplay.isEmpty()) { + drawTextWithWhitespace(gc, subDisplay, drawn, whitespace, cx, cy, onForeground, x, y); + cx += gc.stringExtent(subDisplay).x; + } + if (lf < 0) { + break; + } + drawLineDelimiter(gc, whitespace, cx, cy, onForeground, x, y, hasCarriageReturn); + cy += textWidgetLineHeight + textWidget.getLineSpacing(); cx = x; + segmentStart = lf + 1; } - cx += p.x; } } + if (trailingCarriageReturn || trailingLineFeed) { + drawLineDelimiter(gc, whitespace, cx, cy, onForeground, x, y, trailingCarriageReturn, trailingLineFeed); + } gc.setFont(font); } + private static void drawLineDelimiter(GC gc, WhitespaceConfig whitespace, int x, int y, + Consumer onForeground, int originX, int originY, boolean hasCarriageReturn) { + drawLineDelimiter(gc, whitespace, x, y, onForeground, originX, originY, hasCarriageReturn, true); + } + + private static void drawLineDelimiter(GC gc, WhitespaceConfig whitespace, int x, int y, + Consumer onForeground, int originX, int originY, boolean hasCarriageReturn, + boolean hasLineFeed) { + String str = lineDelimiterMarker(whitespace, hasCarriageReturn, hasLineFeed); + if (str.isEmpty()) { + return; + } + int savedAlpha = gc.getAlpha(); + gc.setAlpha(whitespace.alpha()); + gc.drawString(str, x, y, true); + if (onForeground != null) { + onForeground.accept(new ForegroundInfo(x - originX, y - originY, str, gc.getFont(), gc.getBackground(), + gc.getForeground(), whitespace.alpha())); + } + gc.setAlpha(savedAlpha); + } + + private static String lineDelimiterMarker(WhitespaceConfig whitespace, boolean hasCarriageReturn, + boolean hasLineFeed) { + if (whitespace == null) { + return ""; //$NON-NLS-1$ + } + StringBuilder marker = new StringBuilder(2); + if (hasCarriageReturn && whitespace.showCarriageReturn()) { + marker.append(WhitespaceConfig.CARRIAGE_RETURN_SIGN); + } + if (hasLineFeed && whitespace.showLineFeed()) { + marker.append(WhitespaceConfig.LINE_FEED_SIGN); + } + return marker.toString(); + } + + /** + * Returns the start offset of the label line containing {@code offset} + * (i.e., the offset of the character right after the preceding {@code '\n'}, + * or 0 if {@code offset} is on the first line). + */ + private static int getLabelLineStart(String label, int offset) { + int prev = label.lastIndexOf('\n', offset - 1); + return prev < 0 ? 0 : prev + 1; + } + + /** + * Returns the full label line that contains {@code offset}, with any trailing + * {@code '\r'} stripped, and without the terminating {@code '\n'}. + */ + private static String getLabelLine(String label, int offset) { + int start = getLabelLineStart(label, offset); + int end = label.indexOf('\n', offset); + String raw = end < 0 ? label.substring(start) : label.substring(start, end); + return raw.endsWith("\r") ? raw.substring(0, raw.length() - 1) : raw; //$NON-NLS-1$ + } + + /** + * Returns the raw (tab-preserving) line that corresponds to the expanded label + * line containing {@code offset}, with any trailing {@code '\r'} stripped. The + * expanded and raw labels share the same line structure because + * {@link UnifiedDiffText#replaceTabWithSpaces} never changes line delimiters, so + * the line is located by index. Returns {@code null} when no raw label is + * available, in which case {@link WhitespaceConfig#applySegment} treats every + * column as a genuine space. + */ + private static String getRawLabelLine(String rawLabel, String label, int offset) { + if (rawLabel == null) { + return null; + } + int lineIndex = countLines(label, offset) - 1; + int start = 0; + for (int i = 0; i < lineIndex; i++) { + int nl = rawLabel.indexOf('\n', start); + if (nl < 0) { + return null; + } + start = nl + 1; + } + int end = rawLabel.indexOf('\n', start); + String raw = end < 0 ? rawLabel.substring(start) : rawLabel.substring(start, end); + return raw.endsWith("\r") ? raw.substring(0, raw.length() - 1) : raw; //$NON-NLS-1$ + } + + /** + * Returns the offset of {@code segStart} within the display line (with + * {@code '\r'} characters not counted, matching the CR-stripped strings drawn + * by {@link #drawStyleRanges}). + */ + private static int getFromInLine(String label, int segStart) { + int lineStart = getLabelLineStart(label, segStart); + String rawPrefix = label.substring(lineStart, segStart); + long crCount = rawPrefix.chars().filter(c -> c == '\r').count(); + return (int) (segStart - lineStart - crCount); + } + + /** + * Draws a single line segment at (cx, cy), applying whitespace glyph + * substitution if {@code whitespace} is non-null. {@code fullLine} is the + * complete display line (no newlines, no CR) to use for leading/enclosed/trailing + * classification; {@code fromInLine} is where {@code text} starts within it. The + * caller measures {@code text} itself; this method only draws. + */ + private static void drawSegment(GC gc, String fullLine, String rawFullLine, int fromInLine, String text, + WhitespaceConfig whitespace, int cx, int cy, Consumer onForeground, int originX, + int originY) { + if (whitespace == null) { + gc.drawString(text, cx, cy, true); + if (onForeground != null) { + onForeground.accept(new ForegroundInfo(cx - originX, cy - originY, text, gc.getFont(), + gc.getBackground(), gc.getForeground())); + } + return; + } + String applied = whitespace.applySegment(fullLine, rawFullLine, fromInLine, + Math.min(fromInLine + text.length(), fullLine.length())); + // if text extends beyond the known fullLine, append remaining chars unchanged + if (applied.length() < text.length()) { + applied = applied + text.substring(applied.length()); + } + // drawTextWithWhitespace short-circuits to a plain draw when nothing was + // substituted, so there is no separate fast path to keep here + drawTextWithWhitespace(gc, text, applied, whitespace, cx, cy, onForeground, originX, originY); + } + + private static void drawTextWithWhitespace(GC gc, String text, String displayed, WhitespaceConfig whitespace, + int x, int y, Consumer onForeground, int originX, int originY) { + if (displayed.equals(text)) { + // no glyph substitution happened (e.g. whitespace rendering is off for this + // kind of run), so skip the per-character prefix measuring entirely + gc.drawString(text, x, y, true); + if (onForeground != null) { + onForeground.accept(new ForegroundInfo(x - originX, y - originY, text, gc.getFont(), gc.getBackground(), + gc.getForeground())); + } + return; + } + int savedAlpha = gc.getAlpha(); + // Keep the same glyph positioning and rounding as rendering without visible + // whitespace. Splitting this into runs causes their widths to accumulate + // differently from the width used for the detailed-diff rectangles. + gc.drawString(text, x, y, true); + if (onForeground != null) { + onForeground.accept(new ForegroundInfo(x - originX, y - originY, text, gc.getFont(), gc.getBackground(), + gc.getForeground(), savedAlpha)); + } + // Walk left to right measuring each growing prefix once; the previous + // prefix width gives the cell width as a delta. Measuring substring(0, i) + // *and* substring(0, i + 1) per character would be twice as many native + // calls, and keeping the full-prefix measurement preserves the exact glyph + // positioning used for the detailed-diff rectangles. + int prevPrefixWidth = 0; + for (int i = 0; i < text.length(); i++) { + int prefixWidth = gc.stringExtent(text.substring(0, i + 1)).x; + if (displayed.charAt(i) != text.charAt(i)) { + int cellWidth = prefixWidth - prevPrefixWidth; + int indicatorX = x + prevPrefixWidth; + String indicator = displayed.substring(i, i + 1); + Rectangle clipping = gc.getClipping(); + gc.setClipping(indicatorX, clipping.y, cellWidth, clipping.height); + gc.setAlpha(whitespace.alpha()); + gc.drawString(indicator, indicatorX, y, true); + gc.setClipping(clipping); + if (onForeground != null) { + onForeground.accept(new ForegroundInfo(indicatorX - originX, y - originY, indicator, gc.getFont(), + gc.getBackground(), gc.getForeground(), whitespace.alpha(), cellWidth)); + } + } + prevPrefixWidth = prefixWidth; + } + gc.setAlpha(savedAlpha); + } + private static List computeStyleRanges(ITextViewer v, int offset, String source) { List result = new ArrayList<>(); if (!(v instanceof SourceViewer sv)) { @@ -919,6 +1484,10 @@ private static List computeStyleRanges(ITextViewer v, int offset, St public static class UnifiedDiffLineHeaderCodeMining extends LineHeaderCodeMining implements IUnifiedDiffCodeMining { private final String unifiedDiffLabel; + private final String layoutLabel; + private final String rawLabel; + private final boolean trailingCarriageReturn; + private final boolean trailingLineFeed; private final Color deletionBackgroundColor; private final Color detailedDiffColor; private final Color borderColor; @@ -933,64 +1502,38 @@ public static class UnifiedDiffLineHeaderCodeMining extends LineHeaderCodeMining private List foregrounds; private List styleRanges; private Font cachedFont; + private WhitespaceConfig cachedWhitespace; private final HashMap> styledFonts = new HashMap<>(); public UnifiedDiffLineHeaderCodeMining(Position position, ICodeMiningProvider provider, UnifiedDiff diff, int tabWidth, Color deletionBackgroundColor, Color detailedDiffColor, Color borderColor, ITextViewer viewer) throws BadLocationException { super(position, provider, new MouseClickConsumer(viewer)); - if (diff.mode.equals(UnifiedDiffMode.REPLACE_MODE)) { - this.unifiedDiffLabel = removeTrailingNewLines(replaceTabWithSpaces(diff.leftStr, tabWidth)); - } else { - this.unifiedDiffLabel = removeTrailingNewLines(replaceTabWithSpaces(diff.rightStr, tabWidth)); - } + String diffStr = diff.mode.equals(UnifiedDiffMode.REPLACE_MODE) ? diff.leftStr : diff.rightStr; + CodeMiningLabel label = codeMiningLabel(diffStr, tabWidth); + this.rawLabel = label.raw(); + this.unifiedDiffLabel = label.expanded(); + this.layoutLabel = label.layout(); + this.trailingCarriageReturn = label.trailingCarriageReturn(); + this.trailingLineFeed = label.trailingLineFeed(); this.deletionBackgroundColor = deletionBackgroundColor; this.detailedDiffColor = detailedDiffColor; this.borderColor = borderColor; this.diff = diff; this.tabWidth = tabWidth; this.viewer = viewer; - this.detailedDiffRanges = computeDetailedDiffRanges(diff); + this.detailedDiffRanges = computeDetailedDiffRanges(diff, this.rawLabel); ((MouseClickConsumer) getAction()).setCodeMining(this); } - /** - * Line range of a detailed diff, resolved once so painting does not scan - * the hunk text. - */ - private record DetailedDiffRange(String diffStr, int start, int length, int fromLine, int toLine) { - } - - private static List computeDetailedDiffRanges(UnifiedDiff diff) { - boolean useRight = diff.mode.equals(UnifiedDiffMode.OVERLAY_MODE) - || diff.mode.equals(UnifiedDiffMode.OVERLAY_READ_ONLY_MODE) - || diff.mode.equals(UnifiedDiffMode.REVERT_MODE); - String fullDiffStr = useRight ? diff.rightStr : diff.leftStr; - String diffStr = removeTrailingNewLines(fullDiffStr); - List result = new ArrayList<>(); - for (var detailedDiff : diff.detailedDiffs) { - String detailedDiffStr = useRight ? detailedDiff.rightStr : detailedDiff.leftStr; - int detailedDiffStart = useRight ? detailedDiff.rightStart : detailedDiff.leftStart; - int detailedDiffLength = useRight ? detailedDiff.rightLength : detailedDiff.leftLength; - if (detailedDiffStr.trim().length() == 0) { - continue; - } - detailedDiffLength = clampDetailedDiffLength(detailedDiffStart, detailedDiffLength, diffStr.length()); - if (detailedDiffLength <= 0) { - continue; - } - // String#split drops trailing empty strings, so it must not be used to - // count lines: a prefix ending with \n starts the next line - int fromLine = countLines(diffStr, detailedDiffStart); - int toLine = countLines(diffStr, detailedDiffStart + detailedDiffLength); - result.add(new DetailedDiffRange(diffStr, detailedDiffStart, detailedDiffLength, fromLine, toLine)); - } - return result; + @Override + public String getLabel() { + return this.layoutLabel; } @Override - public String getLabel() { - return this.unifiedDiffLabel; + public String getRawLabel() { + return this.rawLabel; } @Override @@ -1042,6 +1585,7 @@ private void cleanCachedData() { backgrounds = null; lastRectangle = null; cachedFont = null; + cachedWhitespace = null; clearStyledFonts(); } @@ -1066,6 +1610,10 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { // font might have been changed in the meantime - remove cache cleanCachedData(); } + WhitespaceConfig whitespace = readWhitespaceConfig(tabWidth); + if (foregrounds != null && !Objects.equals(cachedWhitespace, whitespace)) { + cleanCachedData(); + } cachedFont = font; if (lastRectangle != null && backgrounds != null && foregrounds != null) { boolean fontIsDisposed = false; @@ -1100,7 +1648,25 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { } gc.setBackground(f.background()); gc.setForeground(f.foreground()); - gc.drawString(f.str(), x + f.x(), y + f.y(), true); + int drawFx = x + f.x(); + int drawFy = y + f.y(); + boolean needsAlpha = f.alpha() != 255; + boolean needsClip = f.clipWidth() > 0; + int savedAlpha = needsAlpha ? gc.getAlpha() : 0; + Rectangle savedClipping = needsClip ? gc.getClipping() : null; + if (needsAlpha) { + gc.setAlpha(f.alpha()); + } + if (needsClip) { + gc.setClipping(drawFx, savedClipping.y, f.clipWidth(), savedClipping.height); + } + gc.drawString(f.str(), drawFx, drawFy, true); + if (needsClip) { + gc.setClipping(savedClipping); + } + if (needsAlpha) { + gc.setAlpha(savedAlpha); + } } if (!fontIsDisposed) { drawBandBorder(gc, this.borderColor, y, textWidget.getBounds().width, lastRectangle.height); @@ -1117,7 +1683,7 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { // the same! gc.fillRectangle(0, y, textWidget.getBounds().width /* result.x */, result.y); - String label = getLabel(); + String label = this.unifiedDiffLabel; List ranges = styleRanges(label); // draw darker background for detailed diff @@ -1213,13 +1779,23 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { } // last line starty = getYForLine(toLine - 1, y, gc, textWidget); - Point curr = getPositionForOffset(textWidget, gc, detailedDiffStart + detailedDiffLength, - diffStr, ranges, rangeInfo); - if (curr != null && curr.x > 0) { + int rectWidth = 0; + int rectHeight = textWidget.getLineHeight(); + if (range.coversTrailingDelimiter() + && detailedDiffStart + detailedDiffLength == diffStr.length()) { + String marker = lineDelimiterMarker(whitespace, trailingCarriageReturn, trailingLineFeed); + rectWidth = gc.stringExtent(marker).x; + } else { + Point curr = getPositionForOffset(textWidget, gc, detailedDiffStart + detailedDiffLength, + diffStr, ranges, rangeInfo); + if (curr != null) { + rectWidth = curr.x; + rectHeight = curr.y; + } + } + if (rectWidth > 0) { int rectX = x; int rectY = starty; - int rectWidth = curr.x; - int rectHeight = curr.y; if (starty >= 0) { gc.fillRectangle(rectX, rectY, rectWidth, rectHeight); } @@ -1241,8 +1817,10 @@ public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { return result; } foregrounds = new ArrayList<>(); + cachedWhitespace = whitespace; gc.setFont(cachedFont); - drawStyleRanges(gc, textWidget, ranges, label, styledFonts, x, y, foregrounds::add); + drawStyleRanges(gc, textWidget, ranges, label, rawLabel, styledFonts, x, y, foregrounds::add, whitespace, + trailingCarriageReturn, trailingLineFeed); drawBandBorder(gc, this.borderColor, y, textWidget.getBounds().width, lastRectangle.height); return result; } @@ -1310,8 +1888,9 @@ public Point getPositionForOffset(StyledText tw, GC gc, int offset, String str, gc.setFont(before); } // gc.stringExtent does not consider tabs - we need to replace them with spaces - // to get correct width - Point extent = gc.stringExtent(removeLeadingNewLines(replaceTabWithSpaces(sub, tabWidth))); + // to get correct width; \r must be stripped to match what drawStyleRanges draws + Point extent = gc.stringExtent( + removeLeadingNewLines(replaceTabWithSpaces(sub.replace("\r", ""), tabWidth))); //$NON-NLS-1$ //$NON-NLS-2$ if (result == null) { result = extent; result.y = twLineHeight; diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java index 9b23d567f21..d41bd9e0600 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java @@ -61,6 +61,7 @@ import org.eclipse.jface.commands.ActionHandler; import org.eclipse.jface.layout.GridDataFactory; import org.eclipse.jface.layout.GridLayoutFactory; +import org.eclipse.jface.preference.PreferenceConverter; import org.eclipse.jface.resource.ImageDescriptor; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.text.BadLocationException; @@ -68,8 +69,11 @@ import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentExtension4; import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.ITextViewerExtension5; import org.eclipse.jface.text.Position; +import org.eclipse.jface.text.Region; import org.eclipse.jface.text.codemining.ICodeMining; import org.eclipse.jface.text.source.Annotation; import org.eclipse.jface.text.source.AnnotationModel; @@ -86,6 +90,7 @@ import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel; import org.eclipse.jface.text.source.projection.ProjectionViewer; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.ControlEvent; import org.eclipse.swt.events.ControlListener; @@ -119,6 +124,7 @@ import org.eclipse.ui.contexts.IContextService; import org.eclipse.ui.handlers.IHandlerActivation; import org.eclipse.ui.handlers.IHandlerService; +import org.eclipse.ui.editors.text.EditorsUI; import org.eclipse.ui.texteditor.ITextEditor; import org.eclipse.ui.texteditor.ITextEditorActionDefinitionIds; @@ -412,7 +418,7 @@ public static IStatus open(ITextViewer viewer, IDocument leftDocument, IAnnotati Position position = new Position(unifiedDiff.leftStart, unifiedDiff.rightLength); addOrStage.accept(myAnnotation, position); for (var detailedDiff : unifiedDiff.detailedDiffs) { - if (detailedDiff.rightStr.trim().length() == 0) { + if (detailedDiff.rightLength == 0) { continue; } Annotation detailedAnno = new DetailedDiffAnnotation(mode, unifiedDiff); @@ -431,7 +437,7 @@ public static IStatus open(ITextViewer viewer, IDocument leftDocument, IAnnotati Position position = new Position(unifiedDiff.leftStart, unifiedDiff.leftLength); addOrStage.accept(myAnnotation, position); for (var detailedDiff : unifiedDiff.detailedDiffs) { - if (detailedDiff.leftStr.trim().length() == 0) { + if (detailedDiff.leftLength == 0) { continue; } Annotation detailedAnno = new DetailedDiffAnnotation(mode, unifiedDiff); @@ -1083,10 +1089,6 @@ private static List computeDiffs(IDocument leftDocument, IDocument int detailedRightEnd = getTokenEnd(r, detailedDiff.rightStart(), detailedDiff.rightLength()); String detailedDiffRightDiffSource = rightDiffSource.substring(detailedRightStart, detailedRightEnd); - if (detailedLeftDiffSource.trim().length() == 0 - && detailedDiffRightDiffSource.trim().length() == 0) { - continue; - } diff.detailedDiffs.add(new UnifiedDiff(leftDocument, detailedLeftStart, detailedLeftEnd, detailedLeftDiffSource, rightDocument, detailedRightStart, detailedRightEnd, detailedDiffRightDiffSource, unifiedDiffs, mode)); @@ -2019,12 +2021,14 @@ private static void addPaintListener(ITextViewer viewer, IAnnotationModel model, w.addPaintListener(new UnifiedDiffPaintListener(viewer, model, mode)); } - private static class UnifiedDiffPaintListener implements PaintListener { + private static class UnifiedDiffPaintListener implements PaintListener, DisposeListener { private final IAnnotationModel model; private final ITextViewer viewer; private final Color additionBackgroundColor; private final Color borderColor; + private final Color detailedAdditionBackgroundColor; + private final Color detailedDeletionBackgroundColor; private final StyledText w; public UnifiedDiffPaintListener(ITextViewer viewer, IAnnotationModel model, UnifiedDiffMode mode) { @@ -2041,9 +2045,19 @@ public UnifiedDiffPaintListener(ITextViewer viewer, IAnnotationModel model, Unif RGB interpolated = UnifiedDiffCodeMiningProvider.interpolate(color, background, 0.9); this.additionBackgroundColor = new Color(interpolated); this.borderColor = new Color(UnifiedDiffText.borderColor(color, background)); + this.detailedAdditionBackgroundColor = new Color(PreferenceConverter.getColor(EditorsUI.getPreferenceStore(), + "detailedInlineDiffColor")); //$NON-NLS-1$ + this.detailedDeletionBackgroundColor = new Color(PreferenceConverter.getColor(EditorsUI.getPreferenceStore(), + "detailedDeletionInlineDiffColor")); //$NON-NLS-1$ + w.addDisposeListener(this); } void dispose() { + if (!w.isDisposed()) { + w.removeDisposeListener(this); + } + detailedAdditionBackgroundColor.dispose(); + detailedDeletionBackgroundColor.dispose(); if (additionBackgroundColor != null && !additionBackgroundColor.isDisposed()) { additionBackgroundColor.dispose(); } @@ -2052,35 +2066,66 @@ void dispose() { } } + @Override + public void widgetDisposed(DisposeEvent e) { + dispose(); + } + + private record PaintRange(int offset, int length, Color background) { + } + @Override public void paintControl(PaintEvent e) { + // Reuse the provider's cached whitespace config instead of re-reading the + // store on every repaint; tabWidth is irrelevant to the line delimiters, so + // -1 returns the cached instance directly without allocating a stamped copy. + UnifiedDiffCodeMiningProvider.WhitespaceConfig whitespace = UnifiedDiffCodeMiningProvider + .readWhitespaceConfig(-1); + boolean showCarriageReturn = whitespace != null && whitespace.showCarriageReturn(); + boolean showLineFeed = whitespace != null && whitespace.showLineFeed(); + int whitespaceAlpha = whitespace != null ? whitespace.alpha() : 0; Rectangle bounds = this.w.getBounds(); // Only the damaged lines: getTextBounds lays out every line it is asked for, // so touching every hunk would make each repaint O(document). int firstDamagedLine = this.w.getLineIndex(e.y); int lastDamagedLine = this.w.getLineIndex(e.y + e.height); + List bands = new ArrayList<>(); + List details = new ArrayList<>(); Iterator it = this.model.getAnnotationIterator(); while (it.hasNext()) { Annotation anno = it.next(); - if (!(ADDITION_ANNO_TYPE.equals(anno.getType()) || DELETION_ANNO_TYPE.equals(anno.getType()))) { + String type = anno.getType(); + boolean main = ADDITION_ANNO_TYPE.equals(type) || DELETION_ANNO_TYPE.equals(type); + Color detailedBackground = DETAILED_ADDITION_ANNO_TYPE.equals(type) ? detailedAdditionBackgroundColor + : DETAILED_DELETION_ANNO_TYPE.equals(type) ? detailedDeletionBackgroundColor : null; + if (!main && (detailedBackground == null || !showCarriageReturn && !showLineFeed)) { continue; } - // Fill the area between the last character of the line and the widget boundary. - // This is not done via the Annotations. - int posOffset; - int posLength; - { - Position pos = this.model.getPosition(anno); - if (viewer instanceof ProjectionViewer pv) { - posOffset = pv.modelOffset2WidgetOffset(pos.offset); - if (posOffset < 0) { - continue; // not visible - } - } else { - posOffset = pos.offset; - } - posLength = pos.length; + Position pos = this.model.getPosition(anno); + if (pos == null || pos.isDeleted() || pos.length == 0) { + continue; + } + IRegion region = new Region(pos.offset, pos.length); + if (viewer instanceof ITextViewerExtension5 extension) { + region = extension.modelRange2WidgetRange(region); + } + if (region == null || region.getLength() == 0) { + continue; + } + int startLine = w.getLineAtOffset(region.getOffset()); + int endLine = w.getLineAtOffset(region.getOffset() + region.getLength() - 1); + if (endLine < firstDamagedLine || startLine > lastDamagedLine) { + continue; } + (main ? bands : details).add(new PaintRange(region.getOffset(), region.getLength(), detailedBackground)); + } + // Token ranges do not overlap. Sorting once avoids scanning every detailed + // range again for each delimiter character on the damaged lines. + details.sort(Comparator.comparingInt(PaintRange::offset)); + // All annotation lookups are complete before walking the damaged lines. + for (PaintRange band : bands) { + int posOffset = band.offset(); + int posLength = band.length(); int hunkFirstLine = this.w.getLineAtOffset(posOffset); // a band ending on a line delimiter belongs to the line it is on, not to // the next one the delimiter starts; a zero-length band paints nothing @@ -2103,10 +2148,9 @@ public void paintControl(PaintEvent e) { if (line.length() == 0) { // this.w.getTextBounds seems not to work for empty lines containing only \n in // a document with \r\n as line delimeter - int nextLineOffset = this.w.getOffsetAtLine(lineNr + 1); - Rectangle nextLineBounds = this.w.getTextBounds(nextLineOffset, nextLineOffset); - if (endLineBounds.y + endLineBounds.height < nextLineBounds.y) { - endLineBounds.height = nextLineBounds.y - endLineBounds.y; + int nextLineY = this.w.getLinePixel(lineNr + 1); + if (endLineBounds.y + endLineBounds.height < nextLineY) { + endLineBounds.height = nextLineY - endLineBounds.y; } } e.gc.fillRectangle(endLineBounds.x + endLineBounds.width, endLineBounds.y, @@ -2117,6 +2161,10 @@ public void paintControl(PaintEvent e) { e.gc.fillRectangle(2, endLineBounds.y + endLineBounds.height - this.w.getLineSpacing(), endLineBounds.x + endLineBounds.width, this.w.getLineSpacing()); + // The addition-area background fill above paints over the ¶/¤ markers the + // editor's own whitespace painter drew at the line end, so redraw them here. + drawLineDelimiter(e, endLineOffset, showCarriageReturn, showLineFeed, whitespaceAlpha, details); + // The marker background must not overwrite the band's shared edge. drawBandBorder(e.gc, bounds.width, endLineBounds.y, endLineBounds.height, lineNr == hunkFirstLine, lineNr == lastLine); e.gc.setBackground(this.additionBackgroundColor); @@ -2124,6 +2172,82 @@ public void paintControl(PaintEvent e) { } } + private void drawLineDelimiter(PaintEvent e, int endLineOffset, boolean showCarriageReturn, + boolean showLineFeed, int alpha, List details) { + if ((!showCarriageReturn && !showLineFeed) || endLineOffset >= w.getCharCount()) { + return; + } + if (isFoldedLine(w.getLineAtOffset(endLineOffset))) { + // the editor's own painter suppresses the marker on a collapsed region + return; + } + String delimiter = w.getTextRange(endLineOffset, Math.min(2, w.getCharCount() - endLineOffset)); + Point location = w.getLocationAtOffset(endLineOffset); + if (delimiter.startsWith("\r") && showCarriageReturn) { //$NON-NLS-1$ + location.x += drawDelimiterCharacter(e.gc, UnifiedDiffCodeMiningProvider.WhitespaceConfig.CARRIAGE_RETURN_SIGN, + endLineOffset, location, alpha, details); + } + if ((delimiter.startsWith("\n") || delimiter.startsWith("\r\n")) && showLineFeed) { //$NON-NLS-1$ //$NON-NLS-2$ + drawDelimiterCharacter(e.gc, UnifiedDiffCodeMiningProvider.WhitespaceConfig.LINE_FEED_SIGN, + endLineOffset + (delimiter.startsWith("\r") ? 1 : 0), location, alpha, details); //$NON-NLS-1$ + } + } + + private int drawDelimiterCharacter(GC gc, char sign, int offset, Point location, int alpha, + List details) { + String marker = String.valueOf(sign); + Point extent = gc.stringExtent(marker); + int savedAlpha = gc.getAlpha(); + Color savedBackground = gc.getBackground(); + Color savedForeground = gc.getForeground(); + Color background = detailedBackgroundAtOffset(offset, details); + if (background != null) { + gc.setAlpha(255); + gc.setBackground(background); + gc.fillRectangle(location.x, location.y, extent.x, extent.y); + } + gc.setAlpha(alpha); + gc.setForeground(foregroundAtOffset(offset)); + gc.drawString(marker, location.x, location.y, true); + gc.setForeground(savedForeground); + gc.setBackground(savedBackground); + gc.setAlpha(savedAlpha); + return extent.x; + } + + private Color detailedBackgroundAtOffset(int offset, List details) { + int low = 0; + int high = details.size() - 1; + while (low <= high) { + int middle = (low + high) >>> 1; + PaintRange detail = details.get(middle); + if (offset < detail.offset()) { + high = middle - 1; + } else if (offset >= detail.offset() + detail.length()) { + low = middle + 1; + } else { + return detail.background(); + } + } + return null; + } + + private Color foregroundAtOffset(int offset) { + StyleRange styleRange = offset < w.getCharCount() ? w.getStyleRangeAtOffset(offset) : null; + if (styleRange != null && styleRange.foreground != null) { + return styleRange.foreground; + } + return w.getForeground(); + } + + private boolean isFoldedLine(int widgetLine) { + if (viewer instanceof ITextViewerExtension5 extension) { + int modelLine = extension.widgetLine2ModelLine(widgetLine); + return extension.modelLine2WidgetLine(modelLine + 1) == -1; + } + return false; + } + /** Draws the thin top/bottom edge of the hunk band on its first/last line. */ private void drawBandBorder(GC gc, int width, int y, int height, boolean top, boolean bottom) { if (this.borderColor == null || this.borderColor.isDisposed() || width <= 0 || height <= 0) { diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffCodeMiningProviderTest.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffCodeMiningProviderTest.java index 4b5d6d5314f..9ff4016fb2b 100644 --- a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffCodeMiningProviderTest.java +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffCodeMiningProviderTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -42,12 +43,15 @@ import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jface.internal.text.codemining.CodeMiningLineHeaderAnnotation; +import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.ITextViewer; import org.eclipse.jface.text.Position; import org.eclipse.jface.text.TextAttribute; +import org.eclipse.jface.text.WhitespaceCharacterPainter; import org.eclipse.jface.text.codemining.ICodeMining; import org.eclipse.jface.text.codemining.ICodeMiningProvider; import org.eclipse.jface.text.presentation.IPresentationReconciler; @@ -76,9 +80,12 @@ import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.ImageData; import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.ui.editors.text.EditorsUI; +import org.eclipse.ui.texteditor.AbstractTextEditor; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -545,6 +552,392 @@ public void testFooterMiningMultiLineDetailedDiffResetsPerLine() throws Exceptio } } + @Test + public void testFooterMiningRendersWhitespaceWithoutMovingDetailedDiff() throws Exception { + switchToDocument(new Document("line 0\npublic public public")); + IStatus status = UnifiedDiffManager.open(viewer, document, model, null, + "line 0\npublic public public changed\n", MODE, null, null, null, null, + MergeViewerContentProvider.LEFT_CONTRIBUTOR, MergeViewerContentProvider.RIGHT_CONTRIBUTOR, true, + CONTEXT_LINES); + assertTrue(status.isOK(), "open() should succeed: " + status); + + UnifiedDiffFooterCodeMining footer = footerMining(); + Color deletionColor = new Color(display, 255, 255, 255); + Color detailedDiffColor = new Color(display, 44, 55, 66); + UnifiedDiffFooterCodeMining paintingFooter = new UnifiedDiffFooterCodeMining(document, provider, + footer.getUnifiedDiff(), 4, deletionColor, detailedDiffColor, null, viewer); + Image withoutWhitespace = new Image(display, 500, 100); + Image withWhitespace = new Image(display, 500, 100); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldShowWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldEnclosedSpaces = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, false); + draw(paintingFooter, withoutWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES, true); + draw(paintingFooter, withWhitespace); + + int withoutWhitespaceColumn = firstColumnWith(withoutWhitespace, detailedDiffColor); + int withWhitespaceColumn = firstColumnWith(withWhitespace, detailedDiffColor); + assertThat(withoutWhitespaceColumn).as("the detailed-diff background must be painted") + .isGreaterThanOrEqualTo(0); + assertEquals(withoutWhitespaceColumn, withWhitespaceColumn, + "visible whitespace must not move the detailed-diff background"); + assertThat(differentPixels(withoutWhitespace, withWhitespace)) + .as("visible spaces must add pixels to the rendered mining").isGreaterThan(0); + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldShowWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES, oldEnclosedSpaces); + withoutWhitespace.dispose(); + withWhitespace.dispose(); + paintingFooter.dispose(); + deletionColor.dispose(); + detailedDiffColor.dispose(); + } + } + + @Test + public void testHeaderMiningCachesWhitespaceWithoutMovingDetailedDiff() throws Exception { + switchToDocument(new Document("line 0\npublic public public\nline 2\n")); + IStatus status = UnifiedDiffManager.open(viewer, document, model, null, + "line 0\npublic public public changed\nline 2\n", MODE, null, null, null, null, + MergeViewerContentProvider.LEFT_CONTRIBUTOR, MergeViewerContentProvider.RIGHT_CONTRIBUTOR, true, + CONTEXT_LINES); + assertTrue(status.isOK(), "open() should succeed: " + status); + + UnifiedDiffLineHeaderCodeMining header = headerMining(); + Color deletionColor = new Color(display, 255, 255, 255); + Color detailedDiffColor = new Color(display, 44, 55, 66); + UnifiedDiffLineHeaderCodeMining paintingHeader = new UnifiedDiffLineHeaderCodeMining(header.getPosition(), + provider, header.getUnifiedDiff(), 4, deletionColor, detailedDiffColor, null, viewer); + Image withoutWhitespace = new Image(display, 500, 100); + Image firstWhitespacePaint = new Image(display, 500, 100); + Image cachedWhitespacePaint = new Image(display, 500, 100); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldShowWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldEnclosedSpaces = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, false); + draw(paintingHeader, withoutWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES, true); + draw(paintingHeader, firstWhitespacePaint); + draw(paintingHeader, cachedWhitespacePaint); + + int withoutWhitespaceColumn = firstColumnWith(withoutWhitespace, detailedDiffColor); + int withWhitespaceColumn = firstColumnWith(firstWhitespacePaint, detailedDiffColor); + assertThat(withoutWhitespaceColumn).as("the detailed-diff background must be painted") + .isGreaterThanOrEqualTo(0); + assertEquals(withoutWhitespaceColumn, withWhitespaceColumn, + "visible whitespace must not move the detailed-diff background"); + assertEquals(withWhitespaceColumn, firstColumnWith(cachedWhitespacePaint, detailedDiffColor), + "the cached repaint must preserve the detailed-diff position"); + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldShowWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_ENCLOSED_SPACES, oldEnclosedSpaces); + withoutWhitespace.dispose(); + firstWhitespacePaint.dispose(); + cachedWhitespacePaint.dispose(); + paintingHeader.dispose(); + deletionColor.dispose(); + detailedDiffColor.dispose(); + } + } + + @Test + public void testFooterMiningRendersLineFeedCharacters() throws Exception { + switchToDocument(new Document("line 0\nx")); + IStatus status = UnifiedDiffManager.open(viewer, document, model, null, "line 0\nfirst\nsecond\n", MODE, + null, null, null, null, MergeViewerContentProvider.LEFT_CONTRIBUTOR, + MergeViewerContentProvider.RIGHT_CONTRIBUTOR, true, CONTEXT_LINES); + assertTrue(status.isOK(), "open() should succeed: " + status); + + UnifiedDiffFooterCodeMining footer = footerMining(); + Color deletionColor = new Color(display, 255, 255, 255); + Color detailedDiffColor = new Color(display, 44, 55, 66); + UnifiedDiffFooterCodeMining paintingFooter = new UnifiedDiffFooterCodeMining(document, provider, + footer.getUnifiedDiff(), 4, deletionColor, detailedDiffColor, null, viewer); + Image withoutLineFeeds = new Image(display, 300, 100); + Image withLineFeeds = new Image(display, 300, 100); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldShowWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldShowLineFeed = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, false); + draw(paintingFooter, withoutLineFeeds); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, true); + draw(paintingFooter, withLineFeeds); + + assertThat(differentPixels(withoutLineFeeds, withLineFeeds)) + .as("line-feed markers must add pixels to the rendered mining").isGreaterThan(0); + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldShowWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, oldShowLineFeed); + withoutLineFeeds.dispose(); + withLineFeeds.dispose(); + paintingFooter.dispose(); + deletionColor.dispose(); + detailedDiffColor.dispose(); + } + } + + /** A trailing empty added CRLF line remains visible in a footer mining. */ + @Test + public void testFooterMiningRendersTrailingBlankLineDelimiters() throws Exception { + switchToDocument(new Document("line 0\r\npublic class MyClass {")); //$NON-NLS-1$ + String source = "line 0\r\npublic class MyClass { \r\n\r\n"; //$NON-NLS-1$ + IStatus status = UnifiedDiffManager.open(viewer, document, model, null, source, MODE, null, null, null, null, + MergeViewerContentProvider.LEFT_CONTRIBUTOR, MergeViewerContentProvider.RIGHT_CONTRIBUTOR, false, + CONTEXT_LINES); + assertTrue(status.isOK(), "open() should succeed: " + status); //$NON-NLS-1$ + + UnifiedDiffFooterCodeMining footer = footerMining(); + assertThat(footer.getUnifiedDiff().detailedDiffs) + .as("the word diff must retain the trailing-space and delimiter addition") //$NON-NLS-1$ + .isNotEmpty(); + Color deletionColor = new Color(display, 255, 255, 255); + Color detailedDiffColor = new Color(display, 44, 55, 66); + UnifiedDiffFooterCodeMining paintingFooter = new UnifiedDiffFooterCodeMining(document, provider, + footer.getUnifiedDiff(), 4, deletionColor, detailedDiffColor, null, viewer); + Image withoutLineDelimiters = new Image(display, 500, 100); + Image withLineDelimiters = new Image(display, 500, 100); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldShowWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldShowCarriageReturn = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN); + boolean oldShowLineFeed = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, false); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, false); + draw(paintingFooter, withoutLineDelimiters); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, true); + draw(paintingFooter, withLineDelimiters); + + int renderedHeight = paintingFooter.getLastRectangle().height; + int lineHeight = viewer.getTextWidget().getLineHeight(); + int secondLineTop = lineHeight + viewer.getTextWidget().getLineSpacing(); + int delimiterMarkerWidth; + GC markerGc = new GC(withLineDelimiters); + try { + markerGc.setFont(viewer.getTextWidget().getFont()); + delimiterMarkerWidth = markerGc.stringExtent("¤¶").x; //$NON-NLS-1$ + } finally { + markerGc.dispose(); + } + assertThat(renderedHeight).as("the footer mining must retain the trailing blank line") //$NON-NLS-1$ + .isGreaterThan(lineHeight); + int lastDetailedColumn = lastColumnWithInBand(withLineDelimiters.getImageData(), + detailedDiffColor.getRGB(), secondLineTop, renderedHeight); + assertThat(lastDetailedColumn) + .as("the added blank line's darker background must cover its CRLF marker") //$NON-NLS-1$ + .isGreaterThanOrEqualTo(0).isLessThan(delimiterMarkerWidth + 2); + assertThat(differentPixels(withoutLineDelimiters, withLineDelimiters, 0, lineHeight)) + .as("the added content line's CRLF marker must be rendered").isGreaterThan(0); //$NON-NLS-1$ + assertThat(differentPixels(withoutLineDelimiters, withLineDelimiters, secondLineTop, renderedHeight)) + .as("the added blank line's CRLF marker must be rendered").isGreaterThan(0); //$NON-NLS-1$ + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldShowWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, oldShowCarriageReturn); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, oldShowLineFeed); + withoutLineDelimiters.dispose(); + withLineDelimiters.dispose(); + paintingFooter.dispose(); + deletionColor.dispose(); + detailedDiffColor.dispose(); + } + } + + /** A trailing empty removed CRLF line remains visible as its own mining row. */ + @Test + public void testHeaderMiningRendersTrailingBlankLineDelimiters() throws Exception { + switchToDocument(new Document("line 0\r\npublic class MyClass {\r\n\t\r\nline 4\r\n")); //$NON-NLS-1$ + String source = "line 0\r\npublic class MyClass2 { \r\n\r\nline 4\r\n"; //$NON-NLS-1$ + IStatus status = UnifiedDiffManager.open(viewer, document, model, null, source, UnifiedDiffMode.REVERT_MODE, + null, null, null, null, MergeViewerContentProvider.LEFT_CONTRIBUTOR, + MergeViewerContentProvider.RIGHT_CONTRIBUTOR, false, CONTEXT_LINES); + assertTrue(status.isOK(), "open() should succeed: " + status); //$NON-NLS-1$ + List diffs = UnifiedDiffManager.get(viewer); + viewer.updateCodeMinings(); + waitForAttachedMinings(diffs, allRegions().size()); + + UnifiedDiffLineHeaderCodeMining header = headerMining(); + CodeMiningLineHeaderAnnotation annotation = assertInstanceOf(CodeMiningLineHeaderAnnotation.class, + attachedMiningAnnotationOf(header.getUnifiedDiff())); + assertThat(header.getUnifiedDiff().detailedDiffs) + .as("the word diff must retain the whitespace-only tab/space replacement") //$NON-NLS-1$ + .hasSize(2).anyMatch(diff -> diff.leftStr.contains("\t")); //$NON-NLS-1$ + int tabOffset = document.get().indexOf('\t'); + assertTrue(hasAnnotationCovering(".detailedAddition", tabOffset), //$NON-NLS-1$ + "the current tab must have a detailed-diff annotation"); //$NON-NLS-1$ + Color deletionColor = new Color(display, 255, 255, 255); + Color detailedDiffColor = new Color(display, 44, 55, 66); + UnifiedDiffLineHeaderCodeMining paintingHeader = new UnifiedDiffLineHeaderCodeMining(header.getPosition(), + provider, header.getUnifiedDiff(), 4, deletionColor, detailedDiffColor, null, viewer); + Image withoutLineDelimiters = new Image(display, 500, 100); + Image withLineDelimiters = new Image(display, 500, 100); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldShowWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldShowCarriageReturn = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN); + boolean oldShowLineFeed = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, false); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, false); + draw(paintingHeader, withoutLineDelimiters); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, true); + draw(paintingHeader, withLineDelimiters); + + int renderedHeight = paintingHeader.getLastRectangle().height; + assertThat(renderedHeight).as("the deletion mining must retain the trailing blank line") //$NON-NLS-1$ + .isGreaterThan(viewer.getTextWidget().getLineHeight()); + assertEquals(renderedHeight, annotation.getHeight(), + "the editor must reserve the full painted height so the current source line remains visible"); //$NON-NLS-1$ + assertThat(UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRanges(header.getUnifiedDiff(), 4, + detailedDiffColor)).as("the mining must retain the trailing-space detailed range").hasSize(2); //$NON-NLS-1$ + int lineHeight = viewer.getTextWidget().getLineHeight(); + int secondLineTop = lineHeight + viewer.getTextWidget().getLineSpacing(); + int delimiterMarkerWidth; + GC markerGc = new GC(withLineDelimiters); + try { + markerGc.setFont(viewer.getTextWidget().getFont()); + delimiterMarkerWidth = markerGc.stringExtent("¤¶").x; //$NON-NLS-1$ + } finally { + markerGc.dispose(); + } + assertThat(colorRunsInBand(withLineDelimiters, detailedDiffColor, 0, lineHeight)) + .as("the class-name and trailing-space changes must both have darker backgrounds") //$NON-NLS-1$ + .isGreaterThanOrEqualTo(2); + assertThat(lastColumnWithInBand(withLineDelimiters.getImageData(), detailedDiffColor.getRGB(), + secondLineTop, renderedHeight)) + .as("the removed blank line's darker background must cover only its CRLF marker") //$NON-NLS-1$ + .isGreaterThanOrEqualTo(0).isLessThan(delimiterMarkerWidth + 2); + assertThat(differentPixels(withoutLineDelimiters, withLineDelimiters, 0, lineHeight)) + .as("the removed content line's CRLF marker must be rendered").isGreaterThan(0); //$NON-NLS-1$ + assertThat(differentPixels(withoutLineDelimiters, withLineDelimiters, secondLineTop, renderedHeight)) + .as("the removed blank line's CRLF marker must be rendered").isGreaterThan(0); //$NON-NLS-1$ + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldShowWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, oldShowCarriageReturn); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, oldShowLineFeed); + withoutLineDelimiters.dispose(); + withLineDelimiters.dispose(); + paintingHeader.dispose(); + deletionColor.dispose(); + detailedDiffColor.dispose(); + } + } + + @Test + public void testClickOverlayUsesWhitespaceCharacterPainter() throws Exception { + switchToDocument(new Document("line 0\nline 1 changed")); + IStatus status = UnifiedDiffManager.open(viewer, document, model, null, "line 0\nline 1\n", MODE, null, + null, null, null, MergeViewerContentProvider.LEFT_CONTRIBUTOR, + MergeViewerContentProvider.RIGHT_CONTRIBUTOR, true, CONTEXT_LINES); + assertTrue(status.isOK(), "open() should succeed: " + status); + + UnifiedDiffFooterCodeMining footer = footerMining(); + Color deletionColor = new Color(display, 255, 255, 255); + Color detailedDiffColor = new Color(display, 44, 55, 66); + UnifiedDiffFooterCodeMining paintingFooter = new UnifiedDiffFooterCodeMining(document, provider, + footer.getUnifiedDiff(), 4, deletionColor, detailedDiffColor, null, viewer); + Image image = new Image(display, 300, 100); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldShowWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + try { + draw(paintingFooter, image); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + paintingFooter.getAction().accept(null); + + Control[] children = viewer.getTextWidget().getChildren(); + assertThat(children).as("clicking the mining creates an overlay").hasSize(1); + assertThat(children[0].getTypedListeners(SWT.Paint, WhitespaceCharacterPainter.class)) + .as("the overlay uses JFace's whitespace painter").hasSize(1); + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldShowWhitespace); + for (Control child : viewer.getTextWidget().getChildren()) { + child.dispose(); + } + image.dispose(); + paintingFooter.dispose(); + deletionColor.dispose(); + detailedDiffColor.dispose(); + } + } + + private void draw(ICodeMining mining, Image image) { + GC gc = new GC(image); + try { + mining.draw(gc, viewer.getTextWidget(), null, 0, 0); + } finally { + gc.dispose(); + } + } + + private UnifiedDiffFooterCodeMining footerMining() throws Exception { + for (ICodeMining mining : provide()) { + if (mining instanceof UnifiedDiffFooterCodeMining footer) { + return footer; + } + } + return fail("a footer mining must be present"); + } + + private UnifiedDiffLineHeaderCodeMining headerMining() throws Exception { + for (ICodeMining mining : provide()) { + if (mining instanceof UnifiedDiffLineHeaderCodeMining header) { + return header; + } + } + return fail("a line-header mining must be present"); + } + + private static int differentPixels(Image first, Image second) { + return differentPixels(first, second, 0, first.getBounds().height); + } + + private static int differentPixels(Image first, Image second, int yTop, int yBottom) { + ImageData firstData = first.getImageData(); + ImageData secondData = second.getImageData(); + int differences = 0; + for (int x = 0; x < firstData.width; x++) { + for (int y = Math.max(0, yTop); y < Math.min(firstData.height, yBottom); y++) { + RGB firstRgb = firstData.palette.getRGB(firstData.getPixel(x, y)); + RGB secondRgb = secondData.palette.getRGB(secondData.getPixel(x, y)); + if (!firstRgb.equals(secondRgb)) { + differences++; + } + } + } + return differences; + } + + private static int colorRunsInBand(Image image, Color color, int yTop, int yBottom) { + ImageData data = image.getImageData(); + RGB target = color.getRGB(); + int runs = 0; + boolean previousColumnMatches = false; + for (int x = 0; x < data.width; x++) { + boolean columnMatches = false; + for (int y = Math.max(0, yTop); y < Math.min(data.height, yBottom); y++) { + RGB rgb = data.palette.getRGB(data.getPixel(x, y)); + if (Math.abs(rgb.red - target.red) <= 2 && Math.abs(rgb.green - target.green) <= 2 + && Math.abs(rgb.blue - target.blue) <= 2) { + columnMatches = true; + break; + } + } + if (columnMatches && !previousColumnMatches) { + runs++; + } + previousColumnMatches = columnMatches; + } + return runs; + } + /** * Returns the rightmost column in {@code [yTop, yBottom)} holding * {@code target}, or {@code -1}. Matches with a small tolerance against @@ -768,6 +1161,19 @@ private Annotation attachedMiningAnnotationOf(UnifiedDiff diff) { return fail("no mining is attached for the diff on line " + diff.leftStart); } + private boolean hasAnnotationCovering(String typeSuffix, int offset) { + for (Iterator it = model.getAnnotationIterator(); it.hasNext();) { + Annotation annotation = it.next(); + Position position = model.getPosition(annotation); + String type = annotation.getType(); + if (type != null && type.endsWith(typeSuffix) && position != null && position.getOffset() <= offset + && offset < position.getOffset() + position.getLength()) { + return true; + } + } + return false; + } + private List collapsedRegions() { List result = new ArrayList<>(); ProjectionAnnotationModel projectionModel = viewer.getProjectionAnnotationModel(); diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java index e70931bc239..d1f50adeaf5 100644 --- a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java @@ -50,6 +50,8 @@ import org.eclipse.core.runtime.Platform; import org.eclipse.core.runtime.preferences.InstanceScope; import org.eclipse.core.tests.resources.util.WorkspaceResetExtension; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferenceConverter; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IRegion; @@ -58,6 +60,7 @@ import org.eclipse.jface.text.Region; import org.eclipse.jface.text.source.Annotation; import org.eclipse.jface.text.source.IAnnotationModel; +import org.eclipse.jface.text.source.IAnnotationModelExtension; import org.eclipse.jface.text.source.ISourceViewer; import org.eclipse.jface.text.source.inlined.LineHeaderAnnotation; import org.eclipse.jface.text.source.projection.ProjectionViewer; @@ -68,6 +71,11 @@ import org.eclipse.swt.events.PaintEvent; import org.eclipse.swt.events.PaintListener; import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Event; @@ -77,7 +85,9 @@ import org.eclipse.ui.PlatformUI; import org.eclipse.ui.actions.ActionFactory; import org.eclipse.ui.handlers.IHandlerService; +import org.eclipse.ui.editors.text.EditorsUI; import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.texteditor.AbstractTextEditor; import org.eclipse.ui.texteditor.ITextEditor; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -234,6 +244,179 @@ public void testRevertModeProducesAnnotationsAndPaints() { forcePaintCycle(viewer.getTextWidget()); } + @Test + public void testRevertModePaintsTrailingEmptyCrLfLine() throws BadLocationException { + String current = "package jp1;\r\n\r\n// Footer mining manual comparison fixture \r\n\r\n"; //$NON-NLS-1$ + String source = "package jp1;\r\n\r\n// Footer mining manual comparison fixture"; //$NON-NLS-1$ + assertEquals(63, current.length()); + assertEquals(58, source.length()); + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldCr = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN); + boolean oldLf = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, true); + setEditorContent(current); + + IStatus status = UnifiedDiff.create(editor, source, UnifiedDiffMode.REVERT_MODE).ignoreWhiteSpace(false).open(); + assertTrue(status.isOK(), "open() should return OK status: " + status); //$NON-NLS-1$ + + IAnnotationModel model = annotationModel(); + List additions = annotations(model, ADDITION_ANNO_TYPE); + assertThat(additions).as("the added trailing lines must have one main annotation").hasSize(1); //$NON-NLS-1$ + Position position = model.getPosition(additions.get(0)); + assertThat(position.offset + position.length) + .as("the addition annotation must include the final CRLF") //$NON-NLS-1$ + .isEqualTo(current.length()); + + StyledText tw = viewer().getTextWidget(); + forcePaintCycle(tw); + int offset = document().getLineOffset(3); + RGB background = PreferenceConverter.getColor(store, "detailedInlineDiffColor"); + assertDelimiterBackgrounds(tw, offset, "\r\n", background, background); + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, oldCr); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, oldLf); + } + } + + @Test + public void testDelimiterCharactersUseTheirOwnDetailedRange() throws BadLocationException { + for (UnifiedDiffMode mode : List.of(UnifiedDiffMode.REVERT_MODE, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE)) { + for (int[] range : new int[][] { { 0, 2 }, { 0, 1 }, { 1, 1 }, { 0, 0 } }) { + assertDelimiterDetailedRange(mode, "\r\n", range[0], range[1]); + } + for (String delimiter : List.of("\r", "\n")) { + assertDelimiterDetailedRange(mode, delimiter, 0, 1); + assertDelimiterDetailedRange(mode, delimiter, 0, 0); + } + } + } + + private void assertDelimiterDetailedRange(UnifiedDiffMode mode, String delimiter, int start, int length) + throws BadLocationException { + IPreferenceStore store = EditorsUI.getPreferenceStore(); + boolean oldWhitespace = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS); + boolean oldCr = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN); + boolean oldLf = store.getBoolean(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED); + boolean addition = mode == UnifiedDiffMode.REVERT_MODE; + String colorKey = addition ? "detailedInlineDiffColor" : "detailedDeletionInlineDiffColor"; + String oldColor = store.getString(colorKey); + boolean defaultColor = store.isDefault(colorKey); + RGB background = new RGB(73, 113, 159); + try { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, true); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, true); + PreferenceConverter.setValue(store, colorKey, background); + setEditorContent(("package jp1;\r\n\r\n// Footer mining manual comparison fixture \r\n\r\n") + .replace("\r\n", delimiter)); + assertTrue(UnifiedDiff.create(editor, + ("package jp1;\r\n\r\n// Other mining changed comparison words").replace("\r\n", delimiter), mode) + .ignoreWhiteSpace(false).open().isOK()); + IAnnotationModel model = annotationModel(); + List details = annotations(model, + addition ? DETAILED_ADDITION_ANNO_TYPE : DETAILED_DELETION_ANNO_TYPE); + assertThat(details).as("exercise lookup among multiple unordered token annotations").hasSizeGreaterThan(1); + int offset = document().getLineOffset(3); + Annotation delimiterDetail = details.stream() + .filter(annotation -> model.getPosition(annotation).overlapsWith(offset, delimiter.length())) + .findFirst().orElseThrow(); + // Restrict the token annotation to one delimiter character (or neither). + ((IAnnotationModelExtension) model).modifyAnnotationPosition(delimiterDetail, + new Position(offset + start, length)); + StyledText tw = viewer().getTextWidget(); + forcePaintCycle(tw); + int lfIndex = delimiter.indexOf('\n'); + assertDelimiterBackgrounds(tw, offset, delimiter, start == 0 && length > 0 ? background : null, + start <= lfIndex && lfIndex < start + length ? background : null); + } finally { + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_WHITESPACE_CHARACTERS, oldWhitespace); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_CARRIAGE_RETURN, oldCr); + store.setValue(AbstractTextEditor.PREFERENCE_SHOW_LINE_FEED, oldLf); + if (defaultColor) { + store.setToDefault(colorKey); + } else { + store.setValue(colorKey, oldColor); + } + } + } + + private static void assertDelimiterBackgrounds(StyledText tw, int offset, String delimiter, RGB crBackground, + RGB lfBackground) { + Point location = tw.getLocationAtOffset(offset); + Rectangle bounds = tw.getClientArea(); + Image image = new Image(tw.getDisplay(), bounds.width, bounds.height); + GC gc = new GC(image); + try { + gc.setFont(tw.getFont()); + gc.setForeground(tw.getForeground()); + gc.setBackground(tw.getBackground()); + gc.fillRectangle(bounds); + // Render the unified-diff paint listener into an image. Native widget capture + // depends on visibility and can convert colors through the display profile. + List listeners = tw.getTypedListeners(SWT.Paint, PaintListener.class) + .filter(listener -> listener.getClass().getEnclosingClass() == UnifiedDiffManager.class + && !listener.getClass().isAnonymousClass()).toList(); + assertThat(listeners).as("the unified-diff paint listener must be installed").hasSize(1); + firePaint(tw, gc, listeners); + int crWidth = delimiter.startsWith("\r") ? gc.stringExtent("¤").x : 0; + int width = crWidth + (delimiter.endsWith("\n") ? gc.stringExtent("¶").x : 0); + int height = gc.stringExtent("¤¶").y; + // Preserve native pixels: downscaling a Retina image blends the narrow + // delimiter background with the glyph and neighboring cells. + int zoom = tw.getMonitor().getZoom(); + ImageData data = image.getImageData(zoom); + location.x = location.x * zoom / 100; + location.y = location.y * zoom / 100; + crWidth = crWidth * zoom / 100; + width = width * zoom / 100; + height = height * zoom / 100; + RGB mainBackground = data.palette.getRGB(data.getPixel(location.x + width + 1, location.y + height / 2)); + if (crWidth > 0) { + assertMarkerOnBackground(data, location.x, location.x + crWidth, location.y, height, + crBackground != null ? crBackground : mainBackground, + crBackground != null ? mainBackground : lfBackground); + } + if (delimiter.endsWith("\n")) { + assertMarkerOnBackground(data, location.x + crWidth, location.x + width, location.y, height, + lfBackground != null ? lfBackground : mainBackground, + lfBackground != null ? mainBackground : crBackground); + } + } finally { + gc.dispose(); + image.dispose(); + } + } + + private static void assertMarkerOnBackground(ImageData image, int from, int to, int top, int height, RGB background, + RGB otherBackground) { + int backgroundPixels = 0; + int glyphPixels = 0; + int otherBackgroundPixels = 0; + for (int x = from; x < to; x++) { + // Exclude the band's border, which must not count as a glyph pixel. + for (int y = top + 1; y < top + height - 1; y++) { + RGB pixel = image.palette.getRGB(image.getPixel(x, y)); + if (pixel.equals(otherBackground)) { + otherBackgroundPixels++; + } + if (background.equals(pixel)) { + backgroundPixels++; + } else { + glyphPixels++; + } + } + } + assertThat(backgroundPixels).as("delimiter cell must have the annotation background") + .isGreaterThan(0); + assertThat(otherBackgroundPixels).as("delimiter cell must not retain the other annotation background").isZero(); + assertThat(glyphPixels).as("delimiter glyph must be visible").isGreaterThan(0); + } + // ------------------------------------------------------------ REPLACE_MODE // REPLACE_MODE rewrites the editor document diff by diff and shifts every @@ -1237,20 +1420,24 @@ private static void firePaint(StyledText tw) { } GC gc = new GC(tw); try { - Event event = new Event(); - event.widget = tw; - event.gc = gc; - event.x = 0; - event.y = 0; - event.width = Math.max(1, tw.getClientArea().width); - event.height = Math.max(1, tw.getClientArea().height); - PaintEvent paintEvent = new PaintEvent(event); - tw.getTypedListeners(SWT.Paint, PaintListener.class).forEach(listener -> listener.paintControl(paintEvent)); + firePaint(tw, gc, tw.getTypedListeners(SWT.Paint, PaintListener.class).toList()); } finally { gc.dispose(); } } + private static void firePaint(StyledText tw, GC gc, List listeners) { + Event event = new Event(); + event.widget = tw; + event.gc = gc; + event.x = 0; + event.y = 0; + event.width = Math.max(1, tw.getClientArea().width); + event.height = Math.max(1, tw.getClientArea().height); + PaintEvent paintEvent = new PaintEvent(event); + listeners.forEach(listener -> listener.paintControl(paintEvent)); + } + private void executeEditorCommand(String commandId) throws Exception { PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage().activate(editor); IHandlerService handlerService = editor.getSite().getService(IHandlerService.class); diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffTextTest.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffTextTest.java index 4b51f564a0f..0f459129e46 100644 --- a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffTextTest.java +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffTextTest.java @@ -35,6 +35,7 @@ import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider.UnifiedDiffLineHeaderCodeMining; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider.UnifiedDiffLineHeaderCodeMining.RangeInfo; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider.WhitespaceConfig; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffText; import org.eclipse.jface.text.Document; @@ -293,18 +294,68 @@ public void testClampDetailedDiffLengthNoOvershoot() { // --------------------------------- createDetailedDiffBackgroundRanges - /** A detailed diff reaching into trailing spaces is trimmed to the visible label, not dropped. */ + /** A detailed diff reaching into trailing spaces retains the complete painted label. */ @Test - public void testDetailedDiffRunningIntoTrailingSpacesIsTrimmedNotDropped() { + public void testDetailedDiffRunningIntoTrailingSpacesIsRetained() { String diffStr = "foo bar "; UnifiedDiff diff = replaceDiff(diffStr, detailedDiff(diffStr, 4, 6)); List ranges = UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRanges(diff, 4, BACKGROUND_1); - assertEquals(1, ranges.size(), "the highlight must survive, trimmed to the visible label"); + assertEquals(1, ranges.size(), "the highlight must include the trailing whitespace glyphs"); assertEquals(4, ranges.get(0).start, "highlight starts at 'bar'"); - assertEquals(3, ranges.get(0).length, "highlight is trimmed to 'bar', dropping the trailing spaces"); + assertEquals(6, ranges.get(0).length, "highlight includes 'bar' and its three trailing spaces"); + } + + /** + * The click-through overlay keeps the original tabs, so its background ranges + * must stay in raw coordinates — a highlight behind a tab-indented word is not + * shifted by the expanded tab width, unlike the band's expanded ranges. + */ + @Test + public void testDetailedDiffRawRangesUseUnexpandedOffsets() { + String diffStr = "\t\tbar"; + UnifiedDiff diff = replaceDiff(diffStr, detailedDiff(diffStr, 2, 3)); + + List expanded = UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRanges(diff, 4, + BACKGROUND_1); + List raw = UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRangesRaw(diff, 4, + BACKGROUND_1); + + assertEquals(1, expanded.size()); + assertEquals(8, expanded.get(0).start, "the band shifts 'bar' past the two four-wide tabs"); + assertEquals(1, raw.size()); + assertEquals(2, raw.get(0).start, "the overlay keeps the raw offset of 'bar'"); + assertEquals(3, raw.get(0).length, "'bar'"); + } + + @Test + public void testDetailedDiffRawRangesRetainTrailingSpaces() { + String diffStr = "\tbar "; + UnifiedDiff diff = replaceDiff(diffStr, detailedDiff(diffStr, 1, 6)); + + List raw = UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRangesRaw(diff, 4, + BACKGROUND_1); + + assertEquals(1, raw.size()); + assertEquals(1, raw.get(0).start, "highlight starts at 'bar' in raw coordinates"); + assertEquals(6, raw.get(0).length, "raw ranges include the trailing spaces too"); + } + + @Test + public void testDetailedDiffRawAndExpandedAgreeWithoutTabs() { + String diffStr = "foo bar "; + UnifiedDiff diff = replaceDiff(diffStr, detailedDiff(diffStr, 4, 6)); + + List expanded = UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRanges(diff, 4, + BACKGROUND_1); + List raw = UnifiedDiffCodeMiningProvider.createDetailedDiffBackgroundRangesRaw(diff, 4, + BACKGROUND_1); + + assertEquals(expanded.size(), raw.size()); + assertEquals(expanded.get(0).start, raw.get(0).start, "no tabs means no coordinate shift"); + assertEquals(expanded.get(0).length, raw.get(0).length); } // ------------------------------------------- borderColor @@ -449,4 +500,139 @@ private static StyleRange styledRange(int start, int length) { range.foreground = systemColor(SWT.COLOR_BLUE); return range; } + + // -------------------------------------------------- whitespace tab glyphs + + /** + * The label is tab-expanded to spaces before it is drawn, so the whitespace + * glyph substitution must learn from the raw line which expanded columns came + * from a tab and render those as a single {@code »} on the first column, like + * the editor's own painter, rather than one {@code ·} per expanded space. + */ + @Test + public void testApplySegmentLeadingTabBecomesTabSign() { + String raw = "\tx"; + String expanded = replaceTabWithSpaces(raw, 4); + WhitespaceConfig config = tabConfig(4, true, true, true); + assertEquals("» x", config.applySegment(expanded, raw, 0, expanded.length())); + } + + @Test + public void testApplySegmentMixedTabAndSpaceIndent() { + String raw = "\t x"; + String expanded = replaceTabWithSpaces(raw, 4); + WhitespaceConfig config = tabConfig(4, true, true, true); + // the tab fills four columns (» then three blanks); the genuine space is a dot + assertEquals("» ·x", config.applySegment(expanded, raw, 0, expanded.length())); + } + + @Test + public void testApplySegmentEnclosedTabShownTrailingTabHidden() { + String raw = "a\tb\t"; + String expanded = replaceTabWithSpaces(raw, 2); + WhitespaceConfig config = new WhitespaceConfig(false, false, false, false, false, false, false, true, false, + false, false, 255, 2); + // the enclosed tab shows its sign; the trailing tab stays blank + assertEquals("a» b ", config.applySegment(expanded, raw, 0, expanded.length())); + } + + @Test + public void testApplySegmentLeadingTabsOffDrawsNoSign() { + String raw = "\tx"; + String expanded = replaceTabWithSpaces(raw, 4); + WhitespaceConfig config = tabConfig(4, false, true, true); + assertEquals(expanded, config.applySegment(expanded, raw, 0, expanded.length())); + } + + /** + * A sub-range starting inside an expanded tab run keeps the first-vs-filler + * classification, so {@code »} is only drawn when the sub-range includes the + * tab's first column. + */ + @Test + public void testApplySegmentSubRangeInsideTab() { + String raw = "\t\tx"; + String expanded = replaceTabWithSpaces(raw, 4); + WhitespaceConfig config = tabConfig(4, true, true, true); + assertAll( // + () -> assertEquals(" » x", config.applySegment(expanded, raw, 2, expanded.length()), + "range starts on the second tab's first column"), // + () -> assertEquals(" ", config.applySegment(expanded, raw, 1, 4), + "range covers only filler columns, so no sign"), // + () -> assertEquals(3, config.applySegment(expanded, raw, 1, 4).length(), + "output length matches the requested span")); + } + + /** Without a raw line every column is treated as a genuine space. */ + @Test + public void testApplySegmentWithoutRawLineFallsBackToSpaces() { + String raw = "\tx"; + String expanded = replaceTabWithSpaces(raw, 4); + WhitespaceConfig config = tabConfig(4, true, true, true); + assertEquals("····x", config.applySegment(expanded, null, 0, expanded.length())); + } + + /** + * A line that is only whitespace has no text to be before or after, so the + * editor's painter shows a space whenever any of the leading/enclosed/trailing + * space flags is on. Each flag alone must therefore render the whole run. + */ + @Test + public void testApplySegmentAllWhitespaceLineHonoursEachFlag() { + String line = " "; + assertAll( // + () -> assertEquals("···", spaceConfig(true, false, false).applySegment(line, line, 0, line.length()), + "leading-only shows the whole blank line"), // + () -> assertEquals("···", spaceConfig(false, true, false).applySegment(line, line, 0, line.length()), + "enclosed-only shows the whole blank line"), // + () -> assertEquals("···", spaceConfig(false, false, true).applySegment(line, line, 0, line.length()), + "trailing-only shows the whole blank line"), // + () -> assertEquals(" ", spaceConfig(false, false, false).applySegment(line, line, 0, line.length()), + "with every flag off nothing is shown")); + } + + /** An all-whitespace line of tabs shows the tab sign if any tab flag is on. */ + @Test + public void testApplySegmentAllWhitespaceLineHonoursEachTabFlag() { + String raw = "\t"; + String expanded = replaceTabWithSpaces(raw, 4); + assertAll( // + () -> assertEquals("» ", tabConfig(4, true, false, false).applySegment(expanded, raw, 0, + expanded.length()), "leading-only shows the blank line's tab"), // + () -> assertEquals("» ", tabConfig(4, false, true, false).applySegment(expanded, raw, 0, + expanded.length()), "enclosed-only shows the blank line's tab"), // + () -> assertEquals("» ", tabConfig(4, false, false, true).applySegment(expanded, raw, 0, + expanded.length()), "trailing-only shows the blank line's tab")); + } + + /** The ideographic space U+3000 renders as its own sign, like the editor. */ + @Test + public void testApplySegmentIdeographicSpaceBecomesSign() { + String line = "a b"; + WhitespaceConfig config = new WhitespaceConfig(false, false, false, false, true, false, false, false, false, + false, false, 255, 4); + assertEquals("a°b", config.applySegment(line, line, 0, line.length()), + "the enclosed ideographic space shows the ° sign"); + } + + /** With the ideographic-space flags off, U+3000 is left as-is. */ + @Test + public void testApplySegmentIdeographicSpaceHiddenWhenFlagsOff() { + String line = "a b"; + // every space/tab flag on but all ideographic flags off + WhitespaceConfig config = new WhitespaceConfig(true, true, true, false, false, false, true, true, true, false, + false, 255, 4); + assertEquals("a b", config.applySegment(line, line, 0, line.length()), + "an ideographic space is not shown unless an ideographic flag is on"); + } + + private static WhitespaceConfig spaceConfig(boolean leading, boolean enclosed, boolean trailing) { + return new WhitespaceConfig(leading, enclosed, trailing, false, false, false, false, false, false, false, false, + 255, 4); + } + + private static WhitespaceConfig tabConfig(int tabWidth, boolean leading, boolean enclosed, boolean trailing) { + return new WhitespaceConfig(true, true, true, false, false, false, leading, enclosed, trailing, false, false, + 255, tabWidth); + } }