Work on adding an external dockable ToC panel

This commit is contained in:
Kovid Goyal 2019-08-06 20:21:06 +05:30
parent 98ecf220e6
commit 02d8563efc
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 255 additions and 3 deletions

View File

@ -0,0 +1,217 @@
#!/usr/bin/env python2
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2019, Kovid Goyal <kovid at kovidgoyal.net>
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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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()