1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2019, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import re
7from functools import partial
8
9from qt.core import (
10    QApplication, QFont, QHBoxLayout, QIcon, QMenu, QModelIndex, QStandardItem,
11    QStandardItemModel, QStyledItemDelegate, Qt, QToolButton, QToolTip, QTreeView,
12    QWidget, pyqtSignal, QEvent
13)
14
15from calibre.gui2 import error_dialog
16from calibre.gui2.search_box import SearchBox2
17from calibre.utils.icu import primary_contains
18
19
20class Delegate(QStyledItemDelegate):
21
22    def helpEvent(self, ev, view, option, index):
23        # Show a tooltip only if the item is truncated
24        if not ev or not view:
25            return False
26        if ev.type() == QEvent.Type.ToolTip:
27            rect = view.visualRect(index)
28            size = self.sizeHint(option, index)
29            if rect.width() < size.width():
30                tooltip = index.data(Qt.ItemDataRole.DisplayRole)
31                QToolTip.showText(ev.globalPos(), tooltip, view)
32                return True
33        return QStyledItemDelegate.helpEvent(self, ev, view, option, index)
34
35
36class TOCView(QTreeView):
37
38    searched = pyqtSignal(object)
39
40    def __init__(self, *args):
41        QTreeView.__init__(self, *args)
42        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
43        self.delegate = Delegate(self)
44        self.setItemDelegate(self.delegate)
45        self.setMinimumWidth(80)
46        self.header().close()
47        self.setMouseTracking(True)
48        self.set_style_sheet()
49        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
50        self.customContextMenuRequested.connect(self.context_menu)
51        QApplication.instance().palette_changed.connect(self.set_style_sheet, type=Qt.ConnectionType.QueuedConnection)
52
53    def setModel(self, model):
54        QTreeView.setModel(self, model)
55        model.current_toc_nodes_changed.connect(self.current_toc_nodes_changed, type=Qt.ConnectionType.QueuedConnection)
56
57    def current_toc_nodes_changed(self, ancestors, nodes):
58        if ancestors:
59            self.auto_expand_indices(ancestors)
60        if nodes:
61            self.scrollTo(nodes[-1].index())
62
63    def auto_expand_indices(self, indices):
64        for idx in indices:
65            self.setExpanded(idx, True)
66
67    def set_style_sheet(self):
68        self.setStyleSheet('''
69            QTreeView {
70                background-color: palette(window);
71                color: palette(window-text);
72                border: none;
73            }
74
75            QTreeView::item {
76                border: 1px solid transparent;
77                padding-top:0.5ex;
78                padding-bottom:0.5ex;
79            }
80
81            QTreeView::item:hover {
82                background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #e7effd, stop: 1 #cbdaf1);
83                color: black;
84                border: 1px solid #bfcde4;
85                border-radius: 6px;
86            }
87        ''')
88
89    def mouseMoveEvent(self, ev):
90        if self.indexAt(ev.pos()).isValid():
91            self.setCursor(Qt.CursorShape.PointingHandCursor)
92        else:
93            self.unsetCursor()
94        return QTreeView.mouseMoveEvent(self, ev)
95
96    def expand_tree(self, index):
97        self.expand(index)
98        i = -1
99        while True:
100            i += 1
101            child = index.child(i, 0)
102            if not child.isValid():
103                break
104            self.expand_tree(child)
105
106    def collapse_at_level(self, index):
107        item = self.model().itemFromIndex(index)
108        for x in self.model().items_at_depth(item.depth):
109            self.collapse(self.model().indexFromItem(x))
110
111    def expand_at_level(self, index):
112        item = self.model().itemFromIndex(index)
113        for x in self.model().items_at_depth(item.depth):
114            self.expand(self.model().indexFromItem(x))
115
116    def context_menu(self, pos):
117        index = self.indexAt(pos)
118        m = QMenu(self)
119        if index.isValid():
120            m.addAction(_('Expand all items under %s') % index.data(), partial(self.expand_tree, index))
121        m.addSeparator()
122        m.addAction(_('Expand all items'), self.expandAll)
123        m.addAction(_('Collapse all items'), self.collapseAll)
124        m.addSeparator()
125        if index.isValid():
126            m.addAction(_('Expand all items at the level of {}').format(index.data()), partial(self.expand_at_level, index))
127            m.addAction(_('Collapse all items at the level of {}').format(index.data()), partial(self.collapse_at_level, index))
128        m.addSeparator()
129        m.addAction(_('Copy Table of Contents to clipboard'), self.copy_to_clipboard)
130        m.exec(self.mapToGlobal(pos))
131
132    def copy_to_clipboard(self):
133        m = self.model()
134        QApplication.clipboard().setText(getattr(m, 'as_plain_text', ''))
135
136    def update_current_toc_nodes(self, families):
137        self.model().update_current_toc_nodes(families)
138
139    def scroll_to_current_toc_node(self):
140        try:
141            nodes = self.model().viewed_nodes()
142        except AttributeError:
143            nodes = ()
144        if nodes:
145            self.scrollTo(nodes[-1].index())
146
147
148class TOCSearch(QWidget):
149
150    def __init__(self, toc_view, parent=None):
151        QWidget.__init__(self, parent)
152        self.toc_view = toc_view
153        self.l = l = QHBoxLayout(self)
154        self.search = s = SearchBox2(self)
155        self.search.setMinimumContentsLength(15)
156        self.search.initialize('viewer_toc_search_history', help_text=_('Search Table of Contents'))
157        self.search.setToolTip(_('Search for text in the Table of Contents'))
158        s.search.connect(self.do_search)
159        self.go = b = QToolButton(self)
160        b.setIcon(QIcon(I('search.png')))
161        b.clicked.connect(s.do_search)
162        b.setToolTip(_('Find next match'))
163        l.addWidget(s), l.addWidget(b)
164
165    def do_search(self, text):
166        if not text or not text.strip():
167            return
168        delta = -1 if QApplication.instance().keyboardModifiers() & Qt.KeyboardModifier.ShiftModifier else 1
169        index = self.toc_view.model().search(text, delta=delta)
170        if index.isValid():
171            self.toc_view.scrollTo(index)
172            self.toc_view.searched.emit(index)
173        else:
174            error_dialog(self.toc_view, _('No matches found'), _(
175                'There are no Table of Contents entries matching: %s') % text, show=True)
176        self.search.search_done(True)
177
178
179class TOCItem(QStandardItem):
180
181    def __init__(self, toc, depth, all_items, normal_font, emphasis_font, depths, parent=None):
182        text = toc.get('title') or ''
183        self.href = (toc.get('dest') or '')
184        if toc.get('frag'):
185            self.href += '#' + toc['frag']
186        if text:
187            text = re.sub(r'\s', ' ', text)
188        self.title = text
189        self.parent = parent
190        self.node_id = toc['id']
191        QStandardItem.__init__(self, text)
192        all_items.append(self)
193        self.normal_font, self.emphasis_font = normal_font, emphasis_font
194        if toc['children']:
195            depths.add(depth + 1)
196            for t in toc['children']:
197                self.appendRow(TOCItem(t, depth+1, all_items, normal_font, emphasis_font, depths, parent=self))
198        self.setFlags(Qt.ItemFlag.ItemIsEnabled)
199        self.is_current_search_result = False
200        self.depth = depth
201        self.set_being_viewed(False)
202
203    def set_being_viewed(self, is_being_viewed):
204        self.is_being_viewed = is_being_viewed
205        self.setFont(self.emphasis_font if is_being_viewed else self.normal_font)
206
207    @property
208    def ancestors(self):
209        parent = self.parent
210        while parent is not None:
211            yield parent
212            parent = parent.parent
213
214    @classmethod
215    def type(cls):
216        return QStandardItem.ItemType.UserType+10
217
218    def set_current_search_result(self, yes):
219        if yes and not self.is_current_search_result:
220            self.setText(self.text() + ' ◄')
221            self.is_current_search_result = True
222        elif not yes and self.is_current_search_result:
223            self.setText(self.text()[:-2])
224            self.is_current_search_result = False
225
226    def __repr__(self):
227        indent = ' ' * self.depth
228        return '{}▶ TOC Item: {} ({})'.format(indent, self.title, self.node_id)
229
230    def __str__(self):
231        return repr(self)
232
233
234class TOC(QStandardItemModel):
235
236    current_toc_nodes_changed = pyqtSignal(object, object)
237
238    def __init__(self, toc=None):
239        QStandardItemModel.__init__(self)
240        self.current_query = {'text':'', 'index':-1, 'items':()}
241        self.all_items = depth_first = []
242        normal_font = QApplication.instance().font()
243        emphasis_font = QFont(normal_font)
244        emphasis_font.setBold(True), emphasis_font.setItalic(True)
245        self.depths = {0}
246        if toc:
247            for t in toc['children']:
248                self.appendRow(TOCItem(t, 0, depth_first, normal_font, emphasis_font, self.depths))
249        self.depths = tuple(sorted(self.depths))
250        self.node_id_map = {x.node_id: x for x in self.all_items}
251
252    def find_items(self, query):
253        for item in self.all_items:
254            text = item.text()
255            if not query or (text and primary_contains(query, text)):
256                yield item
257
258    def items_at_depth(self, depth):
259        for item in self.all_items:
260            if item.depth == depth:
261                yield item
262
263    def node_id_for_text(self, query):
264        for item in self.find_items(query):
265            return item.node_id
266
267    def node_id_for_href(self, query, exact=False):
268        for item in self.all_items:
269            href = item.href
270            if (exact and query == href) or (not exact and query in href):
271                return item.node_id
272
273    def search(self, query, delta=1):
274        cq = self.current_query
275        if cq['items'] and -1 < cq['index'] < len(cq['items']):
276            cq['items'][cq['index']].set_current_search_result(False)
277        if cq['text'] != query:
278            items = tuple(self.find_items(query))
279            cq.update({'text':query, 'items':items, 'index':-1})
280        num = len(cq['items'])
281        if num > 0:
282            cq['index'] = (cq['index'] + delta + num) % num
283            item = cq['items'][cq['index']]
284            item.set_current_search_result(True)
285            index = self.indexFromItem(item)
286            return index
287        return QModelIndex()
288
289    def update_current_toc_nodes(self, current_toc_leaves):
290        viewed_nodes = set()
291        ancestors = {}
292        for node_id in current_toc_leaves:
293            node = self.node_id_map.get(node_id)
294            if node is not None:
295                viewed_nodes.add(node_id)
296                ansc = tuple(node.ancestors)
297                viewed_nodes |= {x.node_id for x in ansc}
298                for x in ansc:
299                    ancestors[x.node_id] = x.index()
300        nodes = []
301        for node in self.all_items:
302            is_being_viewed = node.node_id in viewed_nodes
303            if is_being_viewed:
304                nodes.append(node)
305            if is_being_viewed != node.is_being_viewed:
306                node.set_being_viewed(is_being_viewed)
307        self.current_toc_nodes_changed.emit(tuple(ancestors.values()), nodes)
308
309    def viewed_nodes(self):
310        return tuple(node for node in self.all_items if node.is_being_viewed)
311
312    @property
313    def title_for_current_node(self):
314        for node in reversed(self.all_items):
315            if node.is_being_viewed:
316                return node.title
317
318    @property
319    def as_plain_text(self):
320        lines = []
321        for item in self.all_items:
322            lines.append(' ' * (4 * item.depth) + (item.title or ''))
323        return '\n'.join(lines)
324