1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import os, re, traceback 10from functools import partial 11 12from qt.core import ( 13 QStyledItemDelegate, Qt, QTreeView, pyqtSignal, QSize, QIcon, QApplication, QStyle, QAbstractItemView, 14 QMenu, QPoint, QToolTip, QCursor, QDrag, QRect, QModelIndex, 15 QLinearGradient, QPalette, QColor, QPen, QBrush, QFont, QTimer 16) 17 18from calibre import sanitize_file_name 19from calibre.constants import config_dir 20from calibre.ebooks.metadata import rating_to_stars 21from calibre.gui2.complete2 import EditWithComplete 22from calibre.gui2.tag_browser.model import (TagTreeItem, TAG_SEARCH_STATES, 23 TagsModel, DRAG_IMAGE_ROLE, COUNT_ROLE, rename_only_in_vl_question) 24from calibre.gui2.widgets import EnLineEdit 25from calibre.gui2 import (config, gprefs, choose_files, pixmap_to_data, 26 rating_font, empty_index, question_dialog) 27from calibre.utils.icu import sort_key 28from calibre.utils.serialize import json_loads 29 30 31class TagDelegate(QStyledItemDelegate): # {{{ 32 33 def __init__(self, tags_view): 34 QStyledItemDelegate.__init__(self, tags_view) 35 self.old_look = False 36 self.rating_pat = re.compile(r'[%s]' % rating_to_stars(3, True)) 37 self.rating_font = QFont(rating_font()) 38 self.completion_data = None 39 self.tags_view = tags_view 40 41 def draw_average_rating(self, item, style, painter, option, widget): 42 rating = item.average_rating 43 if rating is None: 44 return 45 r = style.subElementRect(QStyle.SubElement.SE_ItemViewItemDecoration, option, widget) 46 icon = option.icon 47 painter.save() 48 nr = r.adjusted(0, 0, 0, 0) 49 nr.setBottom(r.bottom()-int(r.height()*(rating/5.0))) 50 painter.setClipRect(nr) 51 bg = option.palette.window() 52 if self.old_look: 53 bg = option.palette.alternateBase() if option.features&option.Alternate else option.palette.base() 54 painter.fillRect(r, bg) 55 style.proxy().drawPrimitive(QStyle.PrimitiveElement.PE_PanelItemViewItem, option, painter, widget) 56 painter.setOpacity(0.3) 57 icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) 58 painter.restore() 59 60 def draw_icon(self, style, painter, option, widget): 61 r = style.subElementRect(QStyle.SubElement.SE_ItemViewItemDecoration, option, widget) 62 icon = option.icon 63 icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) 64 65 def paint_text(self, painter, rect, flags, text, hover): 66 set_color = hover and QApplication.instance().is_dark_theme 67 if set_color: 68 painter.save() 69 pen = painter.pen() 70 pen.setColor(QColor(Qt.GlobalColor.black)) 71 painter.setPen(pen) 72 painter.drawText(rect, flags, text) 73 if set_color: 74 painter.restore() 75 76 def draw_text(self, style, painter, option, widget, index, item): 77 tr = style.subElementRect(QStyle.SubElement.SE_ItemViewItemText, option, widget) 78 text = index.data(Qt.ItemDataRole.DisplayRole) 79 hover = option.state & QStyle.StateFlag.State_MouseOver 80 is_search = (True if item.type == TagTreeItem.TAG and 81 item.tag.category == 'search' else False) 82 if not is_search and (hover or gprefs['tag_browser_show_counts']): 83 count = str(index.data(COUNT_ROLE)) 84 width = painter.fontMetrics().boundingRect(count).width() 85 r = QRect(tr) 86 r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4) 87 self.paint_text(painter, r, Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextSingleLine, count, hover) 88 tr.setRight(r.left() - 1) 89 else: 90 tr.setRight(tr.right() - 1) 91 is_rating = item.type == TagTreeItem.TAG and not self.rating_pat.sub('', text) 92 if is_rating: 93 painter.setFont(self.rating_font) 94 flags = Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft | Qt.TextFlag.TextSingleLine 95 lr = QRect(tr) 96 lr.setRight(lr.right() * 2) 97 br = painter.boundingRect(lr, flags, text) 98 if br.width() > tr.width(): 99 g = QLinearGradient(tr.topLeft(), tr.topRight()) 100 c = option.palette.color(QPalette.ColorRole.WindowText) 101 g.setColorAt(0, c), g.setColorAt(0.8, c) 102 c = QColor(c) 103 c.setAlpha(0) 104 g.setColorAt(1, c) 105 pen = QPen() 106 pen.setBrush(QBrush(g)) 107 painter.setPen(pen) 108 self.paint_text(painter, tr, flags, text, hover) 109 110 def paint(self, painter, option, index): 111 QStyledItemDelegate.paint(self, painter, option, empty_index) 112 widget = self.parent() 113 style = QApplication.style() if widget is None else widget.style() 114 self.initStyleOption(option, index) 115 item = index.data(Qt.ItemDataRole.UserRole) 116 self.draw_icon(style, painter, option, widget) 117 painter.save() 118 self.draw_text(style, painter, option, widget, index, item) 119 painter.restore() 120 if item.boxed: 121 r = style.subElementRect(QStyle.SubElement.SE_ItemViewItemFocusRect, option, 122 widget) 123 painter.drawLine(r.bottomLeft(), r.bottomRight()) 124 if item.type == TagTreeItem.TAG and item.tag.state == 0 and config['show_avg_rating']: 125 self.draw_average_rating(item, style, painter, option, widget) 126 127 def set_completion_data(self, data): 128 self.completion_data = data 129 130 def createEditor(self, parent, option, index): 131 item = self.tags_view.model().get_node(index) 132 if not item.ignore_vl: 133 if item.use_vl is None: 134 if self.tags_view.model().get_in_vl(): 135 item.use_vl = rename_only_in_vl_question(self.tags_view) 136 else: 137 item.use_vl = False 138 elif not item.use_vl and self.tags_view.model().get_in_vl(): 139 item.use_vl = not question_dialog(self.tags_view, 140 _('Rename in Virtual library'), '<p>' + 141 _('A Virtual library is active but you are renaming ' 142 'the item in all books in your library. Is ' 143 'this really what you want to do?') + '</p>', 144 yes_text=_('Yes, apply in entire library'), 145 no_text=_('No, apply only in Virtual library'), 146 skip_dialog_name='tag_item_rename_in_entire_library') 147 if self.completion_data: 148 editor = EditWithComplete(parent) 149 editor.set_separator(None) 150 editor.update_items_cache(self.completion_data) 151 else: 152 editor = EnLineEdit(parent) 153 return editor 154 155 # }}} 156 157 158class TagsView(QTreeView): # {{{ 159 160 refresh_required = pyqtSignal() 161 tags_marked = pyqtSignal(object) 162 edit_user_category = pyqtSignal(object) 163 delete_user_category = pyqtSignal(object) 164 del_item_from_user_cat = pyqtSignal(object, object, object) 165 add_item_to_user_cat = pyqtSignal(object, object, object) 166 add_subcategory = pyqtSignal(object) 167 tags_list_edit = pyqtSignal(object, object, object) 168 saved_search_edit = pyqtSignal(object) 169 rebuild_saved_searches = pyqtSignal() 170 author_sort_edit = pyqtSignal(object, object, object, object, object) 171 tag_item_renamed = pyqtSignal() 172 search_item_renamed = pyqtSignal() 173 drag_drop_finished = pyqtSignal(object) 174 restriction_error = pyqtSignal(object) 175 tag_item_delete = pyqtSignal(object, object, object, object, object) 176 tag_identifier_delete = pyqtSignal(object, object) 177 apply_tag_to_selected = pyqtSignal(object, object, object) 178 edit_enum_values = pyqtSignal(object, object, object) 179 180 def __init__(self, parent=None): 181 QTreeView.__init__(self, parent=None) 182 self.possible_drag_start = None 183 self.setProperty('frame_for_focus', True) 184 self.setMouseTracking(True) 185 self.alter_tb = None 186 self.disable_recounting = False 187 self.setUniformRowHeights(True) 188 self.setIconSize(QSize(20, 20)) 189 self.setTabKeyNavigation(True) 190 self.setAnimated(True) 191 self.setHeaderHidden(True) 192 self.setItemDelegate(TagDelegate(tags_view=self)) 193 self.made_connections = False 194 self.setAcceptDrops(True) 195 self.setDragEnabled(True) 196 self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) 197 self.setDropIndicatorShown(True) 198 self.setAutoExpandDelay(500) 199 self.pane_is_visible = False 200 self.search_icon = QIcon(I('search.png')) 201 self.search_copy_icon = QIcon(I("search_copy_saved.png")) 202 self.user_category_icon = QIcon(I('tb_folder.png')) 203 self.edit_metadata_icon = QIcon(I('edit_input.png')) 204 self.delete_icon = QIcon(I('list_remove.png')) 205 self.rename_icon = QIcon(I('edit-undo.png')) 206 self.plus_icon = QIcon(I('plus.png')) 207 self.minus_icon = QIcon(I('minus.png')) 208 209 self._model = TagsModel(self) 210 self._model.search_item_renamed.connect(self.search_item_renamed) 211 self._model.refresh_required.connect(self.refresh_required, 212 type=Qt.ConnectionType.QueuedConnection) 213 self._model.tag_item_renamed.connect(self.tag_item_renamed) 214 self._model.restriction_error.connect(self.restriction_error) 215 self._model.user_categories_edited.connect(self.user_categories_edited, 216 type=Qt.ConnectionType.QueuedConnection) 217 self._model.drag_drop_finished.connect(self.drag_drop_finished) 218 self._model.convert_requested.connect(self.convert_requested) 219 self.set_look_and_feel(first=True) 220 QApplication.instance().palette_changed.connect(self.set_style_sheet, type=Qt.ConnectionType.QueuedConnection) 221 222 def convert_requested(self, book_ids, to_fmt): 223 from calibre.gui2.ui import get_gui 224 get_gui().iactions['Convert Books'].convert_ebooks_to_format(book_ids, to_fmt) 225 226 def set_style_sheet(self): 227 stylish_tb = ''' 228 QTreeView { 229 background-color: palette(window); 230 color: palette(window-text); 231 border: none; 232 } 233 ''' 234 self.setStyleSheet(''' 235 QTreeView::item { 236 border: 1px solid transparent; 237 padding-top:PADex; 238 padding-bottom:PADex; 239 } 240 241 QTreeView::item:hover { 242 background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #e7effd, stop: 1 #cbdaf1); 243 border: 1px solid #bfcde4; 244 border-radius: 6px; 245 } 246 '''.replace('PAD', str(gprefs['tag_browser_item_padding'])) + ( 247 '' if gprefs['tag_browser_old_look'] else stylish_tb)) 248 249 def set_look_and_feel(self, first=False): 250 self.set_style_sheet() 251 self.setAlternatingRowColors(gprefs['tag_browser_old_look']) 252 self.itemDelegate().old_look = gprefs['tag_browser_old_look'] 253 254 if gprefs['tag_browser_allow_keyboard_focus']: 255 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 256 else: 257 self.setFocusPolicy(Qt.FocusPolicy.NoFocus) 258 # Ensure the TB doesn't keep the focus it might already have. When this 259 # method is first called during GUI initialization not everything is 260 # set up, in which case don't try to change the focus. 261 # Note: this process has the side effect of moving the focus to the 262 # library view whenever a look & feel preference is changed. 263 if not first: 264 try: 265 from calibre.gui2.ui import get_gui 266 get_gui().shift_esc() 267 except: 268 traceback.print_exc() 269 270 @property 271 def hidden_categories(self): 272 return self._model.hidden_categories 273 274 @property 275 def db(self): 276 return self._model.db 277 278 @property 279 def collapse_model(self): 280 return self._model.collapse_model 281 282 def set_pane_is_visible(self, to_what): 283 pv = self.pane_is_visible 284 self.pane_is_visible = to_what 285 if to_what and not pv: 286 self.recount() 287 288 def get_state(self): 289 state_map = {} 290 expanded_categories = [] 291 hide_empty_categories = self.model().prefs['tag_browser_hide_empty_categories'] 292 crmap = self._model.category_row_map() 293 for category in self._model.category_nodes: 294 if (category.category_key in self.hidden_categories or ( 295 hide_empty_categories and len(category.child_tags()) == 0)): 296 continue 297 row = crmap.get(category.category_key) 298 if row is not None: 299 index = self._model.index(row, 0, QModelIndex()) 300 if self.isExpanded(index): 301 expanded_categories.append(category.category_key) 302 states = [c.tag.state for c in category.child_tags()] 303 names = [(c.tag.name, c.tag.category) for c in category.child_tags()] 304 state_map[category.category_key] = dict(zip(names, states)) 305 return expanded_categories, state_map 306 307 def reread_collapse_parameters(self): 308 self._model.reread_collapse_model(self.get_state()[1]) 309 310 def set_database(self, db, alter_tb): 311 self._model.set_database(db) 312 self.alter_tb = alter_tb 313 self.pane_is_visible = True # because TagsModel.set_database did a recount 314 self.setModel(self._model) 315 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) 316 pop = self.db.CATEGORY_SORTS.index(config['sort_tags_by']) 317 self.alter_tb.sort_menu.actions()[pop].setChecked(True) 318 try: 319 match_pop = self.db.MATCH_TYPE.index(config['match_tags_type']) 320 except ValueError: 321 match_pop = 0 322 self.alter_tb.match_menu.actions()[match_pop].setChecked(True) 323 if not self.made_connections: 324 self.clicked.connect(self.toggle) 325 self.customContextMenuRequested.connect(self.show_context_menu) 326 self.refresh_required.connect(self.recount, type=Qt.ConnectionType.QueuedConnection) 327 self.alter_tb.sort_menu.triggered.connect(self.sort_changed) 328 self.alter_tb.match_menu.triggered.connect(self.match_changed) 329 self.made_connections = True 330 self.refresh_signal_processed = True 331 db.add_listener(self.database_changed) 332 self.expanded.connect(self.item_expanded) 333 self.collapsed.connect(self.collapse_node_and_children) 334 335 def keyPressEvent(self, event): 336 337 def on_last_visible_item(dex, check_children): 338 model = self._model 339 if model.get_node(dex) == model.root_item: 340 # Got to root. There can't be any more children to show 341 return True 342 if check_children and self.isExpanded(dex): 343 # We are on a node with expanded children so there is a node to go to. 344 # We don't check children if we are moving up the parent hierarchy 345 return False 346 parent = model.parent(dex) 347 if dex.row() < model.rowCount(parent) - 1: 348 # Node has more nodes after it 349 return False 350 # Last node. Check the parent for further to see if there are more nodes 351 return on_last_visible_item(parent, False) 352 353 # I don't see how current_index can ever be not valid, but ... 354 if self.currentIndex().isValid(): 355 key = event.key() 356 if gprefs['tag_browser_allow_keyboard_focus']: 357 if key == Qt.Key.Key_Return and self.state() != QAbstractItemView.State.EditingState: 358 self.toggle_current_index() 359 return 360 # Check if we are moving the focus and we are at the beginning or the 361 # end of the list. The goal is to prevent moving focus away from the 362 # tag browser. 363 if key == Qt.Key.Key_Tab: 364 if not on_last_visible_item(self.currentIndex(), True): 365 QTreeView.keyPressEvent(self, event) 366 return 367 if key == Qt.Key.Key_Backtab: 368 if self.model().get_node(self.currentIndex()) != self._model.root_item.children[0]: 369 QTreeView.keyPressEvent(self, event) 370 return 371 # If this is an edit request, mark the node to request whether to use VLs 372 # As far as I can tell, F2 is used across all platforms 373 if key == Qt.Key.Key_F2: 374 node = self.model().get_node(self.currentIndex()) 375 if node.type == TagTreeItem.TAG: 376 # Saved search nodes don't use the VL test/dialog 377 node.use_vl = None 378 node.ignore_vl = node.tag.category == 'search' 379 else: 380 # Don't open the editor for non-editable items 381 if not node.category_key.startswith('@') or node.is_gst: 382 return 383 # Category nodes don't use the VL test/dialog 384 node.use_vl = False 385 node.ignore_vl = True 386 QTreeView.keyPressEvent(self, event) 387 388 def database_changed(self, event, ids): 389 if self.refresh_signal_processed: 390 self.refresh_signal_processed = False 391 self.refresh_required.emit() 392 393 def user_categories_edited(self, user_cats, nkey): 394 state_map = self.get_state()[1] 395 self.db.new_api.set_pref('user_categories', user_cats) 396 self._model.rebuild_node_tree(state_map=state_map) 397 p = self._model.find_category_node('@'+nkey) 398 self.show_item_at_path(p) 399 400 @property 401 def match_all(self): 402 return (self.alter_tb and self.alter_tb.match_menu.actions()[1].isChecked()) 403 404 def sort_changed(self, action): 405 for i, ac in enumerate(self.alter_tb.sort_menu.actions()): 406 if ac is action: 407 config.set('sort_tags_by', self.db.CATEGORY_SORTS[i]) 408 self.recount() 409 break 410 411 def match_changed(self, action): 412 try: 413 for i, ac in enumerate(self.alter_tb.match_menu.actions()): 414 if ac is action: 415 config.set('match_tags_type', self.db.MATCH_TYPE[i]) 416 except: 417 pass 418 419 def mousePressEvent(self, event): 420 if event.buttons() & Qt.MouseButton.LeftButton: 421 # Only remember a possible drag start if the item is drag enabled 422 dex = self.indexAt(event.pos()) 423 if self._model.flags(dex) & Qt.ItemFlag.ItemIsDragEnabled: 424 self.possible_drag_start = event.pos() 425 else: 426 self.possible_drag_start = None 427 return QTreeView.mousePressEvent(self, event) 428 429 def mouseMoveEvent(self, event): 430 dex = self.indexAt(event.pos()) 431 if dex.isValid(): 432 self.setCursor(Qt.CursorShape.PointingHandCursor) 433 else: 434 self.unsetCursor() 435 if not event.buttons() & Qt.MouseButton.LeftButton: 436 return 437 if not dex.isValid(): 438 QTreeView.mouseMoveEvent(self, event) 439 return 440 # don't start drag/drop until the mouse has moved a bit. 441 if (self.possible_drag_start is None or 442 (event.pos() - self.possible_drag_start).manhattanLength() < 443 QApplication.startDragDistance()): 444 QTreeView.mouseMoveEvent(self, event) 445 return 446 447 if not self._model.flags(dex) & Qt.ItemFlag.ItemIsDragEnabled: 448 QTreeView.mouseMoveEvent(self, event) 449 return 450 md = self._model.mimeData([dex]) 451 pixmap = dex.data(DRAG_IMAGE_ROLE).pixmap(self.iconSize()) 452 drag = QDrag(self) 453 drag.setPixmap(pixmap) 454 drag.setMimeData(md) 455 if (self._model.is_in_user_category(dex) or 456 self._model.is_index_on_a_hierarchical_category(dex)): 457 ''' 458 Things break if we specify MoveAction as the default, which is 459 what we want for drag on hierarchical categories. Dragging user 460 categories stops working. Don't know why. To avoid the problem 461 we fix the action in dragMoveEvent. 462 ''' 463 drag.exec(Qt.DropAction.CopyAction|Qt.DropAction.MoveAction, Qt.DropAction.CopyAction) 464 else: 465 drag.exec(Qt.DropAction.CopyAction) 466 467 def mouseDoubleClickEvent(self, event): 468 # swallow these to avoid toggling and editing at the same time 469 pass 470 471 @property 472 def search_string(self): 473 tokens = self._model.tokens() 474 joiner = ' and ' if self.match_all else ' or ' 475 return joiner.join(tokens) 476 477 def toggle_current_index(self): 478 ci = self.currentIndex() 479 if ci.isValid(): 480 self.toggle(ci) 481 482 def toggle(self, index): 483 self._toggle(index, None) 484 485 def _toggle(self, index, set_to): 486 ''' 487 set_to: if None, advance the state. Otherwise must be one of the values 488 in TAG_SEARCH_STATES 489 ''' 490 exclusive = QApplication.keyboardModifiers() not in (Qt.KeyboardModifier.ControlModifier, Qt.KeyboardModifier.ShiftModifier) 491 if self._model.toggle(index, exclusive, set_to=set_to): 492 # Reset the focus back to TB if it has it before the toggle 493 # Must ask this question before starting the search because 494 # it changes the focus 495 has_focus = self.hasFocus() 496 self.tags_marked.emit(self.search_string) 497 if has_focus and gprefs['tag_browser_allow_keyboard_focus']: 498 # Reset the focus to the TB. Use the singleshot in case 499 # some of searching is done using queued signals. 500 QTimer.singleShot(0, lambda: self.setFocus()) 501 502 def conditional_clear(self, search_string): 503 if search_string != self.search_string: 504 self.clear() 505 506 def context_menu_handler(self, action=None, category=None, 507 key=None, index=None, search_state=None, 508 is_first_letter=False, ignore_vl=False): 509 if not action: 510 return 511 try: 512 if action == 'set_icon': 513 try: 514 path = choose_files(self, 'choose_category_icon', 515 _('Change icon for: %s')%key, filters=[ 516 ('Images', ['png', 'gif', 'jpg', 'jpeg'])], 517 all_files=False, select_only_single_file=True) 518 if path: 519 path = path[0] 520 p = QIcon(path).pixmap(QSize(128, 128)) 521 d = os.path.join(config_dir, 'tb_icons') 522 if not os.path.exists(d): 523 os.makedirs(d) 524 with open(os.path.join(d, 'icon_' + sanitize_file_name(key)+'.png'), 'wb') as f: 525 f.write(pixmap_to_data(p, format='PNG')) 526 path = os.path.basename(f.name) 527 self._model.set_custom_category_icon(key, str(path)) 528 self.recount() 529 except: 530 traceback.print_exc() 531 return 532 if action == 'clear_icon': 533 self._model.set_custom_category_icon(key, None) 534 self.recount() 535 return 536 537 def set_completion_data(category): 538 try: 539 completion_data = self.db.new_api.all_field_names(category) 540 except: 541 completion_data = None 542 self.itemDelegate().set_completion_data(completion_data) 543 544 if action == 'edit_item_no_vl': 545 item = self.model().get_node(index) 546 item.use_vl = False 547 item.ignore_vl = ignore_vl 548 set_completion_data(category) 549 self.edit(index) 550 return 551 if action == 'edit_item_in_vl': 552 item = self.model().get_node(index) 553 item.use_vl = True 554 item.ignore_vl = ignore_vl 555 set_completion_data(category) 556 self.edit(index) 557 return 558 if action == 'delete_item_in_vl': 559 tag = index.tag 560 id_ = tag.id if tag.is_editable else None 561 children = index.child_tags() 562 self.tag_item_delete.emit(key, id_, tag.original_name, 563 self.model().get_book_ids_to_use(), 564 children) 565 return 566 if action == 'delete_item_no_vl': 567 tag = index.tag 568 id_ = tag.id if tag.is_editable else None 569 children = index.child_tags() 570 self.tag_item_delete.emit(key, id_, tag.original_name, 571 None, children) 572 return 573 if action == 'delete_identifier': 574 self.tag_identifier_delete.emit(index.tag.name, False) 575 return 576 if action == 'delete_identifier_in_vl': 577 self.tag_identifier_delete.emit(index.tag.name, True) 578 return 579 if action == 'open_editor': 580 self.tags_list_edit.emit(category, key, is_first_letter) 581 return 582 if action == 'manage_categories': 583 self.edit_user_category.emit(category) 584 return 585 if action == 'search': 586 self._toggle(index, set_to=search_state) 587 return 588 if action == "raw_search": 589 from calibre.gui2.ui import get_gui 590 get_gui().get_saved_search_text(search_name='search:' + key) 591 return 592 if action == 'add_to_category': 593 tag = index.tag 594 if len(index.children) > 0: 595 for c in index.all_children(): 596 self.add_item_to_user_cat.emit(category, c.tag.original_name, 597 c.tag.category) 598 self.add_item_to_user_cat.emit(category, tag.original_name, 599 tag.category) 600 return 601 if action == 'add_subcategory': 602 self.add_subcategory.emit(key) 603 return 604 if action == 'search_category': 605 self._toggle(index, set_to=search_state) 606 return 607 if action == 'delete_user_category': 608 self.delete_user_category.emit(key) 609 return 610 if action == 'delete_search': 611 if not question_dialog( 612 self, 613 title=_('Delete Saved search'), 614 msg='<p>'+ _('Delete the saved search: {}?').format(key), 615 skip_dialog_name='tb_delete_saved_search', 616 skip_dialog_msg=_('Show this confirmation again') 617 ): 618 return 619 self.model().db.saved_search_delete(key) 620 self.rebuild_saved_searches.emit() 621 return 622 if action == 'delete_item_from_user_category': 623 tag = index.tag 624 if len(index.children) > 0: 625 for c in index.children: 626 self.del_item_from_user_cat.emit(key, c.tag.original_name, 627 c.tag.category) 628 self.del_item_from_user_cat.emit(key, tag.original_name, tag.category) 629 return 630 if action == 'manage_searches': 631 self.saved_search_edit.emit(category) 632 return 633 if action == 'edit_authors': 634 self.author_sort_edit.emit(self, index, False, False, is_first_letter) 635 return 636 if action == 'edit_author_sort': 637 self.author_sort_edit.emit(self, index, True, False, is_first_letter) 638 return 639 if action == 'edit_author_link': 640 self.author_sort_edit.emit(self, index, False, True, False) 641 return 642 643 reset_filter_categories = True 644 if action == 'hide': 645 self.hidden_categories.add(category) 646 elif action == 'show': 647 self.hidden_categories.discard(category) 648 elif action == 'categorization': 649 changed = self.collapse_model != category 650 self._model.collapse_model = category 651 if changed: 652 reset_filter_categories = False 653 gprefs['tags_browser_partition_method'] = category 654 elif action == 'defaults': 655 self.hidden_categories.clear() 656 elif action == 'add_tag': 657 item = self.model().get_node(index) 658 if item is not None: 659 self.apply_to_selected_books(item) 660 return 661 elif action == 'remove_tag': 662 item = self.model().get_node(index) 663 if item is not None: 664 self.apply_to_selected_books(item, True) 665 return 666 elif action == 'edit_enum': 667 self.edit_enum_values.emit(self, self.db, key) 668 return 669 self.db.new_api.set_pref('tag_browser_hidden_categories', list(self.hidden_categories)) 670 if reset_filter_categories: 671 self._model.set_categories_filter(None) 672 self._model.rebuild_node_tree() 673 except Exception: 674 traceback.print_exc() 675 return 676 677 def apply_to_selected_books(self, item, remove=False): 678 if item.type != item.TAG: 679 return 680 tag = item.tag 681 if not tag.category or not tag.original_name: 682 return 683 self.apply_tag_to_selected.emit(tag.category, tag.original_name, remove) 684 685 def show_context_menu(self, point): 686 def display_name(tag): 687 ans = tag.name 688 if tag.category == 'search': 689 n = tag.name 690 if len(n) > 45: 691 n = n[:45] + '...' 692 ans = n 693 elif tag.is_hierarchical and not tag.is_editable: 694 ans = tag.original_name 695 if ans: 696 ans = ans.replace('&', '&&') 697 return ans 698 699 index = self.indexAt(point) 700 self.context_menu = QMenu(self) 701 added_show_hidden_categories = False 702 703 def add_show_hidden_categories(): 704 nonlocal added_show_hidden_categories 705 if self.hidden_categories and not added_show_hidden_categories: 706 added_show_hidden_categories = True 707 m = self.context_menu.addMenu(_('Show category')) 708 m.setIcon(QIcon(I('plus.png'))) 709 for col in sorted(self.hidden_categories, 710 key=lambda x: sort_key(self.db.field_metadata[x]['name'])): 711 ac = m.addAction(self.db.field_metadata[col]['name'], 712 partial(self.context_menu_handler, action='show', category=col)) 713 ic = self.model().category_custom_icons.get(col) 714 if ic: 715 ac.setIcon(QIcon(ic)) 716 m.addSeparator() 717 m.addAction(_('All categories'), 718 partial(self.context_menu_handler, action='defaults')).setIcon(QIcon(I('plusplus.png'))) 719 720 search_submenu = None 721 if index.isValid(): 722 item = index.data(Qt.ItemDataRole.UserRole) 723 tag = None 724 tag_item = item 725 726 if item.type == TagTreeItem.TAG: 727 tag = item.tag 728 while item.type != TagTreeItem.CATEGORY: 729 item = item.parent 730 731 if item.type == TagTreeItem.CATEGORY: 732 if not item.category_key.startswith('@'): 733 while item.parent != self._model.root_item: 734 item = item.parent 735 category = str(item.name or '') 736 key = item.category_key 737 # Verify that we are working with a field that we know something about 738 if key not in self.db.field_metadata: 739 return True 740 fm = self.db.field_metadata[key] 741 742 # Did the user click on a leaf node? 743 if tag: 744 # If the user right-clicked on an editable item, then offer 745 # the possibility of renaming that item. 746 if (fm['datatype'] != 'composite' and 747 (tag.is_editable or tag.is_hierarchical) and 748 key != 'search'): 749 # Add the 'rename' items to both interior and leaf nodes 750 if fm['datatype'] != 'enumeration': 751 if self.model().get_in_vl(): 752 self.context_menu.addAction(self.rename_icon, 753 _('Rename %s in Virtual library')%display_name(tag), 754 partial(self.context_menu_handler, action='edit_item_in_vl', 755 index=index, category=key)) 756 self.context_menu.addAction(self.rename_icon, 757 _('Rename %s')%display_name(tag), 758 partial(self.context_menu_handler, action='edit_item_no_vl', 759 index=index, category=key)) 760 if key in ('tags', 'series', 'publisher') or \ 761 self._model.db.field_metadata.is_custom_field(key): 762 if self.model().get_in_vl(): 763 self.context_menu.addAction(self.delete_icon, 764 _('Delete %s in Virtual library')%display_name(tag), 765 partial(self.context_menu_handler, action='delete_item_in_vl', 766 key=key, index=tag_item)) 767 768 self.context_menu.addAction(self.delete_icon, 769 _('Delete %s')%display_name(tag), 770 partial(self.context_menu_handler, action='delete_item_no_vl', 771 key=key, index=tag_item)) 772 if tag.is_editable: 773 if key == 'authors': 774 self.context_menu.addAction(_('Edit sort for %s')%display_name(tag), 775 partial(self.context_menu_handler, 776 action='edit_author_sort', index=tag.id)).setIcon(QIcon(I('auto_author_sort.png'))) 777 self.context_menu.addAction(_('Edit link for %s')%display_name(tag), 778 partial(self.context_menu_handler, 779 action='edit_author_link', index=tag.id)).setIcon(QIcon(I('insert-link.png'))) 780 781 # is_editable is also overloaded to mean 'can be added 782 # to a User category' 783 m = QMenu(_('Add %s to User category')%display_name(tag), self.context_menu) 784 m.setIcon(self.user_category_icon) 785 added = [False] 786 787 def add_node_tree(tree_dict, m, path): 788 p = path[:] 789 for k in sorted(tree_dict.keys(), key=sort_key): 790 p.append(k) 791 n = k[1:] if k.startswith('@') else k 792 m.addAction(self.user_category_icon, n, 793 partial(self.context_menu_handler, 794 'add_to_category', 795 category='.'.join(p), index=tag_item)) 796 added[0] = True 797 if len(tree_dict[k]): 798 tm = m.addMenu(self.user_category_icon, 799 _('Children of %s')%n) 800 add_node_tree(tree_dict[k], tm, p) 801 p.pop() 802 add_node_tree(self.model().user_category_node_tree, m, []) 803 if added[0]: 804 self.context_menu.addMenu(m) 805 806 # is_editable also means the tag can be applied/removed 807 # from selected books 808 if fm['datatype'] != 'rating': 809 m = self.context_menu.addMenu(self.edit_metadata_icon, 810 _('Add/remove %s to selected books')%display_name(tag)) 811 m.addAction(self.plus_icon, 812 _('Add %s to selected books') % display_name(tag), 813 partial(self.context_menu_handler, action='add_tag', index=index)) 814 m.addAction(self.minus_icon, 815 _('Remove %s from selected books') % display_name(tag), 816 partial(self.context_menu_handler, action='remove_tag', index=index)) 817 818 elif key == 'search' and tag.is_searchable: 819 self.context_menu.addAction(self.rename_icon, 820 _('Rename %s')%display_name(tag), 821 partial(self.context_menu_handler, action='edit_item_no_vl', 822 index=index, ignore_vl=True)) 823 self.context_menu.addAction(self.delete_icon, 824 _('Delete Saved search %s')%display_name(tag), 825 partial(self.context_menu_handler, 826 action='delete_search', key=tag.original_name)) 827 elif key == 'identifiers': 828 if self.model().get_in_vl(): 829 self.context_menu.addAction(self.delete_icon, 830 _('Delete %s in Virtual Library')%display_name(tag), 831 partial(self.context_menu_handler, 832 action='delete_identifier_in_vl', 833 key=key, index=tag_item)) 834 else: 835 self.context_menu.addAction(self.delete_icon, 836 _('Delete %s')%display_name(tag), 837 partial(self.context_menu_handler, 838 action='delete_identifier', 839 key=key, index=tag_item)) 840 841 if key.startswith('@') and not item.is_gst: 842 self.context_menu.addAction(self.user_category_icon, 843 _('Remove %(item)s from category %(cat)s')% 844 dict(item=display_name(tag), cat=item.py_name), 845 partial(self.context_menu_handler, 846 action='delete_item_from_user_category', 847 key=key, index=tag_item)) 848 if tag.is_searchable: 849 # Add the search for value items. All leaf nodes are searchable 850 self.context_menu.addSeparator() 851 search_submenu = self.context_menu.addMenu(_('Search for')) 852 search_submenu.setIcon(QIcon(I('search.png'))) 853 search_submenu.addAction(self.search_icon, 854 '%s'%display_name(tag), 855 partial(self.context_menu_handler, action='search', 856 search_state=TAG_SEARCH_STATES['mark_plus'], 857 index=index)) 858 add_child_search = (tag.is_hierarchical == '5state' and 859 len(tag_item.children)) 860 if add_child_search: 861 search_submenu.addAction(self.search_icon, 862 _('%s and its children')%display_name(tag), 863 partial(self.context_menu_handler, action='search', 864 search_state=TAG_SEARCH_STATES['mark_plusplus'], 865 index=index)) 866 search_submenu.addAction(self.search_icon, 867 _('Everything but %s')%display_name(tag), 868 partial(self.context_menu_handler, action='search', 869 search_state=TAG_SEARCH_STATES['mark_minus'], 870 index=index)) 871 if add_child_search: 872 search_submenu.addAction(self.search_icon, 873 _('Everything but %s and its children')%display_name(tag), 874 partial(self.context_menu_handler, action='search', 875 search_state=TAG_SEARCH_STATES['mark_minusminus'], 876 index=index)) 877 if key == 'search': 878 search_submenu.addAction(self.search_copy_icon, 879 _('The saved search expression'), 880 partial(self.context_menu_handler, action='raw_search', 881 key=tag.name)) 882 self.context_menu.addSeparator() 883 elif key.startswith('@') and not item.is_gst: 884 if item.can_be_edited: 885 self.context_menu.addAction(self.rename_icon, 886 _('Rename %s')%item.py_name, 887 partial(self.context_menu_handler, action='edit_item_no_vl', 888 index=index, ignore_vl=True)) 889 self.context_menu.addAction(self.user_category_icon, 890 _('Add sub-category to %s')%item.py_name, 891 partial(self.context_menu_handler, 892 action='add_subcategory', key=key)) 893 self.context_menu.addAction(self.delete_icon, 894 _('Delete User category %s')%item.py_name, 895 partial(self.context_menu_handler, 896 action='delete_user_category', key=key)) 897 self.context_menu.addSeparator() 898 # Add searches for temporary first letter nodes 899 if self._model.collapse_model == 'first letter' and \ 900 tag_item.temporary and not key.startswith('@'): 901 self.context_menu.addSeparator() 902 search_submenu = self.context_menu.addMenu(_('Search for')) 903 search_submenu.setIcon(QIcon(I('search.png'))) 904 search_submenu.addAction(self.search_icon, 905 '%s'%display_name(tag_item.tag), 906 partial(self.context_menu_handler, action='search', 907 search_state=TAG_SEARCH_STATES['mark_plus'], 908 index=index)) 909 search_submenu.addAction(self.search_icon, 910 _('Everything but %s')%display_name(tag_item.tag), 911 partial(self.context_menu_handler, action='search', 912 search_state=TAG_SEARCH_STATES['mark_minus'], 913 index=index)) 914 # search by category. Some categories are not searchable, such 915 # as search and news 916 if item.tag.is_searchable: 917 if search_submenu is None: 918 search_submenu = self.context_menu.addMenu(_('Search for')) 919 search_submenu.setIcon(QIcon(I('search.png'))) 920 self.context_menu.addSeparator() 921 else: 922 search_submenu.addSeparator() 923 search_submenu.addAction(self.search_icon, 924 _('Books in category %s')%category, 925 partial(self.context_menu_handler, 926 action='search_category', 927 index=self._model.createIndex(item.row(), 0, item), 928 search_state=TAG_SEARCH_STATES['mark_plus'])) 929 search_submenu.addAction(self.search_icon, 930 _('Books not in category %s')%category, 931 partial(self.context_menu_handler, 932 action='search_category', 933 index=self._model.createIndex(item.row(), 0, item), 934 search_state=TAG_SEARCH_STATES['mark_minus'])) 935 936 # Offer specific editors for tags/series/publishers/saved searches 937 self.context_menu.addSeparator() 938 if key in ['tags', 'publisher', 'series'] or ( 939 fm['is_custom'] and fm['datatype'] != 'composite'): 940 if tag_item.type == TagTreeItem.CATEGORY and tag_item.temporary: 941 ac = self.context_menu.addAction(_('Manage %s')%category, 942 partial(self.context_menu_handler, action='open_editor', 943 category=tag_item.name, 944 key=key, is_first_letter=True)) 945 else: 946 ac = self.context_menu.addAction(_('Manage %s')%category, 947 partial(self.context_menu_handler, action='open_editor', 948 category=tag.original_name if tag else None, 949 key=key)) 950 ic = self.model().category_custom_icons.get(key) 951 if ic: 952 ac.setIcon(QIcon(ic)) 953 if fm['datatype'] == 'enumeration': 954 self.context_menu.addAction(_('Edit permissible values for %s')%category, 955 partial(self.context_menu_handler, action='edit_enum', 956 key=key)) 957 elif key == 'authors': 958 if tag_item.type == TagTreeItem.CATEGORY: 959 if tag_item.temporary: 960 ac = self.context_menu.addAction(_('Manage %s')%category, 961 partial(self.context_menu_handler, action='edit_authors', 962 index=tag_item.name, is_first_letter=True)) 963 else: 964 ac = self.context_menu.addAction(_('Manage %s')%category, 965 partial(self.context_menu_handler, action='edit_authors')) 966 else: 967 ac = self.context_menu.addAction(_('Manage %s')%category, 968 partial(self.context_menu_handler, action='edit_authors', 969 index=tag.id)) 970 ic = self.model().category_custom_icons.get(key) 971 if ic: 972 ac.setIcon(QIcon(ic)) 973 elif key == 'search': 974 self.context_menu.addAction(_('Manage Saved searches'), 975 partial(self.context_menu_handler, action='manage_searches', 976 category=tag.name if tag else None)) 977 978 # Hide/Show/Restore categories 979 self.context_menu.addSeparator() 980 self.context_menu.addAction(_('Hide category %s') % category, 981 partial(self.context_menu_handler, action='hide', 982 category=key)).setIcon(QIcon(I('minus.png'))) 983 add_show_hidden_categories() 984 985 if tag is None: 986 self.context_menu.addSeparator() 987 self.context_menu.addAction(_('Change category icon'), 988 partial(self.context_menu_handler, action='set_icon', key=key)).setIcon(QIcon(I('icon_choose.png'))) 989 self.context_menu.addAction(_('Restore default icon'), 990 partial(self.context_menu_handler, action='clear_icon', key=key)).setIcon(QIcon(I('edit-clear.png'))) 991 992 # Always show the User categories editor 993 self.context_menu.addSeparator() 994 if key.startswith('@') and \ 995 key[1:] in self.db.new_api.pref('user_categories', {}).keys(): 996 self.context_menu.addAction(self.user_category_icon, 997 _('Manage User categories'), 998 partial(self.context_menu_handler, action='manage_categories', 999 category=key[1:])) 1000 else: 1001 self.context_menu.addAction(self.user_category_icon, 1002 _('Manage User categories'), 1003 partial(self.context_menu_handler, action='manage_categories', 1004 category=None)) 1005 if self.hidden_categories: 1006 if not self.context_menu.isEmpty(): 1007 self.context_menu.addSeparator() 1008 add_show_hidden_categories() 1009 1010 m = self.context_menu.addMenu(_('Change sub-categorization scheme')) 1011 m.setIcon(QIcon(I('config.png'))) 1012 da = m.addAction(_('Disable'), 1013 partial(self.context_menu_handler, action='categorization', category='disable')) 1014 fla = m.addAction(_('By first letter'), 1015 partial(self.context_menu_handler, action='categorization', category='first letter')) 1016 pa = m.addAction(_('Partition'), 1017 partial(self.context_menu_handler, action='categorization', category='partition')) 1018 if self.collapse_model == 'disable': 1019 da.setCheckable(True) 1020 da.setChecked(True) 1021 elif self.collapse_model == 'first letter': 1022 fla.setCheckable(True) 1023 fla.setChecked(True) 1024 else: 1025 pa.setCheckable(True) 1026 pa.setChecked(True) 1027 1028 if config['sort_tags_by'] != "name": 1029 fla.setEnabled(False) 1030 m.hovered.connect(self.collapse_menu_hovered) 1031 fla.setToolTip(_('First letter is usable only when sorting by name')) 1032 # Apparently one cannot set a tooltip to empty, so use a star and 1033 # deal with it in the hover method 1034 da.setToolTip('*') 1035 pa.setToolTip('*') 1036 1037 # Add expand menu items 1038 self.context_menu.addSeparator() 1039 m = self.context_menu.addMenu(_('Expand or collapse')) 1040 try: 1041 node_name = self._model.get_node(index).tag.name 1042 except AttributeError: 1043 pass 1044 else: 1045 if self.has_children(index) and not self.isExpanded(index): 1046 m.addAction(self.plus_icon, 1047 _('Expand {0}').format(node_name), partial(self.expand, index)) 1048 if self.has_unexpanded_children(index): 1049 m.addAction(self.plus_icon, 1050 _('Expand {0} and its children').format(node_name), 1051 partial(self.expand_node_and_children, index)) 1052 1053 # Add menu items to collapse parent nodes 1054 idx = index 1055 paths = [] 1056 while True: 1057 # First walk up the node tree getting the displayed names of 1058 # expanded parent nodes 1059 node = self._model.get_node(idx) 1060 if node.type == TagTreeItem.ROOT: 1061 break 1062 if self.has_children(idx) and self.isExpanded(idx): 1063 # leaf nodes don't have children so can't be expanded. 1064 # Also the leaf node might be collapsed 1065 paths.append((node.tag.name, idx)) 1066 idx = self._model.parent(idx) 1067 for p in paths: 1068 # Now add the menu items 1069 m.addAction(self.minus_icon, 1070 _("Collapse {0}").format(p[0]), partial(self.collapse_node, p[1])) 1071 m.addAction(self.minus_icon, _('Collapse all'), self.collapseAll) 1072 1073 # Ask plugins if they have any actions to add to the context menu 1074 from calibre.gui2.ui import get_gui 1075 first = True 1076 for ac in get_gui().iactions.values(): 1077 try: 1078 for context_action in ac.tag_browser_context_action(index): 1079 if first: 1080 self.context_menu.addSeparator() 1081 first = False 1082 self.context_menu.addAction(context_action) 1083 except Exception: 1084 import traceback 1085 traceback.print_exc() 1086 1087 if not self.context_menu.isEmpty(): 1088 self.context_menu.popup(self.mapToGlobal(point)) 1089 return True 1090 1091 def has_children(self, idx): 1092 return self.model().rowCount(idx) > 0 1093 1094 def collapse_node_and_children(self, idx): 1095 self.collapse(idx) 1096 for r in range(self.model().rowCount(idx)): 1097 self.collapse_node_and_children(idx.child(r, 0)) 1098 1099 def collapse_node(self, idx): 1100 if not idx.isValid(): 1101 return 1102 self.collapse_node_and_children(idx) 1103 self.setCurrentIndex(idx) 1104 self.scrollTo(idx) 1105 1106 def expand_node_and_children(self, index): 1107 if not index.isValid(): 1108 return 1109 self.expand(index) 1110 for r in range(self.model().rowCount(index)): 1111 self.expand_node_and_children(index.child(r, 0)) 1112 1113 def has_unexpanded_children(self, index): 1114 if not index.isValid(): 1115 return False 1116 for r in range(self._model.rowCount(index)): 1117 dex = index.child(r, 0) 1118 if self._model.rowCount(dex) > 0: 1119 if not self.isExpanded(dex): 1120 return True 1121 return self.has_unexpanded_children(dex) 1122 return False 1123 1124 def collapse_menu_hovered(self, action): 1125 tip = action.toolTip() 1126 if tip == '*': 1127 tip = '' 1128 QToolTip.showText(QCursor.pos(), tip) 1129 1130 def dragMoveEvent(self, event): 1131 QTreeView.dragMoveEvent(self, event) 1132 self.setDropIndicatorShown(False) 1133 index = self.indexAt(event.pos()) 1134 if not index.isValid(): 1135 return 1136 src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser') 1137 item = index.data(Qt.ItemDataRole.UserRole) 1138 if item.type == TagTreeItem.ROOT: 1139 return 1140 1141 if src_is_tb: 1142 src_json = json_loads(bytes(event.mimeData().data('application/calibre+from_tag_browser'))) 1143 if len(src_json) > 1: 1144 # Should never have multiple mimedata from the tag browser 1145 return 1146 if src_is_tb: 1147 src_md = src_json[0] 1148 src_item = self._model.get_node(self._model.index_for_path(src_md[5])) 1149 # Check if this is an intra-hierarchical-category drag/drop 1150 if (src_item.type == TagTreeItem.TAG and 1151 src_item.tag.category == item.tag.category and 1152 not item.temporary and 1153 self._model.is_key_a_hierarchical_category(src_item.tag.category)): 1154 event.setDropAction(Qt.DropAction.MoveAction) 1155 self.setDropIndicatorShown(True) 1156 return 1157 # We aren't dropping an item on its own category. Check if the dest is 1158 # not a user category and can be dropped on. This covers drops from the 1159 # booklist. It is OK to drop onto virtual nodes 1160 if item.type == TagTreeItem.TAG and self._model.flags(index) & Qt.ItemFlag.ItemIsDropEnabled: 1161 event.setDropAction(Qt.DropAction.CopyAction) 1162 self.setDropIndicatorShown(not src_is_tb) 1163 return 1164 # Now see if we are on a user category and the source can be dropped there 1165 if item.type == TagTreeItem.CATEGORY and not item.is_gst: 1166 fm_dest = self.db.metadata_for_field(item.category_key) 1167 if fm_dest['kind'] == 'user': 1168 if src_is_tb: 1169 # src_md and src_item are initialized above 1170 if event.dropAction() == Qt.DropAction.MoveAction: 1171 # can move only from user categories 1172 if (src_md[0] == TagTreeItem.TAG and 1173 (not src_md[1].startswith('@') or src_md[2])): 1174 return 1175 # can't copy virtual nodes into a user category 1176 if src_item.tag.is_editable: 1177 self.setDropIndicatorShown(True) 1178 return 1179 md = event.mimeData() 1180 # Check for drag to user category from the book list. Can handle 1181 # only non-multiple columns, except for some unknown reason authors 1182 if hasattr(md, 'column_name'): 1183 fm_src = self.db.metadata_for_field(md.column_name) 1184 if md.column_name in ['authors', 'publisher', 'series'] or \ 1185 (fm_src['is_custom'] and 1186 ((fm_src['datatype'] in ['series', 'text', 'enumeration'] and 1187 not fm_src['is_multiple']) or 1188 (fm_src['datatype'] == 'composite' and 1189 fm_src['display'].get('make_category', False)))): 1190 self.setDropIndicatorShown(True) 1191 1192 def clear(self): 1193 if self.model(): 1194 self.model().clear_state() 1195 1196 def is_visible(self, idx): 1197 item = idx.data(Qt.ItemDataRole.UserRole) 1198 if getattr(item, 'type', None) == TagTreeItem.TAG: 1199 idx = idx.parent() 1200 return self.isExpanded(idx) 1201 1202 def recount_with_position_based_index(self): 1203 self._model.use_position_based_index_on_next_recount = True 1204 self.recount() 1205 1206 def recount(self, *args): 1207 ''' 1208 Rebuild the category tree, expand any categories that were expanded, 1209 reset the search states, and reselect the current node. 1210 ''' 1211 if self.disable_recounting or not self.pane_is_visible: 1212 return 1213 self.refresh_signal_processed = True 1214 ci = self.currentIndex() 1215 if not ci.isValid(): 1216 ci = self.indexAt(QPoint(10, 10)) 1217 use_pos = self._model.use_position_based_index_on_next_recount 1218 self._model.use_position_based_index_on_next_recount = False 1219 if use_pos: 1220 path = self._model.path_for_index(ci) if self.is_visible(ci) else None 1221 else: 1222 path = self._model.named_path_for_index(ci) if self.is_visible(ci) else None 1223 expanded_categories, state_map = self.get_state() 1224 self._model.rebuild_node_tree(state_map=state_map) 1225 self.blockSignals(True) 1226 for category in expanded_categories: 1227 idx = self._model.index_for_category(category) 1228 if idx is not None and idx.isValid(): 1229 self.expand(idx) 1230 if path is not None: 1231 if use_pos: 1232 self.show_item_at_path(path) 1233 else: 1234 index = self._model.index_for_named_path(path) 1235 if index.isValid(): 1236 self.show_item_at_index(index) 1237 self.blockSignals(False) 1238 1239 def show_item_at_path(self, path, box=False, 1240 position=QAbstractItemView.ScrollHint.PositionAtCenter): 1241 ''' 1242 Scroll the browser and open categories to show the item referenced by 1243 path. If possible, the item is placed in the center. If box=True, a 1244 box is drawn around the item. 1245 ''' 1246 if path: 1247 self.show_item_at_index(self._model.index_for_path(path), box=box, 1248 position=position) 1249 1250 def expand_parent(self, idx): 1251 # Needed otherwise Qt sometimes segfaults if the node is buried in a 1252 # collapsed, off screen hierarchy. To be safe, we expand from the 1253 # outermost in 1254 p = self._model.parent(idx) 1255 if p.isValid(): 1256 self.expand_parent(p) 1257 self.expand(idx) 1258 1259 def show_item_at_index(self, idx, box=False, 1260 position=QAbstractItemView.ScrollHint.PositionAtCenter): 1261 if idx.isValid() and idx.data(Qt.ItemDataRole.UserRole) is not self._model.root_item: 1262 self.expand_parent(idx) 1263 self.setCurrentIndex(idx) 1264 self.scrollTo(idx, position) 1265 if box: 1266 self._model.set_boxed(idx) 1267 1268 def item_expanded(self, idx): 1269 ''' 1270 Called by the expanded signal 1271 ''' 1272 self.setCurrentIndex(idx) 1273 1274 # }}} 1275