src/eric7/UI/CompareDialog.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2004 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a dialog to compare two files and show the result side by
8 side.
9 """
10
11 import re
12 from difflib import _mdiff, IS_CHARACTER_JUNK
13
14 from PyQt6.QtCore import QTimer, QEvent, pyqtSlot
15 from PyQt6.QtGui import QFontMetrics, QBrush, QTextCursor
16 from PyQt6.QtWidgets import QWidget, QApplication, QDialogButtonBox
17
18 from EricWidgets import EricMessageBox
19 from EricWidgets.EricMainWindow import EricMainWindow
20 from EricWidgets.EricPathPicker import EricPathPickerModes
21
22 import UI.PixmapCache
23
24 from .Ui_CompareDialog import Ui_CompareDialog
25
26 import Preferences
27
28
29 def sbsdiff(a, b, linenumberwidth=4):
30 """
31 Compare two sequences of lines; generate the delta for display side by
32 side.
33
34 @param a first sequence of lines (list of strings)
35 @param b second sequence of lines (list of strings)
36 @param linenumberwidth width (in characters) of the linenumbers (integer)
37 @yield tuples of differences. Each tuple is composed of strings as follows.
38 <ul>
39 <li>opcode -- one of e, d, i, r for equal, delete, insert,
40 replace</li>
41 <li>lineno a -- linenumber of sequence a</li>
42 <li>line a -- line of sequence a</li>
43 <li>lineno b -- linenumber of sequence b</li>
44 <li>line b -- line of sequence b</li>
45 </ul>
46 @ytype tuple of (str, str, str, str, str)
47 """
48 # __IGNORE_WARNING_D234r__
49 def removeMarkers(line):
50 """
51 Internal function to remove all diff markers.
52
53 @param line line to work on (string)
54 @return line without diff markers (string)
55 """
56 return (
57 line
58 .replace('\0+', "")
59 .replace('\0-', "")
60 .replace('\0^', "")
61 .replace('\1', "")
62 )
63
64 linenumberformat = "{{0:{0:d}d}}".format(linenumberwidth)
65 emptylineno = ' ' * linenumberwidth
66
67 for (ln1, l1), (ln2, l2), flag in _mdiff(a, b, None, None,
68 IS_CHARACTER_JUNK):
69 if not flag:
70 yield ('e', linenumberformat.format(ln1), l1,
71 linenumberformat.format(ln2), l2)
72 continue
73 if ln2 == "" and l2 in ("\r\n", "\n", "\r"):
74 yield ('d', linenumberformat.format(ln1), removeMarkers(l1),
75 emptylineno, l2)
76 continue
77 if ln1 == "" and l1 in ("\r\n", "\n", "\r"):
78 yield ('i', emptylineno, l1,
79 linenumberformat.format(ln2), removeMarkers(l2))
80 continue
81 yield ('r', linenumberformat.format(ln1), l1,
82 linenumberformat.format(ln2), l2)
83
84
85 class CompareDialog(QWidget, Ui_CompareDialog):
86 """
87 Class implementing a dialog to compare two files and show the result side
88 by side.
89 """
90 def __init__(self, files=None, parent=None):
91 """
92 Constructor
93
94 @param files list of files to compare and their label
95 (list of two tuples of two strings)
96 @param parent parent widget (QWidget)
97 """
98 super().__init__(parent)
99 self.setupUi(self)
100
101 if files is None:
102 files = []
103
104 self.file1Picker.setMode(EricPathPickerModes.OPEN_FILE_MODE)
105 self.file2Picker.setMode(EricPathPickerModes.OPEN_FILE_MODE)
106
107 self.diffButton = self.buttonBox.addButton(
108 self.tr("Compare"), QDialogButtonBox.ButtonRole.ActionRole)
109 self.diffButton.setToolTip(
110 self.tr("Press to perform the comparison of the two files"))
111 self.diffButton.setEnabled(False)
112 self.diffButton.setDefault(True)
113
114 self.firstButton.setIcon(UI.PixmapCache.getIcon("2uparrow"))
115 self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow"))
116 self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow"))
117 self.lastButton.setIcon(UI.PixmapCache.getIcon("2downarrow"))
118
119 self.totalLabel.setText(self.tr('Total: {0}').format(0))
120 self.changedLabel.setText(self.tr('Changed: {0}').format(0))
121 self.addedLabel.setText(self.tr('Added: {0}').format(0))
122 self.deletedLabel.setText(self.tr('Deleted: {0}').format(0))
123
124 self.updateInterval = 20 # update every 20 lines
125
126 self.vsb1 = self.contents_1.verticalScrollBar()
127 self.hsb1 = self.contents_1.horizontalScrollBar()
128 self.vsb2 = self.contents_2.verticalScrollBar()
129 self.hsb2 = self.contents_2.horizontalScrollBar()
130
131 self.on_synchronizeCheckBox_toggled(True)
132
133 self.__generateFormats()
134
135 # connect some of our widgets explicitly
136 self.file1Picker.textChanged.connect(self.__fileChanged)
137 self.file2Picker.textChanged.connect(self.__fileChanged)
138 self.vsb1.valueChanged.connect(self.__scrollBarMoved)
139 self.vsb1.valueChanged.connect(self.vsb2.setValue)
140 self.vsb2.valueChanged.connect(self.vsb1.setValue)
141
142 self.diffParas = []
143 self.currentDiffPos = -1
144
145 self.markerPattern = r"\0\+|\0\^|\0\-"
146
147 if len(files) == 2:
148 self.filesGroup.hide()
149 self.file1Picker.setText(files[0][1])
150 self.file2Picker.setText(files[1][1])
151 self.file1Label.setText(files[0][0])
152 self.file2Label.setText(files[1][0])
153 self.diffButton.hide()
154 QTimer.singleShot(0, self.on_diffButton_clicked)
155 else:
156 self.file1Label.hide()
157 self.file2Label.hide()
158
159 def __generateFormats(self):
160 """
161 Private method to generate the various text formats.
162 """
163 font = Preferences.getEditorOtherFonts("MonospacedFont")
164 self.contents_1.setFontFamily(font.family())
165 self.contents_1.setFontPointSize(font.pointSize())
166 self.contents_2.setFontFamily(font.family())
167 self.contents_2.setFontPointSize(font.pointSize())
168 self.fontHeight = QFontMetrics(self.contents_1.currentFont()).height()
169
170 self.cNormalFormat = self.contents_1.currentCharFormat()
171 self.cInsertedFormat = self.contents_1.currentCharFormat()
172 self.cInsertedFormat.setBackground(
173 QBrush(Preferences.getDiffColour("AddedColor")))
174 self.cDeletedFormat = self.contents_1.currentCharFormat()
175 self.cDeletedFormat.setBackground(
176 QBrush(Preferences.getDiffColour("RemovedColor")))
177 self.cReplacedFormat = self.contents_1.currentCharFormat()
178 self.cReplacedFormat.setBackground(
179 QBrush(Preferences.getDiffColour("ReplacedColor")))
180
181 def show(self, filename=None):
182 """
183 Public slot to show the dialog.
184
185 @param filename name of a file to use as the first file (string)
186 """
187 if filename:
188 self.file1Picker.setText(filename)
189 super().show()
190
191 def __appendText(self, pane, linenumber, line, charFormat,
192 interLine=False):
193 """
194 Private method to append text to the end of the contents pane.
195
196 @param pane text edit widget to append text to (QTextedit)
197 @param linenumber number of line to insert (string)
198 @param line text to insert (string)
199 @param charFormat text format to be used (QTextCharFormat)
200 @param interLine flag indicating interline changes (boolean)
201 """
202 tc = pane.textCursor()
203 tc.movePosition(QTextCursor.MoveOperation.End)
204 pane.setTextCursor(tc)
205 pane.setCurrentCharFormat(charFormat)
206 if interLine:
207 pane.insertPlainText("{0} ".format(linenumber))
208 for txt in re.split(self.markerPattern, line):
209 if txt:
210 if txt.count('\1'):
211 txt1, txt = txt.split('\1', 1)
212 tc = pane.textCursor()
213 tc.movePosition(QTextCursor.MoveOperation.End)
214 pane.setTextCursor(tc)
215 pane.setCurrentCharFormat(charFormat)
216 pane.insertPlainText(txt1)
217 tc = pane.textCursor()
218 tc.movePosition(QTextCursor.MoveOperation.End)
219 pane.setTextCursor(tc)
220 pane.setCurrentCharFormat(self.cNormalFormat)
221 pane.insertPlainText(txt)
222 else:
223 pane.insertPlainText("{0} {1}".format(linenumber, line))
224
225 def on_buttonBox_clicked(self, button):
226 """
227 Private slot called by a button of the button box clicked.
228
229 @param button button that was clicked (QAbstractButton)
230 """
231 if button == self.diffButton:
232 self.on_diffButton_clicked()
233
234 @pyqtSlot()
235 def on_diffButton_clicked(self):
236 """
237 Private slot to handle the Compare button press.
238 """
239 filename1 = self.file1Picker.text()
240 try:
241 with open(filename1, "r", encoding="utf-8") as f1:
242 lines1 = f1.readlines()
243 except OSError:
244 EricMessageBox.critical(
245 self,
246 self.tr("Compare Files"),
247 self.tr(
248 """<p>The file <b>{0}</b> could not be read.</p>""")
249 .format(filename1))
250 return
251
252 filename2 = self.file2Picker.text()
253 try:
254 with open(filename2, "r", encoding="utf-8") as f2:
255 lines2 = f2.readlines()
256 except OSError:
257 EricMessageBox.critical(
258 self,
259 self.tr("Compare Files"),
260 self.tr(
261 """<p>The file <b>{0}</b> could not be read.</p>""")
262 .format(filename2))
263 return
264
265 self.__compare(lines1, lines2)
266
267 def compare(self, lines1, lines2, name1="", name2=""):
268 """
269 Public method to compare two lists of text.
270
271 @param lines1 text to compare against (string or list of strings)
272 @param lines2 text to compare (string or list of strings)
273 @param name1 name to be shown for the first text (string)
274 @param name2 name to be shown for the second text (string)
275 """
276 if name1 == "" or name2 == "":
277 self.filesGroup.hide()
278 else:
279 self.file1Picker.setText(name1)
280 self.file1Picker.setReadOnly(True)
281 self.file2Picker.setText(name2)
282 self.file2Picker.setReadOnly(True)
283 self.diffButton.setEnabled(False)
284 self.diffButton.hide()
285
286 if isinstance(lines1, str):
287 lines1 = lines1.splitlines(True)
288 if isinstance(lines2, str):
289 lines2 = lines2.splitlines(True)
290
291 self.__compare(lines1, lines2)
292
293 def __compare(self, lines1, lines2):
294 """
295 Private method to compare two lists of text.
296
297 @param lines1 text to compare against (list of strings)
298 @param lines2 text to compare (list of strings)
299 """
300 self.contents_1.clear()
301 self.contents_2.clear()
302
303 self.__generateFormats()
304
305 # counters for changes
306 added = 0
307 deleted = 0
308 changed = 0
309
310 self.diffParas = []
311 self.currentDiffPos = -1
312 oldOpcode = ''
313 for paras, (opcode, ln1, l1, ln2, l2) in enumerate(
314 sbsdiff(lines1, lines2), start=1
315 ):
316 if opcode in 'idr':
317 if oldOpcode != opcode:
318 oldOpcode = opcode
319 self.diffParas.append(paras)
320 # update counters
321 if opcode == 'i':
322 added += 1
323 elif opcode == 'd':
324 deleted += 1
325 elif opcode == 'r':
326 changed += 1
327 if opcode == 'i':
328 format1 = self.cNormalFormat
329 format2 = self.cInsertedFormat
330 elif opcode == 'd':
331 format1 = self.cDeletedFormat
332 format2 = self.cNormalFormat
333 elif opcode == 'r':
334 if ln1.strip():
335 format1 = self.cReplacedFormat
336 else:
337 format1 = self.cNormalFormat
338 if ln2.strip():
339 format2 = self.cReplacedFormat
340 else:
341 format2 = self.cNormalFormat
342 else:
343 oldOpcode = ''
344 format1 = self.cNormalFormat
345 format2 = self.cNormalFormat
346 self.__appendText(self.contents_1, ln1, l1, format1, opcode == 'r')
347 self.__appendText(self.contents_2, ln2, l2, format2, opcode == 'r')
348 if not (paras % self.updateInterval):
349 QApplication.processEvents()
350
351 self.vsb1.setValue(0)
352 self.vsb2.setValue(0)
353 self.firstButton.setEnabled(False)
354 self.upButton.setEnabled(False)
355 self.downButton.setEnabled(
356 len(self.diffParas) > 0 and
357 (self.vsb1.isVisible() or self.vsb2.isVisible()))
358 self.lastButton.setEnabled(
359 len(self.diffParas) > 0 and
360 (self.vsb1.isVisible() or self.vsb2.isVisible()))
361
362 self.totalLabel.setText(self.tr('Total: {0}')
363 .format(added + deleted + changed))
364 self.changedLabel.setText(self.tr('Changed: {0}').format(changed))
365 self.addedLabel.setText(self.tr('Added: {0}').format(added))
366 self.deletedLabel.setText(self.tr('Deleted: {0}').format(deleted))
367
368 # move to the first difference
369 self.downButton.click()
370
371 def __moveTextToCurrentDiffPos(self):
372 """
373 Private slot to move the text display to the current diff position.
374 """
375 if 0 <= self.currentDiffPos < len(self.diffParas):
376 value = (self.diffParas[self.currentDiffPos] - 1) * self.fontHeight
377 self.vsb1.setValue(value)
378 self.vsb2.setValue(value)
379
380 def __scrollBarMoved(self, value):
381 """
382 Private slot to enable the buttons and set the current diff position
383 depending on scrollbar position.
384
385 @param value scrollbar position (integer)
386 """
387 tPos = value / self.fontHeight + 1
388 bPos = (value + self.vsb1.pageStep()) / self.fontHeight + 1
389
390 self.currentDiffPos = -1
391
392 if self.diffParas:
393 self.firstButton.setEnabled(tPos > self.diffParas[0])
394 self.upButton.setEnabled(tPos > self.diffParas[0])
395 self.downButton.setEnabled(bPos < self.diffParas[-1])
396 self.lastButton.setEnabled(bPos < self.diffParas[-1])
397
398 if tPos >= self.diffParas[0]:
399 for diffPos in self.diffParas:
400 self.currentDiffPos += 1
401 if tPos <= diffPos:
402 break
403
404 @pyqtSlot()
405 def on_upButton_clicked(self):
406 """
407 Private slot to go to the previous difference.
408 """
409 self.currentDiffPos -= 1
410 self.__moveTextToCurrentDiffPos()
411
412 @pyqtSlot()
413 def on_downButton_clicked(self):
414 """
415 Private slot to go to the next difference.
416 """
417 self.currentDiffPos += 1
418 self.__moveTextToCurrentDiffPos()
419
420 @pyqtSlot()
421 def on_firstButton_clicked(self):
422 """
423 Private slot to go to the first difference.
424 """
425 self.currentDiffPos = 0
426 self.__moveTextToCurrentDiffPos()
427
428 @pyqtSlot()
429 def on_lastButton_clicked(self):
430 """
431 Private slot to go to the last difference.
432 """
433 self.currentDiffPos = len(self.diffParas) - 1
434 self.__moveTextToCurrentDiffPos()
435
436 def __fileChanged(self):
437 """
438 Private slot to enable/disable the Compare button.
439 """
440 if (
441 not self.file1Picker.text() or
442 not self.file2Picker.text()
443 ):
444 self.diffButton.setEnabled(False)
445 else:
446 self.diffButton.setEnabled(True)
447
448 @pyqtSlot(bool)
449 def on_synchronizeCheckBox_toggled(self, sync):
450 """
451 Private slot to connect or disconnect the scrollbars of the displays.
452
453 @param sync flag indicating synchronisation status (boolean)
454 """
455 if sync:
456 self.hsb2.setValue(self.hsb1.value())
457 self.hsb1.valueChanged.connect(self.hsb2.setValue)
458 self.hsb2.valueChanged.connect(self.hsb1.setValue)
459 else:
460 self.hsb1.valueChanged.disconnect(self.hsb2.setValue)
461 self.hsb2.valueChanged.disconnect(self.hsb1.setValue)
462
463
464 class CompareWindow(EricMainWindow):
465 """
466 Main window class for the standalone dialog.
467 """
468 def __init__(self, files=None, parent=None):
469 """
470 Constructor
471
472 @param files list of files to compare and their label
473 (list of two tuples of two strings)
474 @param parent reference to the parent widget (QWidget)
475 """
476 super().__init__(parent)
477
478 self.setStyle(Preferences.getUI("Style"),
479 Preferences.getUI("StyleSheet"))
480
481 self.cw = CompareDialog(files, self)
482 self.cw.installEventFilter(self)
483 size = self.cw.size()
484 self.setCentralWidget(self.cw)
485 self.resize(size)
486
487 def eventFilter(self, obj, event):
488 """
489 Public method to filter events.
490
491 @param obj reference to the object the event is meant for (QObject)
492 @param event reference to the event object (QEvent)
493 @return flag indicating, whether the event was handled (boolean)
494 """
495 if event.type() == QEvent.Type.Close:
496 QApplication.exit()
497 return True
498
499 return False

eric ide

mercurial