From 13e4a21ed8e2a421b8ef4bca610d5c3e678c4b98 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 7 Nov 2013 17:33:18 +0530 Subject: [PATCH] Live updates for the preview panel --- src/calibre/ebooks/oeb/polish/container.py | 7 + src/calibre/gui2/tweak_book/__init__.py | 1 + src/calibre/gui2/tweak_book/boss.py | 10 +- src/calibre/gui2/tweak_book/editor/widget.py | 10 ++ src/calibre/gui2/tweak_book/preview.py | 170 +++++++++++++------ 5 files changed, 143 insertions(+), 55 deletions(-) diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py index d87f90a73c..b5303e09ad 100644 --- a/src/calibre/ebooks/oeb/polish/container.py +++ b/src/calibre/ebooks/oeb/polish/container.py @@ -368,6 +368,13 @@ class Container(object): # {{{ data = self.parse_css(data, self.relpath(path)) return data + 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'}): + ans = self.decode(ans) + return ans + def parse_css(self, data, fname): from cssutils import CSSParser, log log.setLevel(logging.WARN) diff --git a/src/calibre/gui2/tweak_book/__init__.py b/src/calibre/gui2/tweak_book/__init__.py index e73ccc02e1..aff0f23a5d 100644 --- a/src/calibre/gui2/tweak_book/__init__.py +++ b/src/calibre/gui2/tweak_book/__init__.py @@ -13,6 +13,7 @@ tprefs.defaults['editor_theme'] = None tprefs.defaults['editor_font_family'] = None tprefs.defaults['editor_font_size'] = 12 tprefs.defaults['editor_line_wrap'] = True +tprefs.defaults['preview_refresh_time'] = 2 _current_container = None diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index bab6334126..7269f3934e 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -24,6 +24,7 @@ from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.tweak_book import set_current_container, current_container, tprefs, actions, editors from calibre.gui2.tweak_book.undo import GlobalUndoHistory from calibre.gui2.tweak_book.save import SaveManager +from calibre.gui2.tweak_book.preview import parse_worker from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime def get_container(*args, **kwargs): @@ -232,6 +233,7 @@ class Boss(QObject): if editor is None: editor = editors[name] = editor_from_syntax(syntax, self.gui.editor_tabs) editor.undo_redo_state_changed.connect(self.editor_undo_redo_state_changed) + editor.data_changed.connect(self.editor_data_changed) c = current_container() with c.open(name) as f: editor.data = c.decode(f.read()) @@ -260,6 +262,9 @@ class Boss(QObject): if ed is not None: ed.redo() + def editor_data_changed(self, editor): + self.gui.preview.refresh_timer.start(tprefs['preview_refresh_time'] * 1000) + def editor_undo_redo_state_changed(self, *args): self.apply_current_editor_state(update_keymap=False) @@ -280,7 +285,7 @@ class Boss(QObject): if ed is x: name = n break - if name is not None: + if name is not None and getattr(ed, 'syntax', None) == 'html': self.gui.preview.show(name) else: self.gui.keyboard.set_mode('other') @@ -392,9 +397,10 @@ class Boss(QObject): QApplication.instance().quit() def shutdown(self): + self.gui.preview.refresh_timer.stop() self.save_state() self.save_manager.shutdown() - self.gui.preview.parse_worker.shutdown() + parse_worker.shutdown() self.save_manager.wait(0.1) def save_state(self): diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index a2a034d369..13393d72d8 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -16,6 +16,7 @@ class Editor(QMainWindow): modification_state_changed = pyqtSignal(object) undo_redo_state_changed = pyqtSignal(object, object) + data_changed = pyqtSignal(object) def __init__(self, syntax, parent=None): QMainWindow.__init__(self, parent) @@ -30,6 +31,10 @@ class Editor(QMainWindow): self.redo_available = False self.editor.undoAvailable.connect(self._undo_available) self.editor.redoAvailable.connect(self._redo_available) + self.editor.textChanged.connect(self._data_changed) + + def _data_changed(self): + self.data_changed.emit(self) def _undo_available(self, available): self.undo_available = available @@ -50,6 +55,9 @@ class Editor(QMainWindow): self.editor.load_text(val, syntax=self.syntax) return property(fget=fget, fset=fset) + def get_raw_data(self): + return unicode(self.editor.toPlainText()) + def undo(self): self.editor.undo() @@ -74,9 +82,11 @@ class Editor(QMainWindow): def break_cycles(self): self.modification_state_changed.disconnect() self.undo_redo_state_changed.disconnect() + self.data_changed.disconnect() self.editor.undoAvailable.disconnect() self.editor.redoAvailable.disconnect() self.editor.modificationChanged.disconnect() + self.editor.textChanged.disconnect() self.editor.setPlainText('') def launch_editor(path_to_edit, path_is_raw=False, syntax='html'): diff --git a/src/calibre/gui2/tweak_book/preview.py b/src/calibre/gui2/tweak_book/preview.py index 10b5b1abee..59efe2fc70 100644 --- a/src/calibre/gui2/tweak_book/preview.py +++ b/src/calibre/gui2/tweak_book/preview.py @@ -12,14 +12,14 @@ from Queue import Queue, Empty from PyQt4.Qt import ( QWidget, QVBoxLayout, QApplication, QSize, QNetworkAccessManager, - QNetworkReply, QTimer, QNetworkRequest, QUrl) + QNetworkReply, QTimer, QNetworkRequest, QUrl, Qt, QNetworkDiskCache) from PyQt4.QtWebKit import QWebView from calibre import prints from calibre.constants import iswindows from calibre.ebooks.oeb.polish.parsing import parse -from calibre.ebooks.oeb.base import serialize -from calibre.gui2 import Dispatcher +from calibre.ebooks.oeb.base import serialize, OEB_DOCS +from calibre.ptempfile import PersistentTemporaryDirectory from calibre.gui2.tweak_book import current_container, editors from calibre.gui2.viewer.documentview import apply_settings from calibre.gui2.viewer.config import config @@ -27,29 +27,43 @@ from calibre.utils.ipc.simple_worker import offload_worker shutdown = object() +def get_data(name): + 'Get the data for name. Returns a unicode string if name is a text document/stylesheet' + if name in editors: + return editors[name].get_raw_data() + return current_container().raw_data(name) + +# Parsing of html to add linenumbers {{{ def parse_html(raw): - root = parse(raw, decoder=lambda x:x.decode('utf-8'), replace_entities=False, line_numbers=True, linenumber_attribute='lnum') - return serialize(root, 'text/html').decode('utf-8') + root = parse(raw, decoder=lambda x:x.decode('utf-8'), line_numbers=True, linenumber_attribute='lnum') + return serialize(root, 'text/html').encode('utf-8') + +class ParseItem(object): + + __slots__ = ('name', 'length', 'fingerprint', 'parsed_data') + + def __init__(self, name): + self.name = name + self.length, self.fingerprint = 0, None + self.parsed_data = None class ParseWorker(Thread): daemon = True SLEEP_TIME = 1 - def __init__(self, callback=lambda x, y: None): + def __init__(self): Thread.__init__(self) - self.worker = offload_worker(priority='low') self.requests = Queue() self.request_count = 0 - self.start() - self.cache = {} - self.callback = callback + self.parse_items = {} def run(self): mod, func = 'calibre.gui2.tweak_book.preview', 'parse_html' try: # Connect to the worker and send a dummy job to initialize it - self.worker(mod, func, b'

