Start work on diff dialog

This commit is contained in:
Kovid Goyal 2014-01-26 15:27:27 +05:30
parent c4b82a9db8
commit 15bfb2aada
3 changed files with 242 additions and 27 deletions

View File

@ -429,7 +429,7 @@ class Container(object): # {{{
def raw_data(self, name, decode=True): def raw_data(self, name, decode=True):
ans = self.open(name).read() ans = self.open(name).read()
mime = self.mime_map.get(name, guess_type(name)) mime = self.mime_map.get(name, guess_type(name))
if decode and (mime in OEB_STYLES or mime in OEB_DOCS or mime[-4:] in {'+xml', '/xml'}): if decode and (mime in OEB_STYLES or mime in OEB_DOCS or mime == 'text/plain' or mime[-4:] in {'+xml', '/xml'}):
ans = self.decode(ans) ans = self.decode(ans)
return ans return ans

View File

@ -6,5 +6,235 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
from functools import partial
from PyQt4.Qt import (
QGridLayout, QToolButton, QIcon, QRadioButton, QMenu, QApplication, Qt,
QSize, QWidget, QLabel, QStackedLayout, QPainter, QRect, QVBoxLayout,
QCursor, QEventLoop)
from calibre.gui2 import info_dialog
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.tweak_book.editor import syntax_from_mime
from calibre.gui2.tweak_book.diff.view import DiffView
from calibre.gui2.tweak_book.widgets import Dialog
from calibre.gui2.widgets2 import HistoryLineEdit2
from calibre.utils.icu import numeric_sort_key
class BusyWidget(QWidget): # {{{
def __init__(self, parent):
QWidget.__init__(self, parent)
l = QVBoxLayout()
self.setLayout(l)
l.addStretch(10)
self.pi = ProgressIndicator(self, 128)
l.addWidget(self.pi, alignment=Qt.AlignHCenter)
self.dummy = QLabel('<h2>\xa0')
l.addSpacing(10)
l.addWidget(self.dummy, alignment=Qt.AlignHCenter)
l.addStretch(10)
self.text = _('Calculating differences, please wait...')
def paintEvent(self, ev):
br = ev.region().boundingRect()
QWidget.paintEvent(self, ev)
p = QPainter(self)
p.setClipRect(br)
f = p.font()
f.setBold(True)
f.setPointSize(20)
p.setFont(f)
p.setPen(Qt.SolidLine)
r = QRect(0, self.dummy.geometry().top() + 10, self.geometry().width(), 150)
p.drawText(r, Qt.AlignHCenter | Qt.AlignTop | Qt.TextSingleLine, self.text)
p.end()
# }}}
class Cache(object):
def __init__(self):
self._left, self._right = {}, {}
self.left, self.right = self._left.get, self._right.get
self.set_left, self.set_right = self._left.__setitem__, self._right.__setitem__
def changed_files(list_of_names1, list_of_names2, get_data1, get_data2):
list_of_names1, list_of_names2 = frozenset(list_of_names1), frozenset(list_of_names2)
changed_names = set()
cache = Cache()
common_names = list_of_names1.intersection(list_of_names2)
for name in common_names:
left, right = get_data1(name), get_data2(name)
if len(left) == len(right) and left == right:
continue
cache.set_left(name, left), cache.set_right(name, right)
changed_names.add(name)
removals = list_of_names1 - common_names
adds = set(list_of_names2 - common_names)
adata, rdata = {a:get_data2(a) for a in adds}, {r:get_data1(r) for r in removals}
ahash = {a:hash(d) for a, d in adata.iteritems()}
rhash = {r:hash(d) for r, d in rdata.iteritems()}
renamed_names, removed_names, added_names = {}, set(), set()
for name, rh in rhash.iteritems():
for n, ah in ahash.iteritems():
if ah == rh:
renamed_names[name] = n
adds.discard(n)
break
else:
cache.set_left(name, adata[name])
removed_names.add(name)
for name in adds:
cache.set_right(name, adata[name])
added_names.add(name)
return cache, changed_names, renamed_names, removed_names, added_names
def container_diff(left, right):
cache, changed_names, renamed_names, removed_names, added_names = changed_files(
left.name_path_map, right.name_path_map, left.raw_data, right.raw_data)
def syntax(container, name):
mt = container.mime_map[name]
return syntax_from_mime(name, mt)
syntax_map = {name:syntax(left, name) for name in changed_names}
syntax_map.update({name:syntax(left, name) for name in renamed_names})
syntax_map.update({name:syntax(right, name) for name in added_names})
syntax_map.update({name:syntax(left, name) for name in removed_names})
return cache, syntax_map, changed_names, renamed_names, removed_names, added_names
def ebook_diff(path1, path2):
from calibre.ebooks.oeb.polish.container import get_container
left = get_container(path1, tweak_mode=True)
right = get_container(path2, tweak_mode=True)
return container_diff(left, right)
class Diff(Dialog):
def __init__(self, parent=None):
self.context = 3
self.apply_diff_calls = []
Dialog.__init__(self, _('Differences between books'), 'diff-dialog', parent=parent)
def sizeHint(self):
geom = QApplication.instance().desktop().availableGeometry(self)
return QSize(int(0.9 * geom.width()), int(0.8 * geom.height()))
def setup_ui(self):
self.stacks = st = QStackedLayout(self)
self.busy = BusyWidget(self)
self.w = QWidget(self)
st.addWidget(self.busy), st.addWidget(self.w)
self.setLayout(st)
self.l = l = QGridLayout()
self.w.setLayout(l)
self.view = v = DiffView(self)
l.addWidget(v, l.rowCount(), 0, 1, -1)
self.search = s = HistoryLineEdit2(self)
s.initialize('diff_search_history')
l.addWidget(s, l.rowCount(), 0, 1, 1)
s.setPlaceholderText(_('Search'))
s.returnPressed.connect(partial(self.do_search, False))
self.sbn = b = QToolButton(self)
b.setIcon(QIcon(I('arrow-down.png')))
b.clicked.connect(partial(self.do_search, False))
b.setToolTip(_('Find next match'))
l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1)
self.sbp = b = QToolButton(self)
b.setIcon(QIcon(I('arrow-up.png')))
b.clicked.connect(partial(self.do_search, True))
b.setToolTip(_('Find previous match'))
l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1)
self.lb = b = QRadioButton(_('Left panel'), self)
b.setToolTip(_('Perform search in the left panel'))
l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1)
self.rb = b = QRadioButton(_('Right panel'), self)
b.setToolTip(_('Perform search in the right panel'))
l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1)
b.setChecked(True)
self.pb = b = QToolButton(self)
b.setIcon(QIcon(I('config.png')))
b.setToolTip(_('Change the amount of context shown around the changes'))
b.setPopupMode(b.InstantPopup)
m = QMenu(b)
b.setMenu(m)
for i in (3, 5, 10, 50):
m.addAction(_('Show %d lines of context around changes') % i, partial(self.change_context, i))
m.addAction(_('Show all text'), partial(self.change_context, None))
l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1)
self.bb.setStandardButtons(self.bb.Close)
l.addWidget(self.bb, l.rowCount(), 0, 1, -1)
self.view.setFocus(Qt.OtherFocusReason)
def do_search(self, reverse):
pass
def change_context(self, context):
if context == self.context:
return
self.context = context
with self:
self.view.clear()
for args, kwargs in self.apply_diff_calls:
kwargs['context'] = self.context
self.view.add_diff(*args, **kwargs)
self.view.finalize()
def __enter__(self):
self.stacks.setCurrentIndex(0)
self.busy.pi.startAnimation()
QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
QApplication.processEvents(QEventLoop.ExcludeUserInputEvents | QEventLoop.ExcludeSocketNotifiers)
def __exit__(self, *args):
self.busy.pi.stopAnimation()
self.stacks.setCurrentIndex(1)
QApplication.restoreOverrideCursor()
def ebook_diff(self, path1, path2):
with self:
self.apply_diff(_('The books are identical'), *ebook_diff(path1, path2))
self.view.finalize()
def apply_diff(self, identical_msg, cache, syntax_map, changed_names, renamed_names, removed_names, added_names):
self.view.clear()
self.apply_diff_calls = calls = []
def add(args, kwargs):
self.view.add_diff(*args, **kwargs)
calls.append((args, kwargs))
if len(changed_names) + len(renamed_names) + len(removed_names) + len(added_names) < 1:
return info_dialog(self, _('No changes found'), identical_msg, show=True)
for name in sorted(changed_names, key=numeric_sort_key):
args = (name, name, cache.left(name), cache.right(name))
kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context}
add(args, kwargs)
for name in sorted(added_names, key=numeric_sort_key):
args = (_('[This file was added]'), name, None, cache.right(name))
kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context}
add(args, kwargs)
for name in sorted(removed_names, key=numeric_sort_key):
args = (name, _('[This file was removed]'), cache.left(name), None)
kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context}
add(args, kwargs)
for name, new_name in sorted(renamed_names.iteritems(), key=lambda x:numeric_sort_key(x[0])):
args = (name, new_name, None, None)
kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context}
add(args, kwargs)
if __name__ == '__main__':
import sys
app = QApplication([])
d = Diff()
d.show()
d.ebook_diff(sys.argv[-2], sys.argv[-1])
app.exec_()

