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