') + self.worker = offload_worker(priority='low') + self.worker(mod, func, '

') except: import traceback traceback.print_exc() @@ -69,12 +83,7 @@ class ParseWorker(Thread): break request = sorted(requests, reverse=True)[0] del requests - name, data = request[1:] - old_len, old_fp, old_parsed = self.cache.get(name, (None, None, None)) - length, fp = len(data), hash(data) - if length == old_len and fp == old_fp: - self.done(name, old_parsed) - continue + pi, data = request[1:] try: res = self.worker(mod, func, data) except: @@ -86,45 +95,75 @@ class ParseWorker(Thread): prints("Parser error:") prints(res['tb']) else: - self.cache[name] = (length, fp, parsed_data) - self.done(name, parsed_data) - - def done(self, name, data): - try: - self.callback(name, data) - except Exception: - import traceback - traceback.print_exc() + pi.parsed_data = parsed_data def add_request(self, name): data = get_data(name) - self.requests.put((self.request_count, name, data)) + ldata, hdata = len(data), hash(data) + pi = self.parse_items.get(name, None) + if pi is None: + self.parse_items[name] = pi = ParseItem(name) + else: + if pi.length == ldata and pi.fingerprint == hdata: + return + pi.parsed_data = None + pi.length, pi.fingerprint = ldata, hdata + self.requests.put((self.request_count, pi, data)) self.request_count += 1 def shutdown(self): self.requests.put(shutdown) + def get_data(self, name): + return getattr(self.parse_items.get(name, None), 'parsed_data', None) -class LocalNetworkReply(QNetworkReply): +parse_worker = ParseWorker() +# }}} - def __init__(self, parent, request, mime_type, data): +# Override network access to load data "live" from the editors {{{ +class NetworkReply(QNetworkReply): + + def __init__(self, parent, request, mime_type, name): QNetworkReply.__init__(self, parent) self.setOpenMode(QNetworkReply.ReadOnly | QNetworkReply.Unbuffered) - self.__data = data self.setRequest(request) self.setUrl(request.url()) - self.setHeader(QNetworkRequest.ContentTypeHeader, mime_type) + self._aborted = False + if mime_type in OEB_DOCS: + self.resource_name = name + QTimer.singleShot(0, self.check_for_parse) + else: + data = get_data(name) + if isinstance(data, type('')): + data = data.encode('utf-8') + mime_type += '; charset=utf-8' + self.__data = data + self.setHeader(QNetworkRequest.ContentTypeHeader, mime_type) + self.setHeader(QNetworkRequest.ContentLengthHeader, len(self.__data)) + QTimer.singleShot(0, self.finalize_reply) + + def check_for_parse(self): + if self._aborted: + return + data = parse_worker.get_data(self.resource_name) + if data is None: + return QTimer.singleShot(10, self.check_for_parse) + self.__data = data + self.setHeader(QNetworkRequest.ContentTypeHeader, 'text/html; charset=utf-8') self.setHeader(QNetworkRequest.ContentLengthHeader, len(self.__data)) - QTimer.singleShot(0, self.finalize_reply) + self.finalize_reply() def bytesAvailable(self): - return len(self.__data) + try: + return len(self.__data) + except AttributeError: + return 0 def isSequential(self): return True def abort(self): - pass + self._aborted = True def readData(self, maxlen): ans, self.__data = self.__data[:maxlen], self.__data[maxlen:] @@ -132,6 +171,8 @@ class LocalNetworkReply(QNetworkReply): read = readData def finalize_reply(self): + if self._aborted: + return self.setFinished(True) self.setAttribute(QNetworkRequest.HttpStatusCodeAttribute, 200) self.setAttribute(QNetworkRequest.HttpReasonPhraseAttribute, "Ok") @@ -140,10 +181,6 @@ class LocalNetworkReply(QNetworkReply): self.readyRead.emit() self.finished.emit() -def get_data(name): - if name in editors: - return editors[name].data - return current_container().open(name).read() class NetworkAccessManager(QNetworkAccessManager): @@ -152,9 +189,16 @@ class NetworkAccessManager(QNetworkAccessManager): 'Custom') } + def __init__(self, *args): + QNetworkAccessManager.__init__(self, *args) + self.cache = QNetworkDiskCache(self) + self.setCache(self.cache) + self.cache.setCacheDirectory(PersistentTemporaryDirectory(prefix='disk_cache_')) + self.cache.setMaximumCacheSize(0) + def createRequest(self, operation, request, data): url = unicode(request.url().toString()) - if url.startswith('file://'): + if operation == self.GetOperation and url.startswith('file://'): path = url[7:] if iswindows and path.startswith('/'): path = path[1:] @@ -162,13 +206,13 @@ class NetworkAccessManager(QNetworkAccessManager): name = c.abspath_to_name(path) if c.has_name(name): try: - return LocalNetworkReply(self, request, c.mime_map.get(name, 'application/octet-stream'), - get_data(name) if operation == self.GetOperation else b'') + return NetworkReply(self, request, c.mime_map.get(name, 'application/octet-stream'), name) except Exception: import traceback - traceback.print_stack() - return QNetworkAccessManager.createRequest(self, operation, request, - data) + traceback.print_exc() + return QNetworkAccessManager.createRequest(self, operation, request, data) + +# }}} class WebView(QWebView): @@ -195,6 +239,20 @@ class WebView(QWebView): def sizeHint(self): return self._size_hint + def refresh(self): + self.pageAction(self.page().Reload).trigger() + + @dynamic_property + def scroll_pos(self): + def fget(self): + mf = self.page().mainFrame() + return (mf.scrollBarValue(Qt.Horizontal), mf.scrollBarValue(Qt.Vertical)) + def fset(self, val): + mf = self.page().mainFrame() + mf.setScrollBarValue(Qt.Horizontal, val[0]) + mf.setScrollBarValue(Qt.Vertical, val[1]) + return property(fget=fget, fset=fset) + class Preview(QWidget): def __init__(self, parent=None): @@ -202,21 +260,27 @@ class Preview(QWidget): self.l = l = QVBoxLayout() self.setLayout(l) l.setContentsMargins(0, 0, 0, 0) - self.parse_worker = ParseWorker(callback=Dispatcher(self.parsing_done)) self.view = WebView(self) l.addWidget(self.view) self.current_name = None - self.parse_pending = False self.last_sync_request = None + self.refresh_timer = QTimer(self) + self.refresh_timer.timeout.connect(self.refresh) + parse_worker.start() def show(self, name): - self.current_name, self.parse_pending = name, True - self.parse_worker.add_request(name) + if name != self.current_name: + self.refresh_timer.stop() + self.current_name = name + parse_worker.add_request(name) + self.view.setUrl(QUrl.fromLocalFile(current_container().name_to_abspath(name))) - def parsing_done(self, name, data): - if name == self.current_name: - c = current_container() - self.view.setHtml(data, QUrl.fromLocalFile(c.name_to_abspath(name))) - self.parse_pending = False + def refresh(self): + if self.current_name: + # This will check if the current html has changed in its editor, + # and re-parse it if so + parse_worker.add_request(self.current_name) + # Tell webkit to reload all html and associated resources + self.view.refresh()