eric6/UI/CompareDialog.py

changeset 6942
2602857055c5
parent 6645
ad476851d7e0
child 7192
a22eee00b052
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric6/UI/CompareDialog.py	Sun Apr 14 15:09:21 2019 +0200
@@ -0,0 +1,499 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2004 - 2019 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a dialog to compare two files and show the result side by
+side.
+"""
+
+from __future__ import unicode_literals
+try:
+    basestring    # __IGNORE_WARNING__
+except NameError:
+    basestring = str
+
+import re
+from difflib import _mdiff, IS_CHARACTER_JUNK
+
+from PyQt5.QtCore import QTimer, QEvent, pyqtSlot
+from PyQt5.QtGui import QFontMetrics, QBrush, QTextCursor
+from PyQt5.QtWidgets import QWidget, QApplication, QDialogButtonBox
+
+from E5Gui import E5MessageBox
+from E5Gui.E5MainWindow import E5MainWindow
+from E5Gui.E5PathPicker import E5PathPickerModes
+
+import UI.PixmapCache
+
+from .Ui_CompareDialog import Ui_CompareDialog
+
+import Preferences
+
+
+def sbsdiff(a, b, linenumberwidth=4):
+    """
+    Compare two sequences of lines; generate the delta for display side by
+    side.
+    
+    @param a first sequence of lines (list of strings)
+    @param b second sequence of lines (list of strings)
+    @param linenumberwidth width (in characters) of the linenumbers (integer)
+    @return a generator yielding tuples of differences. The tuple is composed
+        of strings as follows.
+        <ul>
+            <li>opcode -- one of e, d, i, r for equal, delete, insert,
+                replace</li>
+            <li>lineno a -- linenumber of sequence a</li>
+            <li>line a -- line of sequence a</li>
+            <li>lineno b -- linenumber of sequence b</li>
+            <li>line b -- line of sequence b</li>
+        </ul>
+    """
+    def removeMarkers(line):
+        """
+        Internal function to remove all diff markers.
+        
+        @param line line to work on (string)
+        @return line without diff markers (string)
+        """
+        return line\
+            .replace('\0+', "")\
+            .replace('\0-', "")\
+            .replace('\0^', "")\
+            .replace('\1', "")
+
+    linenumberformat = "{{0:{0:d}d}}".format(linenumberwidth)
+    emptylineno = ' ' * linenumberwidth
+    
+    for (ln1, l1), (ln2, l2), flag in _mdiff(a, b, None, None,
+                                             IS_CHARACTER_JUNK):
+        if not flag:
+            yield ('e', linenumberformat.format(ln1), l1,
+                   linenumberformat.format(ln2), l2)
+            continue
+        if ln2 == "" and l2 in ("\r\n", "\n", "\r"):
+            yield ('d', linenumberformat.format(ln1), removeMarkers(l1),
+                   emptylineno, l2)
+            continue
+        if ln1 == "" and l1 in ("\r\n", "\n", "\r"):
+            yield ('i', emptylineno, l1,
+                   linenumberformat.format(ln2), removeMarkers(l2))
+            continue
+        yield ('r', linenumberformat.format(ln1), l1,
+               linenumberformat.format(ln2), l2)
+
+
+class CompareDialog(QWidget, Ui_CompareDialog):
+    """
+    Class implementing a dialog to compare two files and show the result side
+    by side.
+    """
+    def __init__(self, files=None, parent=None):
+        """
+        Constructor
+        
+        @param files list of files to compare and their label
+            (list of two tuples of two strings)
+        @param parent parent widget (QWidget)
+        """
+        super(CompareDialog, self).__init__(parent)
+        self.setupUi(self)
+        
+        if files is None:
+            files = []
+        
+        self.file1Picker.setMode(E5PathPickerModes.OpenFileMode)
+        self.file2Picker.setMode(E5PathPickerModes.OpenFileMode)
+        
+        self.diffButton = self.buttonBox.addButton(
+            self.tr("Compare"), QDialogButtonBox.ActionRole)
+        self.diffButton.setToolTip(
+            self.tr("Press to perform the comparison of the two files"))
+        self.diffButton.setEnabled(False)
+        self.diffButton.setDefault(True)
+        
+        self.firstButton.setIcon(UI.PixmapCache.getIcon("2uparrow.png"))
+        self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow.png"))
+        self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow.png"))
+        self.lastButton.setIcon(UI.PixmapCache.getIcon("2downarrow.png"))
+        
+        self.totalLabel.setText(self.tr('Total: {0}').format(0))
+        self.changedLabel.setText(self.tr('Changed: {0}').format(0))
+        self.addedLabel.setText(self.tr('Added: {0}').format(0))
+        self.deletedLabel.setText(self.tr('Deleted: {0}').format(0))
+        
+        self.updateInterval = 20    # update every 20 lines
+        
+        self.vsb1 = self.contents_1.verticalScrollBar()
+        self.hsb1 = self.contents_1.horizontalScrollBar()
+        self.vsb2 = self.contents_2.verticalScrollBar()
+        self.hsb2 = self.contents_2.horizontalScrollBar()
+        
+        self.on_synchronizeCheckBox_toggled(True)
+        
+        self.__generateFormats()
+        
+        # connect some of our widgets explicitly
+        self.file1Picker.textChanged.connect(self.__fileChanged)
+        self.file2Picker.textChanged.connect(self.__fileChanged)
+        self.vsb1.valueChanged.connect(self.__scrollBarMoved)
+        self.vsb1.valueChanged.connect(self.vsb2.setValue)
+        self.vsb2.valueChanged.connect(self.vsb1.setValue)
+        
+        self.diffParas = []
+        self.currentDiffPos = -1
+        
+        self.markerPattern = r"\0\+|\0\^|\0\-"
+        
+        if len(files) == 2:
+            self.filesGroup.hide()
+            self.file1Picker.setText(files[0][1])
+            self.file2Picker.setText(files[1][1])
+            self.file1Label.setText(files[0][0])
+            self.file2Label.setText(files[1][0])
+            self.diffButton.hide()
+            QTimer.singleShot(0, self.on_diffButton_clicked)
+        else:
+            self.file1Label.hide()
+            self.file2Label.hide()
+    
+    def __generateFormats(self):
+        """
+        Private method to generate the various text formats.
+        """
+        font = Preferences.getEditorOtherFonts("MonospacedFont")
+        self.contents_1.setFontFamily(font.family())
+        self.contents_1.setFontPointSize(font.pointSize())
+        self.contents_2.setFontFamily(font.family())
+        self.contents_2.setFontPointSize(font.pointSize())
+        self.fontHeight = QFontMetrics(self.contents_1.currentFont()).height()
+        
+        self.cNormalFormat = self.contents_1.currentCharFormat()
+        self.cInsertedFormat = self.contents_1.currentCharFormat()
+        self.cInsertedFormat.setBackground(
+            QBrush(Preferences.getDiffColour("AddedColor")))
+        self.cDeletedFormat = self.contents_1.currentCharFormat()
+        self.cDeletedFormat.setBackground(
+            QBrush(Preferences.getDiffColour("RemovedColor")))
+        self.cReplacedFormat = self.contents_1.currentCharFormat()
+        self.cReplacedFormat.setBackground(
+            QBrush(Preferences.getDiffColour("ReplacedColor")))
+    
+    def show(self, filename=None):
+        """
+        Public slot to show the dialog.
+        
+        @param filename name of a file to use as the first file (string)
+        """
+        if filename:
+            self.file1Picker.setText(filename)
+        super(CompareDialog, self).show()
+        
+    def __appendText(self, pane, linenumber, line, charFormat,
+                     interLine=False):
+        """
+        Private method to append text to the end of the contents pane.
+        
+        @param pane text edit widget to append text to (QTextedit)
+        @param linenumber number of line to insert (string)
+        @param line text to insert (string)
+        @param charFormat text format to be used (QTextCharFormat)
+        @param interLine flag indicating interline changes (boolean)
+        """
+        tc = pane.textCursor()
+        tc.movePosition(QTextCursor.End)
+        pane.setTextCursor(tc)
+        pane.setCurrentCharFormat(charFormat)
+        if interLine:
+            pane.insertPlainText("{0} ".format(linenumber))
+            for txt in re.split(self.markerPattern, line):
+                if txt:
+                    if txt.count('\1'):
+                        txt1, txt = txt.split('\1', 1)
+                        tc = pane.textCursor()
+                        tc.movePosition(QTextCursor.End)
+                        pane.setTextCursor(tc)
+                        pane.setCurrentCharFormat(charFormat)
+                        pane.insertPlainText(txt1)
+                    tc = pane.textCursor()
+                    tc.movePosition(QTextCursor.End)
+                    pane.setTextCursor(tc)
+                    pane.setCurrentCharFormat(self.cNormalFormat)
+                    pane.insertPlainText(txt)
+        else:
+            pane.insertPlainText("{0} {1}".format(linenumber, line))
+
+    def on_buttonBox_clicked(self, button):
+        """
+        Private slot called by a button of the button box clicked.
+        
+        @param button button that was clicked (QAbstractButton)
+        """
+        if button == self.diffButton:
+            self.on_diffButton_clicked()
+        
+    @pyqtSlot()
+    def on_diffButton_clicked(self):
+        """
+        Private slot to handle the Compare button press.
+        """
+        filename1 = self.file1Picker.text()
+        try:
+            f1 = open(filename1, "r", encoding="utf-8")
+            lines1 = f1.readlines()
+            f1.close()
+        except IOError:
+            E5MessageBox.critical(
+                self,
+                self.tr("Compare Files"),
+                self.tr(
+                    """<p>The file <b>{0}</b> could not be read.</p>""")
+                .format(filename1))
+            return
+
+        filename2 = self.file2Picker.text()
+        try:
+            f2 = open(filename2, "r", encoding="utf-8")
+            lines2 = f2.readlines()
+            f2.close()
+        except IOError:
+            E5MessageBox.critical(
+                self,
+                self.tr("Compare Files"),
+                self.tr(
+                    """<p>The file <b>{0}</b> could not be read.</p>""")
+                .format(filename2))
+            return
+        
+        self.__compare(lines1, lines2)
+        
+    def compare(self, lines1, lines2, name1="", name2=""):
+        """
+        Public method to compare two lists of text.
+        
+        @param lines1 text to compare against (string or list of strings)
+        @param lines2 text to compare (string or list of strings)
+        @keyparam name1 name to be shown for the first text (string)
+        @keyparam name2 name to be shown for the second text (string)
+        """
+        if name1 == "" or name2 == "":
+            self.filesGroup.hide()
+        else:
+            self.file1Picker.setText(name1)
+            self.file1Picker.setReadOnly(True)
+            self.file2Picker.setText(name2)
+            self.file2Picker.setReadOnly(True)
+        self.diffButton.setEnabled(False)
+        self.diffButton.hide()
+        
+        if isinstance(lines1, basestring):
+            lines1 = lines1.splitlines(True)
+        if isinstance(lines2, basestring):
+            lines2 = lines2.splitlines(True)
+        
+        self.__compare(lines1, lines2)
+        
+    def __compare(self, lines1, lines2):
+        """
+        Private method to compare two lists of text.
+        
+        @param lines1 text to compare against (list of strings)
+        @param lines2 text to compare (list of strings)
+        """
+        self.contents_1.clear()
+        self.contents_2.clear()
+        
+        self.__generateFormats()
+        
+        # counters for changes
+        added = 0
+        deleted = 0
+        changed = 0
+        
+        paras = 1
+        self.diffParas = []
+        self.currentDiffPos = -1
+        oldOpcode = ''
+        for opcode, ln1, l1, ln2, l2 in sbsdiff(lines1, lines2):
+            if opcode in 'idr':
+                if oldOpcode != opcode:
+                    oldOpcode = opcode
+                    self.diffParas.append(paras)
+                    # update counters
+                    if opcode == 'i':
+                        added += 1
+                    elif opcode == 'd':
+                        deleted += 1
+                    elif opcode == 'r':
+                        changed += 1
+                if opcode == 'i':
+                    format1 = self.cNormalFormat
+                    format2 = self.cInsertedFormat
+                elif opcode == 'd':
+                    format1 = self.cDeletedFormat
+                    format2 = self.cNormalFormat
+                elif opcode == 'r':
+                    if ln1.strip():
+                        format1 = self.cReplacedFormat
+                    else:
+                        format1 = self.cNormalFormat
+                    if ln2.strip():
+                        format2 = self.cReplacedFormat
+                    else:
+                        format2 = self.cNormalFormat
+            else:
+                oldOpcode = ''
+                format1 = self.cNormalFormat
+                format2 = self.cNormalFormat
+            self.__appendText(self.contents_1, ln1, l1, format1, opcode == 'r')
+            self.__appendText(self.contents_2, ln2, l2, format2, opcode == 'r')
+            paras += 1
+            if not (paras % self.updateInterval):
+                QApplication.processEvents()
+        
+        self.vsb1.setValue(0)
+        self.vsb2.setValue(0)
+        self.firstButton.setEnabled(False)
+        self.upButton.setEnabled(False)
+        self.downButton.setEnabled(
+            len(self.diffParas) > 0 and
+            (self.vsb1.isVisible() or self.vsb2.isVisible()))
+        self.lastButton.setEnabled(
+            len(self.diffParas) > 0 and
+            (self.vsb1.isVisible() or self.vsb2.isVisible()))
+        
+        self.totalLabel.setText(self.tr('Total: {0}')
+                                    .format(added + deleted + changed))
+        self.changedLabel.setText(self.tr('Changed: {0}').format(changed))
+        self.addedLabel.setText(self.tr('Added: {0}').format(added))
+        self.deletedLabel.setText(self.tr('Deleted: {0}').format(deleted))
+
+    def __moveTextToCurrentDiffPos(self):
+        """
+        Private slot to move the text display to the current diff position.
+        """
+        if 0 <= self.currentDiffPos < len(self.diffParas):
+            value = (self.diffParas[self.currentDiffPos] - 1) * self.fontHeight
+            self.vsb1.setValue(value)
+            self.vsb2.setValue(value)
+    
+    def __scrollBarMoved(self, value):
+        """
+        Private slot to enable the buttons and set the current diff position
+        depending on scrollbar position.
+        
+        @param value scrollbar position (integer)
+        """
+        tPos = value / self.fontHeight + 1
+        bPos = (value + self.vsb1.pageStep()) / self.fontHeight + 1
+        
+        self.currentDiffPos = -1
+        
+        if self.diffParas:
+            self.firstButton.setEnabled(tPos > self.diffParas[0])
+            self.upButton.setEnabled(tPos > self.diffParas[0])
+            self.downButton.setEnabled(bPos < self.diffParas[-1])
+            self.lastButton.setEnabled(bPos < self.diffParas[-1])
+            
+            if tPos >= self.diffParas[0]:
+                for diffPos in self.diffParas:
+                    self.currentDiffPos += 1
+                    if tPos <= diffPos:
+                        break
+    
+    @pyqtSlot()
+    def on_upButton_clicked(self):
+        """
+        Private slot to go to the previous difference.
+        """
+        self.currentDiffPos -= 1
+        self.__moveTextToCurrentDiffPos()
+    
+    @pyqtSlot()
+    def on_downButton_clicked(self):
+        """
+        Private slot to go to the next difference.
+        """
+        self.currentDiffPos += 1
+        self.__moveTextToCurrentDiffPos()
+    
+    @pyqtSlot()
+    def on_firstButton_clicked(self):
+        """
+        Private slot to go to the first difference.
+        """
+        self.currentDiffPos = 0
+        self.__moveTextToCurrentDiffPos()
+    
+    @pyqtSlot()
+    def on_lastButton_clicked(self):
+        """
+        Private slot to go to the last difference.
+        """
+        self.currentDiffPos = len(self.diffParas) - 1
+        self.__moveTextToCurrentDiffPos()
+    
+    def __fileChanged(self):
+        """
+        Private slot to enable/disable the Compare button.
+        """
+        if not self.file1Picker.text() or \
+           not self.file2Picker.text():
+            self.diffButton.setEnabled(False)
+        else:
+            self.diffButton.setEnabled(True)
+
+    @pyqtSlot(bool)
+    def on_synchronizeCheckBox_toggled(self, sync):
+        """
+        Private slot to connect or disconnect the scrollbars of the displays.
+        
+        @param sync flag indicating synchronisation status (boolean)
+        """
+        if sync:
+            self.hsb2.setValue(self.hsb1.value())
+            self.hsb1.valueChanged.connect(self.hsb2.setValue)
+            self.hsb2.valueChanged.connect(self.hsb1.setValue)
+        else:
+            self.hsb1.valueChanged.disconnect(self.hsb2.setValue)
+            self.hsb2.valueChanged.disconnect(self.hsb1.setValue)
+
+
+class CompareWindow(E5MainWindow):
+    """
+    Main window class for the standalone dialog.
+    """
+    def __init__(self, files=None, parent=None):
+        """
+        Constructor
+        
+        @param files list of files to compare and their label
+            (list of two tuples of two strings)
+        @param parent reference to the parent widget (QWidget)
+        """
+        super(CompareWindow, self).__init__(parent)
+        
+        self.setStyle(Preferences.getUI("Style"),
+                      Preferences.getUI("StyleSheet"))
+        
+        self.cw = CompareDialog(files, self)
+        self.cw.installEventFilter(self)
+        size = self.cw.size()
+        self.setCentralWidget(self.cw)
+        self.resize(size)
+    
+    def eventFilter(self, obj, event):
+        """
+        Public method to filter events.
+        
+        @param obj reference to the object the event is meant for (QObject)
+        @param event reference to the event object (QEvent)
+        @return flag indicating, whether the event was handled (boolean)
+        """
+        if event.type() == QEvent.Close:
+            QApplication.exit()
+            return True
+        
+        return False

eric ide

mercurial