diff --git a/src/calibre/gui2/viewer/toc.py b/src/calibre/gui2/viewer/toc.py new file mode 100644 index 0000000000..a0c8b570c4 --- /dev/null +++ b/src/calibre/gui2/viewer/toc.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2019, Kovid Goyal + +from __future__ import absolute_import, division, print_function, unicode_literals + +import re +from functools import partial + +from PyQt5.Qt import ( + QApplication, QFont, QHBoxLayout, QIcon, QMenu, QModelIndex, QStandardItem, + QStandardItemModel, QStyledItemDelegate, Qt, QToolButton, QToolTip, QTreeView, + QWidget, pyqtSignal +) + +from calibre.gui2 import error_dialog +from calibre.gui2.search_box import SearchBox2 +from calibre.utils.icu import primary_contains + + +class Delegate(QStyledItemDelegate): + + def helpEvent(self, ev, view, option, index): + # Show a tooltip only if the item is truncated + if not ev or not view: + return False + if ev.type() == ev.ToolTip: + rect = view.visualRect(index) + size = self.sizeHint(option, index) + if rect.width() < size.width(): + tooltip = index.data(Qt.DisplayRole) + QToolTip.showText(ev.globalPos(), tooltip, view) + return True + return QStyledItemDelegate.helpEvent(self, ev, view, option, index) + + +class TOCView(QTreeView): + + searched = pyqtSignal(object) + + def __init__(self, *args): + QTreeView.__init__(self, *args) + self.delegate = Delegate(self) + self.setItemDelegate(self.delegate) + self.setMinimumWidth(80) + self.header().close() + self.setMouseTracking(True) + self.setStyleSheet(''' + QTreeView { + background-color: palette(window); + color: palette(window-text); + border: none; + } + + QTreeView::item { + border: 1px solid transparent; + padding-top:0.5ex; + padding-bottom:0.5ex; + } + + QTreeView::item:hover { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #e7effd, stop: 1 #cbdaf1); + border: 1px solid #bfcde4; + border-radius: 6px; + } + ''') + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.context_menu) + + def mouseMoveEvent(self, ev): + if self.indexAt(ev.pos()).isValid(): + self.setCursor(Qt.PointingHandCursor) + else: + self.unsetCursor() + return QTreeView.mouseMoveEvent(self, ev) + + def expand_tree(self, index): + self.expand(index) + i = -1 + while True: + i += 1 + child = index.child(i, 0) + if not child.isValid(): + break + self.expand_tree(child) + + def context_menu(self, pos): + index = self.indexAt(pos) + m = QMenu(self) + if index.isValid(): + m.addAction(_('Expand all items under %s') % index.data(), partial(self.expand_tree, index)) + m.addSeparator() + m.addAction(_('Expand all items'), self.expandAll) + m.addAction(_('Collapse all items'), self.collapseAll) + m.addSeparator() + m.addAction(_('Copy table of contents to clipboard'), self.copy_to_clipboard) + m.exec_(self.mapToGlobal(pos)) + + def copy_to_clipboard(self): + m = self.model() + QApplication.clipboard().setText(getattr(m, 'as_plain_text', '')) + + +class TOCSearch(QWidget): + + def __init__(self, toc_view, parent=None): + QWidget.__init__(self, parent) + self.toc_view = toc_view + self.l = l = QHBoxLayout(self) + self.search = s = SearchBox2(self) + self.search.setMinimumContentsLength(15) + self.search.initialize('viewer_toc_search_history', help_text=_('Search Table of Contents')) + self.search.setToolTip(_('Search for text in the Table of Contents')) + s.search.connect(self.do_search) + self.go = b = QToolButton(self) + b.setIcon(QIcon(I('search.png'))) + b.clicked.connect(s.do_search) + b.setToolTip(_('Find next match')) + l.addWidget(s), l.addWidget(b) + + def do_search(self, text): + if not text or not text.strip(): + return + index = self.toc_view.model().search(text) + if index.isValid(): + self.toc_view.searched.emit(index) + else: + error_dialog(self.toc_view, _('No matches found'), _( + 'There are no Table of Contents entries matching: %s') % text, show=True) + self.search.search_done(True) + + +class TOCItem(QStandardItem): + + def __init__(self, toc, depth, all_items, parent=None): + text = toc.get('title') or '' + if text: + text = re.sub(r'\s', ' ', text) + self.title = text + self.parent = parent + self.node_id = toc['id'] + QStandardItem.__init__(self, text) + all_items.append(self) + self.emphasis_font = QFont(self.font()) + self.emphasis_font.setBold(True), self.emphasis_font.setItalic(True) + self.normal_font = self.font() + for t in toc['children']: + self.appendRow(TOCItem(t, depth+1, all_items, parent=self)) + self.setFlags(Qt.ItemIsEnabled) + self.is_current_search_result = False + self.depth = depth + self.is_being_viewed = False + + @property + def ancestors(self): + parent = self.parent + while parent is not None: + yield parent + parent = parent.parent + + @classmethod + def type(cls): + return QStandardItem.UserType+10 + + def set_current_search_result(self, yes): + if yes and not self.is_current_search_result: + self.setText(self.text() + ' ◄') + self.is_current_search_result = True + elif not yes and self.is_current_search_result: + self.setText(self.text()[:-2]) + self.is_current_search_result = False + + def __repr__(self): + indent = ' ' * self.depth + return '{}▶ TOC Item: {} ({})'.format(indent, self.title, self.node_id) + + def __str__(self): + return repr(self) + + +class TOC(QStandardItemModel): + + def __init__(self, toc=None): + QStandardItemModel.__init__(self) + self.current_query = {'text':'', 'index':-1, 'items':()} + self.all_items = depth_first = [] + if toc: + for t in toc['children']: + self.appendRow(TOCItem(t, 0, depth_first)) + self.currently_viewed_entry = None + + def find_items(self, query): + for item in self.all_items: + if primary_contains(query, item.text()): + yield item + + def search(self, query): + cq = self.current_query + if cq['items'] and -1 < cq['index'] < len(cq['items']): + cq['items'][cq['index']].set_current_search_result(False) + if cq['text'] != query: + items = tuple(self.find_items(query)) + cq.update({'text':query, 'items':items, 'index':-1}) + if len(cq['items']) > 0: + cq['index'] = (cq['index'] + 1) % len(cq['items']) + item = cq['items'][cq['index']] + item.set_current_search_result(True) + index = self.indexFromItem(item) + return index + return QModelIndex() + + @property + def as_plain_text(self): + lines = [] + for item in self.all_items: + lines.append(' ' * (4 * item.depth) + (item.title or '')) + return '\n'.join(lines) diff --git a/src/calibre/gui2/viewer/ui.py b/src/calibre/gui2/viewer/ui.py index b3b9b9e006..2970852aaf 100644 --- a/src/calibre/gui2/viewer/ui.py +++ b/src/calibre/gui2/viewer/ui.py @@ -11,7 +11,7 @@ from collections import defaultdict from hashlib import sha256 from threading import Thread -from PyQt5.Qt import QDockWidget, Qt, pyqtSignal +from PyQt5.Qt import QDockWidget, Qt, QVBoxLayout, QWidget, pyqtSignal from calibre import prints from calibre.constants import config_dir @@ -21,7 +21,8 @@ from calibre.gui2.viewer.annotations import ( merge_annotations, parse_annotations, save_annots_to_epub, serialize_annotations ) from calibre.gui2.viewer.convert_book import prepare_book, update_book -from calibre.gui2.viewer.web_view import WebView, set_book_path +from calibre.gui2.viewer.toc import TOC, TOCSearch, TOCView +from calibre.gui2.viewer.web_view import WebView, set_book_path, vprefs from calibre.utils.date import utcnow from calibre.utils.ipc.simple_worker import WorkerError from calibre.utils.serialize import json_loads @@ -38,6 +39,7 @@ class EbookViewer(MainWindow): msg_from_anotherinstance = pyqtSignal(object) book_prepared = pyqtSignal(object, object) + MAIN_WINDOW_STATE_VERSION = 1 def __init__(self): MainWindow.__init__(self, None) @@ -56,11 +58,22 @@ class EbookViewer(MainWindow): return ans self.toc_dock = create_dock(_('Table of Contents'), 'toc-dock', Qt.LeftDockWidgetArea) + self.toc_container = w = QWidget(self) + w.l = QVBoxLayout(w) + self.toc = TOCView(w) + self.toc_search = TOCSearch(self.toc, parent=w) + w.l.addWidget(self.toc), w.l.addWidget(self.toc_search), w.l.setContentsMargins(0, 0, 0, 0) + self.toc_dock.setWidget(w) + self.inspector_dock = create_dock(_('Inspector'), 'inspector', Qt.RightDockWidgetArea) self.web_view = WebView(self) self.web_view.cfi_changed.connect(self.cfi_changed) self.web_view.reload_book.connect(self.reload_book) + self.web_view.toggle_toc.connect(self.toggle_toc) self.setCentralWidget(self.web_view) + state = vprefs['main_window_state'] + if state: + self.restoreState(state, self.MAIN_WINDOW_STATE_VERSION) def handle_commandline_arg(self, arg): if arg: @@ -77,6 +90,12 @@ class EbookViewer(MainWindow): self.load_ebook(path, open_at=open_at) self.raise_() + def toggle_toc(self): + if self.toc_dock.isVisible(): + self.toc_dock.setVisible(False) + else: + self.toc_dock.setVisible(True) + def load_ebook(self, pathtoebook, open_at=None, reload_book=False): # TODO: Implement open_at self.web_view.show_preparing_message() @@ -119,7 +138,10 @@ class EbookViewer(MainWindow): path = os.path.join(self.current_book_data['base'], 'calibre-book-manifest.json') with open(path, 'rb') as f: raw = f.read() - self.current_book_data['manifest'] = json.loads(raw) + self.current_book_data['manifest'] = manifest = json.loads(raw) + toc = manifest.get('toc') + self.toc_model = TOC(toc) + self.toc.setModel(self.toc_model) def load_book_annotations(self): amap = self.current_book_data['annotations_map'] @@ -161,6 +183,10 @@ class EbookViewer(MainWindow): save_annots_to_epub(path, annots) update_book(path, before_stat, {'calibre-book-annotations.json': annots}) + def save_state(self): + vprefs['main_window_state'] = bytearray(self.saveState(self.MAIN_WINDOW_STATE_VERSION)) + def closeEvent(self, ev): self.save_annotations() + self.save_state() return MainWindow.closeEvent(self, ev) diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index 0bb0f1b638..fab95586a0 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -36,6 +36,7 @@ except ImportError: vprefs = JSONConfig('viewer-webengine') vprefs.defaults['session_data'] = {} +vprefs.defaults['main_window_state'] = None # Override network access to load data from the book {{{ @@ -168,6 +169,7 @@ class ViewerBridge(Bridge): set_session_data = from_js(object, object) reload_book = from_js() + toggle_toc = from_js() create_view = to_js() show_preparing_message = to_js() @@ -246,6 +248,7 @@ class WebView(RestartingWebEngineView): cfi_changed = pyqtSignal(object) reload_book = pyqtSignal() + toggle_toc = pyqtSignal() def __init__(self, parent=None): self._host_widget = None @@ -259,6 +262,7 @@ class WebView(RestartingWebEngineView): self.bridge.bridge_ready.connect(self.on_bridge_ready) self.bridge.set_session_data.connect(self.set_session_data) self.bridge.reload_book.connect(self.reload_book) + self.bridge.toggle_toc.connect(self.toggle_toc) self.pending_bridge_ready_actions = {} self.setPage(self._page) self.setAcceptDrops(False) diff --git a/src/pyj/read_book/overlay.pyj b/src/pyj/read_book/overlay.pyj index e1329dae34..36a9398500 100644 --- a/src/pyj/read_book/overlay.pyj +++ b/src/pyj/read_book/overlay.pyj @@ -454,6 +454,9 @@ class Overlay: def show_toc(self): self.hide_current_panel() + if runtime.is_standalone_viewer: + ui_operations.toggle_toc() + return self.panels.push(TOCOverlay(self)) self.show_current_panel() diff --git a/src/pyj/viewer-main.pyj b/src/pyj/viewer-main.pyj index d04fd6163e..33b3cbe3ff 100644 --- a/src/pyj/viewer-main.pyj +++ b/src/pyj/viewer-main.pyj @@ -237,6 +237,8 @@ if window is window.top: ui_operations.forward_gesture = forward_gesture ui_operations.update_color_scheme = update_color_scheme ui_operations.update_font_size = update_font_size + ui_operations.toggle_toc = def(): + to_python.toggle_toc() document.body.appendChild(E.div(id='view')) window.onerror = onerror create_modal_container()