mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-06-23 15:30:45 -04:00
Work on adding an external dockable ToC panel
This commit is contained in:
parent
98ecf220e6
commit
02d8563efc
217
src/calibre/gui2/viewer/toc.py
Normal file
217
src/calibre/gui2/viewer/toc.py
Normal 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)
|
@ -11,7 +11,7 @@ from collections import defaultdict
|
|||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from threading import Thread
|
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 import prints
|
||||||
from calibre.constants import config_dir
|
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
|
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.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.date import utcnow
|
||||||
from calibre.utils.ipc.simple_worker import WorkerError
|
from calibre.utils.ipc.simple_worker import WorkerError
|
||||||
from calibre.utils.serialize import json_loads
|
from calibre.utils.serialize import json_loads
|
||||||
@ -38,6 +39,7 @@ class EbookViewer(MainWindow):
|
|||||||
|
|
||||||
msg_from_anotherinstance = pyqtSignal(object)
|
msg_from_anotherinstance = pyqtSignal(object)
|
||||||
book_prepared = pyqtSignal(object, object)
|
book_prepared = pyqtSignal(object, object)
|
||||||
|
MAIN_WINDOW_STATE_VERSION = 1
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
MainWindow.__init__(self, None)
|
MainWindow.__init__(self, None)
|
||||||
@ -56,11 +58,22 @@ class EbookViewer(MainWindow):
|
|||||||
return ans
|
return ans
|
||||||
|
|
||||||
self.toc_dock = create_dock(_('Table of Contents'), 'toc-dock', Qt.LeftDockWidgetArea)
|
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.inspector_dock = create_dock(_('Inspector'), 'inspector', Qt.RightDockWidgetArea)
|
||||||
self.web_view = WebView(self)
|
self.web_view = WebView(self)
|
||||||
self.web_view.cfi_changed.connect(self.cfi_changed)
|
self.web_view.cfi_changed.connect(self.cfi_changed)
|
||||||
self.web_view.reload_book.connect(self.reload_book)
|
self.web_view.reload_book.connect(self.reload_book)
|
||||||
|
self.web_view.toggle_toc.connect(self.toggle_toc)
|
||||||
self.setCentralWidget(self.web_view)
|
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):
|
def handle_commandline_arg(self, arg):
|
||||||
if arg:
|
if arg:
|
||||||
@ -77,6 +90,12 @@ class EbookViewer(MainWindow):
|
|||||||
self.load_ebook(path, open_at=open_at)
|
self.load_ebook(path, open_at=open_at)
|
||||||
self.raise_()
|
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):
|
def load_ebook(self, pathtoebook, open_at=None, reload_book=False):
|
||||||
# TODO: Implement open_at
|
# TODO: Implement open_at
|
||||||
self.web_view.show_preparing_message()
|
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')
|
path = os.path.join(self.current_book_data['base'], 'calibre-book-manifest.json')
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
raw = f.read()
|
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):
|
def load_book_annotations(self):
|
||||||
amap = self.current_book_data['annotations_map']
|
amap = self.current_book_data['annotations_map']
|
||||||
@ -161,6 +183,10 @@ class EbookViewer(MainWindow):
|
|||||||
save_annots_to_epub(path, annots)
|
save_annots_to_epub(path, annots)
|
||||||
update_book(path, before_stat, {'calibre-book-annotations.json': 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):
|
def closeEvent(self, ev):
|
||||||
self.save_annotations()
|
self.save_annotations()
|
||||||
|
self.save_state()
|
||||||
return MainWindow.closeEvent(self, ev)
|
return MainWindow.closeEvent(self, ev)
|
||||||
|
@ -36,6 +36,7 @@ except ImportError:
|
|||||||
|
|
||||||
vprefs = JSONConfig('viewer-webengine')
|
vprefs = JSONConfig('viewer-webengine')
|
||||||
vprefs.defaults['session_data'] = {}
|
vprefs.defaults['session_data'] = {}
|
||||||
|
vprefs.defaults['main_window_state'] = None
|
||||||
|
|
||||||
|
|
||||||
# Override network access to load data from the book {{{
|
# Override network access to load data from the book {{{
|
||||||
@ -168,6 +169,7 @@ class ViewerBridge(Bridge):
|
|||||||
|
|
||||||
set_session_data = from_js(object, object)
|
set_session_data = from_js(object, object)
|
||||||
reload_book = from_js()
|
reload_book = from_js()
|
||||||
|
toggle_toc = from_js()
|
||||||
|
|
||||||
create_view = to_js()
|
create_view = to_js()
|
||||||
show_preparing_message = to_js()
|
show_preparing_message = to_js()
|
||||||
@ -246,6 +248,7 @@ class WebView(RestartingWebEngineView):
|
|||||||
|
|
||||||
cfi_changed = pyqtSignal(object)
|
cfi_changed = pyqtSignal(object)
|
||||||
reload_book = pyqtSignal()
|
reload_book = pyqtSignal()
|
||||||
|
toggle_toc = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
self._host_widget = None
|
self._host_widget = None
|
||||||
@ -259,6 +262,7 @@ class WebView(RestartingWebEngineView):
|
|||||||
self.bridge.bridge_ready.connect(self.on_bridge_ready)
|
self.bridge.bridge_ready.connect(self.on_bridge_ready)
|
||||||
self.bridge.set_session_data.connect(self.set_session_data)
|
self.bridge.set_session_data.connect(self.set_session_data)
|
||||||
self.bridge.reload_book.connect(self.reload_book)
|
self.bridge.reload_book.connect(self.reload_book)
|
||||||
|
self.bridge.toggle_toc.connect(self.toggle_toc)
|
||||||
self.pending_bridge_ready_actions = {}
|
self.pending_bridge_ready_actions = {}
|
||||||
self.setPage(self._page)
|
self.setPage(self._page)
|
||||||
self.setAcceptDrops(False)
|
self.setAcceptDrops(False)
|
||||||
|
@ -454,6 +454,9 @@ class Overlay:
|
|||||||
|
|
||||||
def show_toc(self):
|
def show_toc(self):
|
||||||
self.hide_current_panel()
|
self.hide_current_panel()
|
||||||
|
if runtime.is_standalone_viewer:
|
||||||
|
ui_operations.toggle_toc()
|
||||||
|
return
|
||||||
self.panels.push(TOCOverlay(self))
|
self.panels.push(TOCOverlay(self))
|
||||||
self.show_current_panel()
|
self.show_current_panel()
|
||||||
|
|
||||||
|
@ -237,6 +237,8 @@ if window is window.top:
|
|||||||
ui_operations.forward_gesture = forward_gesture
|
ui_operations.forward_gesture = forward_gesture
|
||||||
ui_operations.update_color_scheme = update_color_scheme
|
ui_operations.update_color_scheme = update_color_scheme
|
||||||
ui_operations.update_font_size = update_font_size
|
ui_operations.update_font_size = update_font_size
|
||||||
|
ui_operations.toggle_toc = def():
|
||||||
|
to_python.toggle_toc()
|
||||||
document.body.appendChild(E.div(id='view'))
|
document.body.appendChild(E.div(id='view'))
|
||||||
window.onerror = onerror
|
window.onerror = onerror
|
||||||
create_modal_container()
|
create_modal_container()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user