View File

@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import sys, re, unicodedata, os import re, unicodedata
from math import ceil from math import ceil
from functools import partial from functools import partial
from collections import namedtuple, OrderedDict from collections import namedtuple, OrderedDict
@ -21,9 +21,7 @@ from PyQt4.Qt import (
QImage, QPixmap, QMenu, QIcon) QImage, QPixmap, QMenu, QIcon)
from calibre import human_readable, fit_image from calibre import human_readable, fit_image
from calibre.ebooks.oeb.polish.utils import guess_type
from calibre.gui2.tweak_book import tprefs from calibre.gui2.tweak_book import tprefs
from calibre.gui2.tweak_book.editor import syntax_from_mime
from calibre.gui2.tweak_book.editor.text import PlainTextEdit, get_highlighter, default_font_family, LineNumbers from calibre.gui2.tweak_book.editor.text import PlainTextEdit, get_highlighter, default_font_family, LineNumbers
from calibre.gui2.tweak_book.editor.themes import THEMES, default_theme, theme_color from calibre.gui2.tweak_book.editor.themes import THEMES, default_theme, theme_color
from calibre.utils.diff import get_sequence_matcher from calibre.utils.diff import get_sequence_matcher
@ -445,7 +443,7 @@ class DiffSplit(QSplitter): # {{{
def add_diff(self, left_name, right_name, left_text, right_text, context=None, syntax=None): def add_diff(self, left_name, right_name, left_text, right_text, context=None, syntax=None):
left_text, right_text = left_text or '', right_text or '' left_text, right_text = left_text or '', right_text or ''
is_identical = len(left_text) == len(right_text) and left_text == right_text is_identical = len(left_text) == len(right_text) and left_text == right_text and left_name == right_name
is_text = isinstance(left_text, type('')) and isinstance(right_text, type('')) is_text = isinstance(left_text, type('')) and isinstance(right_text, type(''))
left_name = left_name or '[%s]'%_('This file was added') left_name = left_name or '[%s]'%_('This file was added')
right_name = right_name or '[%s]'%_('This file was removed') right_name = right_name or '[%s]'%_('This file was removed')
@ -462,6 +460,10 @@ class DiffSplit(QSplitter): # {{{
c = v.textCursor() c = v.textCursor()
c.movePosition(c.End) c.movePosition(c.End)
c.insertText('[%s]\n\n' % _('The files are identical')) c.insertText('[%s]\n\n' % _('The files are identical'))
elif left_name != right_name and not left_text and not right_text:
self.add_text_diff(_('[This file was renamed to %s]') % right_name, _('[This file was renamed from %s]') % left_name, context, syntax)
for v in (self.left, self.right):
v.appendPlainText('\n')
elif is_text: elif is_text:
self.add_text_diff(left_text, right_text, context, syntax) self.add_text_diff(left_text, right_text, context, syntax)
elif syntax == 'raster_image': elif syntax == 'raster_image':
@ -470,6 +472,8 @@ class DiffSplit(QSplitter): # {{{
text = '[%s]' % _('Binary file of size: %s') text = '[%s]' % _('Binary file of size: %s')
left_text, right_text = text % human_readable(len(left_text)), text % human_readable(len(right_text)) left_text, right_text = text % human_readable(len(left_text)), text % human_readable(len(right_text))
self.add_text_diff(left_text, right_text, None, None) self.add_text_diff(left_text, right_text, None, None)
for v in (self.left, self.right):
v.appendPlainText('\n')
# image diffs {{{ # image diffs {{{
@property @property
@ -777,6 +781,7 @@ class DiffView(QWidget): # {{{
l.setMargin(0), l.setSpacing(0) l.setMargin(0), l.setSpacing(0)
self.view = DiffSplit(self) self.view = DiffSplit(self)
l.addWidget(self.view) l.addWidget(self.view)
self.add_diff = self.view.add_diff
self.scrollbar = QScrollBar(self) self.scrollbar = QScrollBar(self)
l.addWidget(self.scrollbar) l.addWidget(self.scrollbar)
self.syncing = False self.syncing = False
@ -870,14 +875,14 @@ class DiffView(QWidget): # {{{
self.view.clear() self.view.clear()
self.changes = [] self.changes = []
self.delta = 0 self.delta = 0
self.scrollbar.setRange(0, 0)
def adjust_range(self): def adjust_range(self):
ls, rs = self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar() ls, rs = self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar()
page_step = self.view.left.verticalScrollBar().pageStep()
self.scrollbar.setPageStep(min(ls.pageStep(), rs.pageStep())) self.scrollbar.setPageStep(min(ls.pageStep(), rs.pageStep()))
self.scrollbar.setSingleStep(min(ls.singleStep(), rs.singleStep())) self.scrollbar.setSingleStep(min(ls.singleStep(), rs.singleStep()))
self.scrollbar.setRange(0, ls.maximum() + self.delta) self.scrollbar.setRange(0, ls.maximum() + self.delta)
self.scrollbar.setVisible(self.scrollbar.maximum() > page_step) self.scrollbar.setVisible(self.view.left.blockCount() > ls.pageStep() or self.view.right.blockCount() > rs.pageStep())
def finalize(self): def finalize(self):
self.view.finalize() self.view.finalize()
@ -914,24 +919,4 @@ class DiffView(QWidget): # {{{
self.scrollbar.setValue(self.scrollbar.value() + d * amount) self.scrollbar.setValue(self.scrollbar.value() + d * amount)
# }}} # }}}
if __name__ == '__main__':
app = QApplication([])
w = DiffView()
w.show()
for l, r in zip(sys.argv[1::2], sys.argv[2::2]):
raw1 = open(l, 'rb').read()
raw2 = open(r, 'rb').read()
syntax = syntax_from_mime(l, guess_type(l))
if syntax is None and '.' not in os.path.basename(l):
# TODO: Add some kind of simple file type from contents detection.
syntax = 'text' # Assume text file
if syntax not in {'raster_image', None}:
try:
raw1, raw2 = raw1.decode('utf-8'), raw2.decode('utf-8')
except UnicodeDecodeError:
pass
w.view.add_diff(l, r, raw1, raw2, syntax=syntax, context=31)
w.finalize()
app.exec_()
# TODO: Add diff colors for other color schemes # TODO: Add diff colors for other color schemes