eric6/UI/CompareDialog.py

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

eric ide

mercurial