714351521c8f53863ef863209e33ef8cf3a2ac16
[idea/community.git] / platform / diff-impl / src / com / intellij / diff / tools / simple / SimpleAlignedDiffModel.kt
1 // Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package com.intellij.diff.tools.simple
3
4 import com.intellij.diff.tools.simple.SimpleAlignedDiffModel.ChangeIntersection.*
5 import com.intellij.diff.util.*
6 import com.intellij.openapi.Disposable
7 import com.intellij.openapi.editor.*
8 import com.intellij.openapi.editor.ex.EditorEx
9 import com.intellij.openapi.editor.ex.RangeMarkerEx
10 import com.intellij.openapi.editor.impl.EditorImpl
11 import com.intellij.openapi.editor.markup.HighlighterLayer
12 import com.intellij.openapi.editor.markup.HighlighterTargetArea
13 import com.intellij.openapi.editor.markup.RangeHighlighter
14 import com.intellij.openapi.editor.markup.TextAttributes
15 import com.intellij.openapi.util.Disposer
16 import com.intellij.ui.ColorUtil
17 import java.awt.Color
18 import java.awt.Graphics
19 import java.awt.Rectangle
20 import kotlin.math.abs
21
22 class SimpleAlignedDiffModel(private val viewer: SimpleDiffViewer) {
23   /**
24    * Changes mapped to corresponding change aligning inlays (INSERTED, DELETED, MODIFIED changes).
25    */
26   private val alignedInlays = mutableMapOf<SideAndChange, Inlay<ChangeAlignDiffInlayPresentation>>()
27
28   /**
29    * Inlay lines mapped to corresponding aligning empty line inlay from other diff side.
30    */
31   private val emptyInlays = mutableMapOf<InlayId, Inlay<EmptyLineAlignDiffInlayPresentation>>()
32   private val adjustedInlaysHeights = mutableMapOf<SideAndChange, Int>()
33   private val inlayHighlighters = mutableMapOf<Side, MutableList<RangeHighlighter>>()
34
35   init {
36     if (viewer.needAlignChanges()) {
37       val inlayListener = MyInlayModelListener()
38       viewer.getEditor(Side.LEFT).inlayModel.addListener(inlayListener, viewer)
39       viewer.getEditor(Side.RIGHT).inlayModel.addListener(inlayListener, viewer)
40     }
41   }
42
43   fun alignChange(change: SimpleDiffChange) {
44     if (!viewer.needAlignChanges()) return
45
46     when (change.diffType) {
47       TextDiffType.INSERTED -> {
48         addInlay(change, TextDiffType.INSERTED, Side.LEFT)
49       }
50       TextDiffType.DELETED -> {
51         addInlay(change, TextDiffType.DELETED, Side.RIGHT)
52       }
53       TextDiffType.MODIFIED -> {
54         addInlay(change, TextDiffType.MODIFIED, Side.LEFT)
55         addInlay(change, TextDiffType.MODIFIED, Side.RIGHT)
56       }
57     }
58   }
59
60   private fun addInlay(change: SimpleDiffChange, diffType: TextDiffType, inlaySide: Side) {
61     val changeSide = inlaySide.other()
62
63     val changeStartLine = change.getStartLine(changeSide)
64     val changeEndLine = change.getEndLine(changeSide)
65     val inlayStartLine = change.getStartLine(inlaySide)
66     val inlayEndLine = change.getEndLine(inlaySide)
67     val isLastLine = changeEndLine == DiffUtil.getLineCount(viewer.getEditor(changeSide).document)
68
69     val delta = (changeEndLine - changeStartLine) - (inlayEndLine - inlayStartLine)
70     if (delta <= 0) return
71
72     createAlignInlay(inlaySide, change, delta, isLastLine)
73       .also { createInlayHighlighter(inlaySide, it, diffType, isLastLine) }
74       .also { alignedInlays[SideAndChange(inlaySide, change)] = it }
75   }
76
77   private fun createInlayHighlighter(side: Side, inlay: Inlay<*>, type: TextDiffType, isLastLine: Boolean) {
78     val editor = viewer.getEditor(side)
79     val startOffset = inlay.offset
80     val endOffset = if (inlay is RangeMarker) inlay.endOffset else startOffset
81
82     val highlighter = editor.markupModel
83       .addRangeHighlighter(startOffset, endOffset, HighlighterLayer.SELECTION, TextAttributes(), HighlighterTargetArea.EXACT_RANGE)
84     if (type != TextDiffType.MODIFIED) {
85       highlighter.lineMarkerRenderer = DiffInlayMarkerRenderer(type, inlay, isLastLine)
86     }
87     inlayHighlighters.getOrPut(side) { mutableListOf() }.add(highlighter)
88   }
89
90   private fun createAlignInlay(side: Side,
91                                change: SimpleDiffChange,
92                                linesToAdd: Int,
93                                isLastLineToAdd: Boolean): Inlay<ChangeAlignDiffInlayPresentation> {
94     val editor = viewer.getEditor(side)
95     val offset = DiffUtil.getOffset(editor.document, change.getStartLine(side), 0)
96     val inlayPresentation = adjustedInlaysHeights.entries
97                               .find { it.key.side == side && it.key.change.isSame(change)}
98                               ?.let { ChangeAlignDiffInlayPresentation(editor, it.value, change.diffType) }
99                             ?: ChangeAlignDiffInlayPresentation(editor, editor.lineHeight * linesToAdd, change.diffType)
100
101     return editor.inlayModel
102       .addBlockElement(offset,
103         InlayProperties()
104           .showAbove(!isLastLineToAdd)
105           .priority(ALIGNED_CHANGE_INLAY_PRIORITY),
106         inlayPresentation)!!
107   }
108
109   fun clear() {
110     alignedInlays.values.forEach(Disposer::dispose)
111     alignedInlays.clear()
112     for ((side, highlighters) in inlayHighlighters) {
113       val markupModel = viewer.getEditor(side).markupModel
114       highlighters.forEach(markupModel::removeHighlighter)
115     }
116     inlayHighlighters.clear()
117   }
118
119   private open class BaseAlignDiffInlayPresentation(private val editor: EditorEx,
120                                                     var height: Int,
121                                                     var width: Int,
122                                                     private val inlayColor: Color? = null) : EditorCustomElementRenderer {
123
124     override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) {
125       editor as EditorImpl
126       val paintColor = inlayColor ?: return
127
128       g.color = paintColor
129       g.fillRect(targetRegion.x, targetRegion.y, editor.preferredSize.width, height)
130     }
131
132     override fun calcWidthInPixels(inlay: Inlay<*>): Int = width
133
134     override fun calcHeightInPixels(inlay: Inlay<*>): Int = height
135   }
136
137   private class EmptyLineAlignDiffInlayPresentation(editor: EditorEx, height: Int, inlayColor: Color? = editor.backgroundColor) :
138     BaseAlignDiffInlayPresentation(editor, height, editor.component.width, inlayColor)
139
140   private class ChangeAlignDiffInlayPresentation(editor: EditorEx, height: Int, diffType: TextDiffType) :
141     BaseAlignDiffInlayPresentation(editor, height, editor.component.width, getAlignedChangeColor(diffType, editor))
142
143   private inner class MyInlayModelListener : InlayModel.Listener {
144     override fun onAdded(inlay: Inlay<*>) = processInlay(inlay, ProcessType.ADDED)
145
146     override fun onRemoved(inlay: Inlay<*>) = processInlay(inlay, ProcessType.REMOVED)
147
148     override fun onUpdated(inlay: Inlay<*>, changeFlags: Int) {
149       if (changeFlags and InlayModel.ChangeFlags.HEIGHT_CHANGED != 0) {
150         processInlay(inlay, ProcessType.HEIGHT_UPDATED)
151       }
152     }
153   }
154
155   private enum class ProcessType { ADDED, REMOVED, HEIGHT_UPDATED }
156
157   private fun processInlay(inlay: Inlay<*>, processType: ProcessType) {
158     if (inlay.renderer is BaseAlignDiffInlayPresentation) return //skip self
159
160     val inlayLine = inlay.logicalLine
161     val inlaySide = if (viewer.getEditor(Side.LEFT) == inlay.editor) Side.LEFT else Side.RIGHT
162
163     if (needSkipInlay(inlay, inlaySide)) return
164
165     val alignSide = inlaySide.other()
166     val isAboveInlay = inlay.properties.isShownAbove
167     val lineToBeAligned = getRelatedLogicalLine(inlaySide, inlayLine, isAboveInlay)
168     val changeIntersection = getChangeIntersection(inlaySide, inlayLine)
169     val inlayId = InlayId(alignSide, inlay.offset, inlay.id)
170
171     when (processType) {
172       ProcessType.REMOVED -> {
173         changeAlignedInlayHeight(changeIntersection, alignSide) { affectedInlay ->
174           affectedInlay.renderer.height - inlay.heightInPixels
175         }
176         emptyInlays.remove(inlayId)?.let(Disposer::dispose)
177       }
178       ProcessType.HEIGHT_UPDATED -> {
179         changeAlignedInlayHeight(changeIntersection, alignSide) {
180           (changeIntersection as InsideChange).change.calculateDeltaHeight() + inlay.heightInPixels
181         }
182         emptyInlays[inlayId]?.run { renderer.height = inlay.heightInPixels; update() }
183       }
184       ProcessType.ADDED -> {
185         when (changeIntersection) {
186           is AboveChange -> {
187             val alignInlayPriority = if (isAboveInlay) ALIGNED_CHANGE_INLAY_PRIORITY else Int.MIN_VALUE
188             addEmptyInlay(inlayId, lineToBeAligned, inlay.heightInPixels, isAboveInlay, alignInlayPriority, parent = inlay)
189           }
190           is InsideChange -> {
191             changeAlignedInlayHeight(changeIntersection, alignSide) { affectedInlay ->
192               affectedInlay.renderer.height + inlay.heightInPixels
193             }
194             val change = changeIntersection.change
195             if (!alignedInlays.containsKey(SideAndChange(alignSide, change))) {
196               val alignInlayPriority = if (isAboveInlay) ALIGNED_CHANGE_INLAY_PRIORITY else Int.MIN_VALUE
197               val color = change.diffType.getColor(inlay.editor)
198               addEmptyInlay(inlayId, lineToBeAligned, inlay.heightInPixels, isAboveInlay, alignInlayPriority, color, parent = inlay)
199             }
200           }
201           is NoIntersection -> {
202             addEmptyInlay(inlayId, lineToBeAligned, inlay.heightInPixels, isAboveInlay, Int.MAX_VALUE, parent = inlay)
203           }
204         }
205       }
206     }
207   }
208
209   private fun needSkipInlay(inlay: Inlay<*>,
210                             inlaySide: Side): Boolean {
211     val alignSide = inlaySide.other()
212     val inlayEditor = viewer.getEditor(inlaySide)
213     val alignEditor = viewer.getEditor(alignSide)
214     //inlays added below line not supported, except last line
215     return !inlay.properties.isShownAbove && inlay.onLastLine &&
216            DiffUtil.getLineCount(alignEditor.document) <= DiffUtil.getLineCount(inlayEditor.document)
217   }
218
219   private fun changeAlignedInlayHeight(changeIntersection: ChangeIntersection, side: Side,
220                                        heightCalculator: (Inlay<ChangeAlignDiffInlayPresentation>) -> Int) {
221     if (changeIntersection !is InsideChange) return
222
223     val change = changeIntersection.change
224
225     val sideAndChange = SideAndChange(side, change)
226     alignedInlays[sideAndChange]
227       ?.run {
228         renderer.height = heightCalculator(this)
229         adjustedInlaysHeights[sideAndChange] = renderer.height
230         update()
231       }
232   }
233
234   private fun addEmptyInlay(inlayId: InlayId, line: Int, height: Int, above: Boolean, priority: Int,
235                             color: Color = viewer.getEditor(inlayId.side.other()).backgroundColor, parent: Disposable) {
236     val editor = viewer.getEditor(inlayId.side)
237     val offset = DiffUtil.getOffset(editor.document, line, 0)
238     val disposable = Disposable { emptyInlays.remove(inlayId)?.also(Disposer::dispose) }
239
240     emptyInlays[inlayId] =
241       editor.inlayModel.addBlockElement(offset,
242         InlayProperties().showAbove(above).priority(priority), EmptyLineAlignDiffInlayPresentation(editor, height, color))!!
243         .also { Disposer.register(parent, disposable) }
244   }
245
246   private fun getChangeIntersection(side: Side, logicalLine: Int): ChangeIntersection {
247     for (change in viewer.diffChanges) {
248       when {
249         change.isStartLine(side, logicalLine) -> return AboveChange
250         change.isMiddleLine(side, logicalLine) -> return InsideChange(change)
251       }
252     }
253
254     return NoIntersection
255   }
256
257   private fun SimpleDiffChange.isStartLine(side: Side, logicalLine: Int) = getStartLine(side) == logicalLine
258   private fun SimpleDiffChange.isMiddleLine(side: Side, logicalLine: Int) =
259     getStartLine(side) < logicalLine && getEndLine(side) - 1 >= logicalLine
260
261   private sealed class ChangeIntersection {
262     class InsideChange(val change: SimpleDiffChange) : ChangeIntersection()
263     object AboveChange : ChangeIntersection()
264     object NoIntersection : ChangeIntersection()
265   }
266
267   private data class InlayId(val side: Side, val offset: Int, val id: Long)
268   private data class SideAndChange(val side: Side, val change: SimpleDiffChange)
269
270   private fun getRelatedLogicalLine(side: Side, logicalLine: Int, isAboveInlay: Boolean): Int {
271     val needAlignLastLine = logicalLine == DiffUtil.getLineCount(viewer.getEditor(side).document) - 1
272
273     if (needAlignLastLine && !isAboveInlay) {
274       // for last line and below added inlay, related line should be always the last line (if there is no change intersection)
275       val alignSide = side.other()
276       val lastLine = DiffUtil.getLineCount(viewer.getEditor(alignSide).document) - 1
277       val changeIntersection = getChangeIntersection(alignSide, lastLine)
278       if (changeIntersection == NoIntersection) {
279         return lastLine
280       }
281     }
282
283     return viewer.transferPosition(side, LineCol(logicalLine, 0)).line
284   }
285
286   private val Inlay<*>.onLastLine get() = DiffUtil.getLineCount(editor.document) - 1 == logicalLine
287   private val Inlay<*>.logicalLine get() = editor.offsetToLogicalPosition(offset).line
288   private val Inlay<*>.id get() = (this as RangeMarkerEx).id
289
290   private fun SimpleDiffChange.isSame(other: SimpleDiffChange) =
291     getStartLine(Side.LEFT) == other.getStartLine(Side.LEFT) && getEndLine(Side.LEFT) == other.getEndLine(Side.LEFT) &&
292     getStartLine(Side.RIGHT) == other.getStartLine(Side.RIGHT) && getEndLine(Side.RIGHT) == other.getEndLine(Side.RIGHT)
293
294   private fun SimpleDiffChange.calculateDeltaHeight(): Int {
295     val leftStartLine = getStartLine(Side.LEFT)
296     val leftEndLine = getEndLine(Side.LEFT)
297     val rightStartLine = getStartLine(Side.RIGHT)
298     val rightEndLine = getEndLine(Side.RIGHT)
299
300     val delta = (leftEndLine - leftStartLine) - (rightEndLine - rightStartLine)
301
302     return abs(delta) * viewer.getEditor(Side.LEFT).lineHeight
303   }
304
305   companion object {
306     const val ALIGNED_CHANGE_INLAY_PRIORITY = 0
307
308     fun getAlignedChangeColor(type: TextDiffType, editor: Editor): Color? {
309       return if (type === TextDiffType.MODIFIED) null else type.getColor(editor).let { ColorUtil.toAlpha(it, 200) }
310     }
311   }
312 }