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):
ans = self.open(name).read()
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)
return ans

View File

@ -6,5 +6,235 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__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'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import sys, re, unicodedata, os
import re, unicodedata
from math import ceil
from functools import partial
from collections import namedtuple, OrderedDict
@ -21,9 +21,7 @@ from PyQt4.Qt import (
QImage, QPixmap, QMenu, QIcon)
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.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.themes import THEMES, default_theme, theme_color
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):
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(''))
left_name = left_name or '[%s]'%_('This file was added')
right_name = right_name or '[%s]'%_('This file was removed')
@ -462,6 +460,10 @@ class DiffSplit(QSplitter): # {{{
c = v.textCursor()
c.movePosition(c.End)
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:
self.add_text_diff(left_text, right_text, context, syntax)
elif syntax == 'raster_image':
@ -470,6 +472,8 @@ class DiffSplit(QSplitter): # {{{
text = '[%s]' % _('Binary file of size: %s')
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)
for v in (self.left, self.right):
v.appendPlainText('\n')
# image diffs {{{
@property
@ -777,6 +781,7 @@ class DiffView(QWidget): # {{{
l.setMargin(0), l.setSpacing(0)
self.view = DiffSplit(self)
l.addWidget(self.view)
self.add_diff = self.view.add_diff
self.scrollbar = QScrollBar(self)
l.addWidget(self.scrollbar)
self.syncing = False
@ -870,14 +875,14 @@ class DiffView(QWidget): # {{{
self.view.clear()
self.changes = []
self.delta = 0
self.scrollbar.setRange(0, 0)
def adjust_range(self):
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.setSingleStep(min(ls.singleStep(), rs.singleStep()))
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):
self.view.finalize()
@ -914,24 +919,4 @@ class DiffView(QWidget): # {{{
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