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