|
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 |