def show_context_menu(self, point): item = self.folders.itemAt(point) if item is None: return m = QMenu(self) m.addAction(QIcon(I('mimetypes/dir.png')), _('Create new folder'), partial(self.create_folder, item)) m.popup(self.folders.mapToGlobal(point))
def show_context_menu(self, point): item = self.itemAt(point) if item is None or item in set(self.categories.itervalues()): return m = QMenu(self) sel = self.selectedItems() num = len(sel) if num > 0: m.addAction(QIcon(I('trash.png')), _('&Delete selected files'), self.request_delete) ci = self.currentItem() if ci is not None: cn = unicode(ci.data(0, NAME_ROLE).toString()) mt = unicode(ci.data(0, MIME_ROLE).toString()) cat = unicode(ci.data(0, CATEGORY_ROLE).toString()) m.addAction(QIcon(I('modified.png')), _('&Rename %s') % (elided_text(self.font(), cn)), self.edit_current_item) if is_raster_image(mt): m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover image') % elided_text(self.font(), cn), partial(self.mark_as_cover, cn)) elif current_container().SUPPORTS_TITLEPAGES and mt in OEB_DOCS and cat == 'text': m.addAction(QIcon(I('default_cover.png')), _('Mark %s as title/cover page') % elided_text(self.font(), cn), partial(self.mark_as_titlepage, cn)) selected_map = defaultdict(list) for item in sel: selected_map[unicode(item.data(0, CATEGORY_ROLE).toString())].append(unicode(item.data(0, NAME_ROLE).toString())) for items in selected_map.itervalues(): items.sort(key=self.index_of_name) if len(selected_map['text']) > 1: m.addAction(QIcon(I('merge.png')), _('&Merge selected text files'), partial(self.start_merge, 'text', selected_map['text'])) if len(selected_map['styles']) > 1: m.addAction(QIcon(I('merge.png')), _('&Merge selected style files'), partial(self.start_merge, 'styles', selected_map['styles'])) if len(list(m.actions())) > 0: m.popup(self.mapToGlobal(point))
def show_context_menu(self, point): item = self.itemAt(point) if item is None or item in set(self.categories.itervalues()): return m = QMenu(self) sel = self.selectedItems() num = len(sel) if num > 0: m.addAction(QIcon(I('trash.png')), _('&Delete selected files'), self.request_delete) ci = self.currentItem() if ci is not None: cn = unicode(ci.data(0, NAME_ROLE).toString()) mt = unicode(ci.data(0, MIME_ROLE).toString()) cat = unicode(ci.data(0, CATEGORY_ROLE).toString()) m.addAction(QIcon(I('modified.png')), _('&Rename %s') % (elided_text(self.font(), cn)), self.edit_current_item) if is_raster_image(mt): m.addAction( QIcon(I('default_cover.png')), _('Mark %s as cover image') % elided_text(self.font(), cn), partial(self.mark_as_cover, cn)) elif current_container( ).SUPPORTS_TITLEPAGES and mt in OEB_DOCS and cat == 'text': m.addAction( QIcon(I('default_cover.png')), _('Mark %s as title/cover page') % elided_text(self.font(), cn), partial(self.mark_as_titlepage, cn)) selected_map = defaultdict(list) for item in sel: selected_map[unicode(item.data( 0, CATEGORY_ROLE).toString())].append( unicode(item.data(0, NAME_ROLE).toString())) for items in selected_map.itervalues(): items.sort(key=self.index_of_name) if len(selected_map['text']) > 1: m.addAction( QIcon(I('merge.png')), _('&Merge selected text files'), partial(self.start_merge, 'text', selected_map['text'])) if len(selected_map['styles']) > 1: m.addAction( QIcon(I('merge.png')), _('&Merge selected style files'), partial(self.start_merge, 'styles', selected_map['styles'])) if len(list(m.actions())) > 0: m.popup(self.mapToGlobal(point))
def show_context_menu(self, point): item = self.itemAt(point) if item is None or item in set(self.categories.itervalues()): return m = QMenu(self) sel = self.selectedItems() num = len(sel) container = current_container() ci = self.currentItem() if ci is not None: cn = unicode(ci.data(0, NAME_ROLE).toString()) mt = unicode(ci.data(0, MIME_ROLE).toString()) cat = unicode(ci.data(0, CATEGORY_ROLE).toString()) n = elided_text(cn.rpartition('/')[-1]) m.addAction(QIcon(I('save.png')), _('Export %s') % n, partial(self.export, cn)) if cn not in container.names_that_must_not_be_changed and cn not in container.names_that_must_not_be_removed and mt not in OEB_FONTS: m.addAction(_('Replace %s with file...') % n, partial(self.replace, cn)) m.addSeparator() m.addAction(QIcon(I('modified.png')), _('&Rename %s') % n, self.edit_current_item) if is_raster_image(mt): m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover image') % n, partial(self.mark_as_cover, cn)) elif current_container().SUPPORTS_TITLEPAGES and mt in OEB_DOCS and cat == 'text': m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover page') % n, partial(self.mark_as_titlepage, cn)) m.addSeparator() if num > 0: m.addSeparator() if num > 1: m.addAction(QIcon(I('modified.png')), _('&Bulk rename selected files'), self.request_bulk_rename) m.addAction(QIcon(I('trash.png')), _('&Delete the %d selected file(s)') % num, self.request_delete) m.addSeparator() selected_map = defaultdict(list) for item in sel: selected_map[unicode(item.data(0, CATEGORY_ROLE).toString())].append(unicode(item.data(0, NAME_ROLE).toString())) for items in selected_map.itervalues(): items.sort(key=self.index_of_name) if selected_map['text']: m.addAction(QIcon(I('format-text-color.png')), _('Link &stylesheets...'), partial(self.link_stylesheets, selected_map['text'])) if len(selected_map['text']) > 1: m.addAction(QIcon(I('merge.png')), _('&Merge selected text files'), partial(self.start_merge, 'text', selected_map['text'])) if len(selected_map['styles']) > 1: m.addAction(QIcon(I('merge.png')), _('&Merge selected style files'), partial(self.start_merge, 'styles', selected_map['styles'])) if len(list(m.actions())) > 0: m.popup(self.mapToGlobal(point))
def show_context_menu(self, point): item = self.itemAt(point) if item is None or item in set(self.categories.itervalues()): return m = QMenu(self) sel = self.selectedItems() num = len(sel) if num > 0: m.addAction(QIcon(I("trash.png")), _("&Delete selected files"), self.request_delete) ci = self.currentItem() if ci is not None: cn = unicode(ci.data(0, NAME_ROLE).toString()) m.addAction( QIcon(I("modified.png")), _("&Rename %s") % (elided_text(self.font(), cn)), self.edit_current_item ) selected_map = defaultdict(list) for item in sel: selected_map[unicode(item.data(0, CATEGORY_ROLE).toString())].append( unicode(item.data(0, NAME_ROLE).toString()) ) for items in selected_map.itervalues(): items.sort(key=self.index_of_name) if len(selected_map["text"]) > 1: m.addAction( QIcon(I("merge.png")), _("&Merge selected text files"), partial(self.start_merge, "text", selected_map["text"]), ) if len(selected_map["styles"]) > 1: m.addAction( QIcon(I("merge.png")), _("&Merge selected style files"), partial(self.start_merge, "styles", selected_map["styles"]), ) if len(list(m.actions())) > 0: m.popup(self.mapToGlobal(point))
class BooksView(QTableView): # {{{ files_dropped = pyqtSignal(object) add_column_signal = pyqtSignal() def viewportEvent(self, event): if (event.type() == event.ToolTip and not gprefs['book_list_tooltips']): return False return QTableView.viewportEvent(self, event) def __init__(self, parent, modelcls=BooksModel, use_edit_metadata_dialog=True): QTableView.__init__(self, parent) self.gui = parent self.setProperty('highlight_current_item', 150) self.row_sizing_done = False self.alternate_views = AlternateViews(self) if not tweaks['horizontal_scrolling_per_column']: self.setHorizontalScrollMode(self.ScrollPerPixel) self.setEditTriggers(self.EditKeyPressed) if tweaks['doubleclick_on_library_view'] == 'edit_cell': self.setEditTriggers(self.DoubleClicked|self.editTriggers()) elif tweaks['doubleclick_on_library_view'] == 'open_viewer': self.setEditTriggers(self.SelectedClicked|self.editTriggers()) self.doubleClicked.connect(parent.iactions['View'].view_triggered) elif tweaks['doubleclick_on_library_view'] == 'edit_metadata': # Must not enable single-click to edit, or the field will remain # open in edit mode underneath the edit metadata dialog if use_edit_metadata_dialog: self.doubleClicked.connect( partial(parent.iactions['Edit Metadata'].edit_metadata, checked=False)) else: self.setEditTriggers(self.DoubleClicked|self.editTriggers()) setup_dnd_interface(self) self.setAlternatingRowColors(True) self.setSelectionBehavior(self.SelectRows) self.setShowGrid(False) self.setWordWrap(False) self.rating_delegate = RatingDelegate(self) self.timestamp_delegate = DateDelegate(self) self.pubdate_delegate = PubDateDelegate(self) self.last_modified_delegate = DateDelegate(self, tweak_name='gui_last_modified_display_format') self.languages_delegate = LanguagesDelegate(self) self.tags_delegate = CompleteDelegate(self, ',', 'all_tag_names') self.authors_delegate = CompleteDelegate(self, '&', 'all_author_names', True) self.cc_names_delegate = CompleteDelegate(self, '&', 'all_custom', True) self.series_delegate = TextDelegate(self) self.publisher_delegate = TextDelegate(self) self.text_delegate = TextDelegate(self) self.cc_text_delegate = CcTextDelegate(self) self.cc_enum_delegate = CcEnumDelegate(self) self.cc_bool_delegate = CcBoolDelegate(self) self.cc_comments_delegate = CcCommentsDelegate(self) self.cc_template_delegate = CcTemplateDelegate(self) self.cc_number_delegate = CcNumberDelegate(self) self.display_parent = parent self._model = modelcls(self) self.setModel(self._model) self._model.count_changed_signal.connect(self.do_row_sizing, type=Qt.QueuedConnection) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSortingEnabled(True) self.selectionModel().currentRowChanged.connect(self._model.current_changed) self.preserve_state = partial(PreserveViewState, self) # {{{ Column Header setup self.can_add_columns = True self.was_restored = False self.column_header = HeaderView(Qt.Horizontal, self) self.setHorizontalHeader(self.column_header) self.column_header.setMovable(True) self.column_header.setClickable(True) self.column_header.sectionMoved.connect(self.save_state) self.column_header.setContextMenuPolicy(Qt.CustomContextMenu) self.column_header.customContextMenuRequested.connect(self.show_column_header_context_menu) self.column_header.sectionResized.connect(self.column_resized, Qt.QueuedConnection) self.row_header = HeaderView(Qt.Vertical, self) self.setVerticalHeader(self.row_header) # }}} self._model.database_changed.connect(self.database_changed) hv = self.verticalHeader() hv.setClickable(True) hv.setCursor(Qt.PointingHandCursor) self.selected_ids = [] self._model.about_to_be_sorted.connect(self.about_to_be_sorted) self._model.sorting_done.connect(self.sorting_done, type=Qt.QueuedConnection) # Column Header Context Menu {{{ def column_header_context_handler(self, action=None, column=None): if not action or not column: return try: idx = self.column_map.index(column) except: return h = self.column_header if action == 'hide': h.setSectionHidden(idx, True) elif action == 'show': h.setSectionHidden(idx, False) if h.sectionSize(idx) < 3: sz = h.sectionSizeHint(idx) h.resizeSection(idx, sz) elif action == 'ascending': self.sortByColumn(idx, Qt.AscendingOrder) elif action == 'descending': self.sortByColumn(idx, Qt.DescendingOrder) elif action == 'defaults': self.apply_state(self.get_default_state()) elif action == 'addcustcol': self.add_column_signal.emit() elif action.startswith('align_'): alignment = action.partition('_')[-1] self._model.change_alignment(column, alignment) elif action == 'quickview': from calibre.customize.ui import find_plugin qv = find_plugin('Show Quickview') if qv: rows = self.selectionModel().selectedRows() if len(rows) > 0: current_row = rows[0].row() current_col = self.column_map.index(column) index = self.model().index(current_row, current_col) qv.actual_plugin_.change_quickview_column(index) self.save_state() def show_column_header_context_menu(self, pos): idx = self.column_header.logicalIndexAt(pos) if idx > -1 and idx < len(self.column_map): col = self.column_map[idx] name = unicode(self.model().headerData(idx, Qt.Horizontal, Qt.DisplayRole).toString()) self.column_header_context_menu = QMenu(self) if col != 'ondevice': self.column_header_context_menu.addAction(_('Hide column %s') % name, partial(self.column_header_context_handler, action='hide', column=col)) m = self.column_header_context_menu.addMenu( _('Sort on %s') % name) a = m.addAction(_('Ascending'), partial(self.column_header_context_handler, action='ascending', column=col)) d = m.addAction(_('Descending'), partial(self.column_header_context_handler, action='descending', column=col)) if self._model.sorted_on[0] == col: ac = a if self._model.sorted_on[1] else d ac.setCheckable(True) ac.setChecked(True) if col not in ('ondevice', 'inlibrary') and \ (not self.model().is_custom_column(col) or self.model().custom_columns[col]['datatype'] not in ('bool', )): m = self.column_header_context_menu.addMenu( _('Change text alignment for %s') % name) al = self._model.alignment_map.get(col, 'left') for x, t in (('left', _('Left')), ('right', _('Right')), ('center', _('Center'))): a = m.addAction(t, partial(self.column_header_context_handler, action='align_'+x, column=col)) if al == x: a.setCheckable(True) a.setChecked(True) if not isinstance(self, DeviceBooksView): if self._model.db.field_metadata[col]['is_category']: act = self.column_header_context_menu.addAction(_('Quickview column %s') % name, partial(self.column_header_context_handler, action='quickview', column=col)) rows = self.selectionModel().selectedRows() if len(rows) > 1: act.setEnabled(False) hidden_cols = [self.column_map[i] for i in range(self.column_header.count()) if self.column_header.isSectionHidden(i)] try: hidden_cols.remove('ondevice') except: pass if hidden_cols: self.column_header_context_menu.addSeparator() m = self.column_header_context_menu.addMenu(_('Show column')) for col in hidden_cols: hidx = self.column_map.index(col) name = unicode(self.model().headerData(hidx, Qt.Horizontal, Qt.DisplayRole).toString()) m.addAction(name, partial(self.column_header_context_handler, action='show', column=col)) self.column_header_context_menu.addSeparator() self.column_header_context_menu.addAction( _('Shrink column if it is too wide to fit'), partial(self.resize_column_to_fit, column=self.column_map[idx])) self.column_header_context_menu.addAction( _('Restore default layout'), partial(self.column_header_context_handler, action='defaults', column=col)) if self.can_add_columns: self.column_header_context_menu.addAction( QIcon(I('column.png')), _('Add your own columns'), partial(self.column_header_context_handler, action='addcustcol', column=col)) self.column_header_context_menu.popup(self.column_header.mapToGlobal(pos)) # }}} # Sorting {{{ def about_to_be_sorted(self, idc): selected_rows = [r.row() for r in self.selectionModel().selectedRows()] self.selected_ids = [idc(r) for r in selected_rows] def sorting_done(self, indexc): pos = self.horizontalScrollBar().value() self.select_rows(self.selected_ids, using_ids=True, change_current=True, scroll=True) self.selected_ids = [] self.horizontalScrollBar().setValue(pos) def sort_by_named_field(self, field, order, reset=True): if field in self.column_map: idx = self.column_map.index(field) if order: self.sortByColumn(idx, Qt.AscendingOrder) else: self.sortByColumn(idx, Qt.DescendingOrder) else: self._model.sort_by_named_field(field, order, reset) def multisort(self, fields, reset=True, only_if_different=False): if len(fields) == 0: return sh = self.cleanup_sort_history(self._model.sort_history, ignore_column_map=True) if only_if_different and len(sh) >= len(fields): ret=True for i,t in enumerate(fields): if t[0] != sh[i][0]: ret = False break if ret: return for n,d in reversed(fields): if n in self._model.db.field_metadata.keys(): sh.insert(0, (n, d)) sh = self.cleanup_sort_history(sh, ignore_column_map=True) self._model.sort_history = [tuple(x) for x in sh] self._model.resort(reset=reset) col = fields[0][0] dir = Qt.AscendingOrder if fields[0][1] else Qt.DescendingOrder if col in self.column_map: col = self.column_map.index(col) hdrs = self.horizontalHeader() try: hdrs.setSortIndicator(col, dir) except: pass # }}} # Ondevice column {{{ def set_ondevice_column_visibility(self): m = self._model self.column_header.setSectionHidden(m.column_map.index('ondevice'), not m.device_connected) def set_device_connected(self, is_connected): self._model.set_device_connected(is_connected) self.set_ondevice_column_visibility() # }}} # Save/Restore State {{{ def get_state(self): h = self.column_header cm = self.column_map state = {} state['hidden_columns'] = [cm[i] for i in range(h.count()) if h.isSectionHidden(i) and cm[i] != 'ondevice'] state['last_modified_injected'] = True state['languages_injected'] = True state['sort_history'] = \ self.cleanup_sort_history(self.model().sort_history) state['column_positions'] = {} state['column_sizes'] = {} state['column_alignment'] = self._model.alignment_map for i in range(h.count()): name = cm[i] state['column_positions'][name] = h.visualIndex(i) if name != 'ondevice': state['column_sizes'][name] = h.sectionSize(i) return state def write_state(self, state): db = getattr(self.model(), 'db', None) name = unicode(self.objectName()) if name and db is not None: db.prefs.set(name + ' books view state', state) def save_state(self): # Only save if we have been initialized (set_database called) if len(self.column_map) > 0 and self.was_restored: state = self.get_state() self.write_state(state) def cleanup_sort_history(self, sort_history, ignore_column_map=False): history = [] for col, order in sort_history: if not isinstance(order, bool): continue if col == 'date': col = 'timestamp' if ignore_column_map or col in self.column_map: if (not history or history[-1][0] != col): history.append([col, order]) return history def apply_sort_history(self, saved_history, max_sort_levels=3): if not saved_history: return for col, order in reversed(self.cleanup_sort_history( saved_history)[:max_sort_levels]): self.sortByColumn(self.column_map.index(col), Qt.AscendingOrder if order else Qt.DescendingOrder) def apply_state(self, state, max_sort_levels=3): h = self.column_header cmap = {} hidden = state.get('hidden_columns', []) for i, c in enumerate(self.column_map): cmap[c] = i if c != 'ondevice': h.setSectionHidden(i, c in hidden) positions = state.get('column_positions', {}) pmap = {} for col, pos in positions.items(): if col in cmap: pmap[pos] = col for pos in sorted(pmap.keys()): col = pmap[pos] idx = cmap[col] current_pos = h.visualIndex(idx) if current_pos != pos: h.moveSection(current_pos, pos) sizes = state.get('column_sizes', {}) for col, size in sizes.items(): if col in cmap: sz = sizes[col] if sz < 3: sz = h.sectionSizeHint(cmap[col]) h.resizeSection(cmap[col], sz) self.apply_sort_history(state.get('sort_history', None), max_sort_levels=max_sort_levels) for col, alignment in state.get('column_alignment', {}).items(): self._model.change_alignment(col, alignment) for i in range(h.count()): if not h.isSectionHidden(i) and h.sectionSize(i) < 3: sz = h.sectionSizeHint(i) h.resizeSection(i, sz) def get_default_state(self): old_state = { 'hidden_columns': ['last_modified', 'languages'], 'sort_history':[DEFAULT_SORT], 'column_positions': {}, 'column_sizes': {}, 'column_alignment': { 'size':'center', 'timestamp':'center', 'pubdate':'center'}, 'last_modified_injected': True, 'languages_injected': True, } h = self.column_header cm = self.column_map for i in range(h.count()): name = cm[i] old_state['column_positions'][name] = i if name != 'ondevice': old_state['column_sizes'][name] = \ min(350, max(self.sizeHintForColumn(i), h.sectionSizeHint(i))) if name in ('timestamp', 'last_modified'): old_state['column_sizes'][name] += 12 return old_state def get_old_state(self): ans = None name = unicode(self.objectName()) if name: name += ' books view state' db = getattr(self.model(), 'db', None) if db is not None: ans = db.prefs.get(name, None) if ans is None: ans = gprefs.get(name, None) try: del gprefs[name] except: pass if ans is not None: db.prefs[name] = ans else: injected = False if not ans.get('last_modified_injected', False): injected = True ans['last_modified_injected'] = True hc = ans.get('hidden_columns', []) if 'last_modified' not in hc: hc.append('last_modified') if not ans.get('languages_injected', False): injected = True ans['languages_injected'] = True hc = ans.get('hidden_columns', []) if 'languages' not in hc: hc.append('languages') if injected: db.prefs[name] = ans return ans def restore_state(self): old_state = self.get_old_state() if old_state is None: old_state = self.get_default_state() max_levels = 3 if tweaks['sort_columns_at_startup'] is not None: sh = [] try: for c,d in tweaks['sort_columns_at_startup']: if not isinstance(d, bool): d = True if d == 0 else False sh.append((c, d)) except: # Ignore invalid tweak values as users seem to often get them # wrong print('Ignoring invalid sort_columns_at_startup tweak, with error:') import traceback traceback.print_exc() old_state['sort_history'] = sh max_levels = max(3, len(sh)) self.column_header.blockSignals(True) self.apply_state(old_state, max_sort_levels=max_levels) self.column_header.blockSignals(False) self.do_row_sizing() self.was_restored = True def refresh_row_sizing(self): self.row_sizing_done = False self.do_row_sizing() def do_row_sizing(self): # Resize all rows to have the correct height if not self.row_sizing_done and self.model().rowCount(QModelIndex()) > 0: self.resizeRowToContents(0) self.verticalHeader().setDefaultSectionSize(self.rowHeight(0) + gprefs['extra_row_spacing']) self.row_sizing_done = True def resize_column_to_fit(self, column): col = self.column_map.index(column) self.column_resized(col, self.columnWidth(col), self.columnWidth(col)) def column_resized(self, col, old_size, new_size): # arbitrary: scroll bar + header + some max_width = self.width() - (self.verticalScrollBar().width() + self.verticalHeader().width() + 10) if max_width < 200: max_width = 200 if new_size > max_width: self.column_header.blockSignals(True) self.setColumnWidth(col, max_width) self.column_header.blockSignals(False) # }}} # Initialization/Delegate Setup {{{ def set_database(self, db): self.alternate_views.set_database(db) self.save_state() self._model.set_database(db) self.tags_delegate.set_database(db) self.cc_names_delegate.set_database(db) self.authors_delegate.set_database(db) self.series_delegate.set_auto_complete_function(db.all_series) self.publisher_delegate.set_auto_complete_function(db.all_publishers) self.alternate_views.set_database(db, stage=1) def database_changed(self, db): for i in range(self.model().columnCount(None)): if self.itemDelegateForColumn(i) in (self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate, self.last_modified_delegate, self.languages_delegate): self.setItemDelegateForColumn(i, self.itemDelegate()) cm = self.column_map for colhead in cm: if self._model.is_custom_column(colhead): cc = self._model.custom_columns[colhead] if cc['datatype'] == 'datetime': delegate = CcDateDelegate(self) delegate.set_format(cc['display'].get('date_format','')) self.setItemDelegateForColumn(cm.index(colhead), delegate) elif cc['datatype'] == 'comments': self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate) elif cc['datatype'] == 'text': if cc['is_multiple']: if cc['display'].get('is_names', False): self.setItemDelegateForColumn(cm.index(colhead), self.cc_names_delegate) else: self.setItemDelegateForColumn(cm.index(colhead), self.tags_delegate) else: self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] == 'series': self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] in ('int', 'float'): self.setItemDelegateForColumn(cm.index(colhead), self.cc_number_delegate) elif cc['datatype'] == 'bool': self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate) elif cc['datatype'] == 'rating': self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate) elif cc['datatype'] == 'composite': self.setItemDelegateForColumn(cm.index(colhead), self.cc_template_delegate) elif cc['datatype'] == 'enumeration': self.setItemDelegateForColumn(cm.index(colhead), self.cc_enum_delegate) else: dattr = colhead+'_delegate' delegate = colhead if hasattr(self, dattr) else 'text' self.setItemDelegateForColumn(cm.index(colhead), getattr(self, delegate+'_delegate')) self.restore_state() self.set_ondevice_column_visibility() #}}} # Context Menu {{{ def set_context_menu(self, menu, edit_collections_action): self.setContextMenuPolicy(Qt.DefaultContextMenu) self.context_menu = menu self.alternate_views.set_context_menu(menu) self.edit_collections_action = edit_collections_action def contextMenuEvent(self, event): sac = self.gui.iactions['Sort By'] sort_added = tuple(ac for ac in self.context_menu.actions() if ac is sac.qaction) if sort_added: sac.update_menu() self.context_menu.popup(event.globalPos()) event.accept() # }}} @property def column_map(self): return self._model.column_map @property def visible_columns(self): h = self.horizontalHeader() logical_indices = (x for x in xrange(h.count()) if not h.isSectionHidden(x)) rmap = {i:x for i, x in enumerate(self.column_map)} return (rmap[h.visualIndex(x)] for x in logical_indices if h.visualIndex(x) > -1) def refresh_book_details(self): idx = self.currentIndex() if idx.isValid(): self._model.current_changed(idx, idx) def scrollContentsBy(self, dx, dy): # Needed as Qt bug causes headerview to not always update when scrolling QTableView.scrollContentsBy(self, dx, dy) if dy != 0: self.column_header.update() def scroll_to_row(self, row): if row > -1: h = self.horizontalHeader() for i in range(h.count()): if not h.isSectionHidden(i) and h.sectionViewportPosition(i) >= 0: self.scrollTo(self.model().index(row, i), self.PositionAtCenter) break def set_current_row(self, row=0, select=True, for_sync=False): if row > -1 and row < self.model().rowCount(QModelIndex()): h = self.horizontalHeader() logical_indices = list(range(h.count())) logical_indices = [x for x in logical_indices if not h.isSectionHidden(x)] pairs = [(x, h.visualIndex(x)) for x in logical_indices if h.visualIndex(x) > -1] if not pairs: pairs = [(0, 0)] pairs.sort(cmp=lambda x,y:cmp(x[1], y[1])) i = pairs[0][0] index = self.model().index(row, i) if for_sync: sm = self.selectionModel() sm.setCurrentIndex(index, sm.NoUpdate) else: self.setCurrentIndex(index) if select: sm = self.selectionModel() sm.select(index, sm.ClearAndSelect|sm.Rows) def keyPressEvent(self, ev): val = self.horizontalScrollBar().value() ret = super(BooksView, self).keyPressEvent(ev) if ev.isAccepted() and ev.key() in (Qt.Key_Home, Qt.Key_End ) and ev.modifiers() & Qt.ControlModifier: self.horizontalScrollBar().setValue(val) return ret def ids_to_rows(self, ids): row_map = OrderedDict() ids = frozenset(ids) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if len(row_map) >= len(ids): break c = m.id(row) if c in ids: row_map[c] = row return row_map def select_rows(self, identifiers, using_ids=True, change_current=True, scroll=True): ''' Select rows identified by identifiers. identifiers can be a set of ids, row numbers or QModelIndexes. ''' rows = set([x.row() if hasattr(x, 'row') else x for x in identifiers]) if using_ids: rows = set([]) identifiers = set(identifiers) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if m.id(row) in identifiers: rows.add(row) rows = list(sorted(rows)) if rows: row = rows[0] if change_current: self.set_current_row(row, select=False) if scroll: self.scroll_to_row(row) sm = self.selectionModel() sel = QItemSelection() m = self.model() max_col = m.columnCount(QModelIndex()) - 1 # Create a range based selector for each set of contiguous rows # as supplying selectors for each individual row causes very poor # performance if a large number of rows has to be selected. for k, g in itertools.groupby(enumerate(rows), lambda (i,x):i-x): group = list(map(operator.itemgetter(1), g)) sel.merge(QItemSelection(m.index(min(group), 0), m.index(max(group), max_col)), sm.Select) sm.select(sel, sm.ClearAndSelect)
class TagsView(QTreeView): # {{{ refresh_required = pyqtSignal() tags_marked = pyqtSignal(object) edit_user_category = pyqtSignal(object) delete_user_category = pyqtSignal(object) del_item_from_user_cat = pyqtSignal(object, object, object) add_item_to_user_cat = pyqtSignal(object, object, object) add_subcategory = pyqtSignal(object) tags_list_edit = pyqtSignal(object, object) saved_search_edit = pyqtSignal(object) rebuild_saved_searches = pyqtSignal() author_sort_edit = pyqtSignal(object, object, object, object) tag_item_renamed = pyqtSignal() search_item_renamed = pyqtSignal() drag_drop_finished = pyqtSignal(object) restriction_error = pyqtSignal() tag_item_delete = pyqtSignal(object, object, object) def __init__(self, parent=None): QTreeView.__init__(self, parent=None) self.alter_tb = None self.disable_recounting = False self.setUniformRowHeights(True) self.setCursor(Qt.PointingHandCursor) self.setIconSize(QSize(20, 20)) self.setTabKeyNavigation(True) self.setAnimated(True) self.setHeaderHidden(True) self.setItemDelegate(TagDelegate(self)) self.made_connections = False self.setAcceptDrops(True) self.setDragEnabled(True) self.setDragDropMode(self.DragDrop) self.setDropIndicatorShown(True) self.in_drag_drop = False self.setAutoExpandDelay(500) self.pane_is_visible = False self.search_icon = QIcon(I('search.png')) self.user_category_icon = QIcon(I('tb_folder.png')) self.delete_icon = QIcon(I('list_remove.png')) self.rename_icon = QIcon(I('edit-undo.png')) self._model = TagsModel(self) self._model.search_item_renamed.connect(self.search_item_renamed) self._model.refresh_required.connect(self.refresh_required, type=Qt.QueuedConnection) self._model.tag_item_renamed.connect(self.tag_item_renamed) self._model.restriction_error.connect(self.restriction_error) self._model.user_categories_edited.connect(self.user_categories_edited, type=Qt.QueuedConnection) self._model.drag_drop_finished.connect(self.drag_drop_finished) stylish_tb = ''' QTreeView { background-color: palette(window); color: palette(window-text); border: none; } ''' self.setStyleSheet(''' QTreeView::item { border: 1px solid transparent; padding-top:0.9ex; padding-bottom:0.9ex; } 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; } ''' + ('' if gprefs['tag_browser_old_look'] else stylish_tb)) if gprefs['tag_browser_old_look']: self.setAlternatingRowColors(True) @property def hidden_categories(self): return self._model.hidden_categories @property def db(self): return self._model.db @property def collapse_model(self): return self._model.collapse_model def set_pane_is_visible(self, to_what): pv = self.pane_is_visible self.pane_is_visible = to_what if to_what and not pv: self.recount() def get_state(self): state_map = {} expanded_categories = [] for row, category in enumerate(self._model.category_nodes): if self.isExpanded(self._model.index(row, 0, QModelIndex())): expanded_categories.append(category.category_key) states = [c.tag.state for c in category.child_tags()] names = [(c.tag.name, c.tag.category) for c in category.child_tags()] state_map[category.category_key] = dict(izip(names, states)) return expanded_categories, state_map def reread_collapse_parameters(self): self._model.reread_collapse_model(self.get_state()[1]) def set_database(self, db, alter_tb): self._model.set_database(db) self.alter_tb = alter_tb self.pane_is_visible = True # because TagsModel.set_database did a recount self.setModel(self._model) self.setContextMenuPolicy(Qt.CustomContextMenu) pop = self.db.CATEGORY_SORTS.index(config['sort_tags_by']) self.alter_tb.sort_menu.actions()[pop].setChecked(True) try: match_pop = self.db.MATCH_TYPE.index(config['match_tags_type']) except ValueError: match_pop = 0 self.alter_tb.match_menu.actions()[match_pop].setChecked(True) if not self.made_connections: self.clicked.connect(self.toggle) self.customContextMenuRequested.connect(self.show_context_menu) self.refresh_required.connect(self.recount, type=Qt.QueuedConnection) self.alter_tb.sort_menu.triggered.connect(self.sort_changed) self.alter_tb.match_menu.triggered.connect(self.match_changed) self.made_connections = True self.refresh_signal_processed = True db.add_listener(self.database_changed) self.expanded.connect(self.item_expanded) def database_changed(self, event, ids): if self.refresh_signal_processed: self.refresh_signal_processed = False self.refresh_required.emit() def user_categories_edited(self, user_cats, nkey): state_map = self.get_state()[1] self.db.prefs.set('user_categories', user_cats) self._model.rebuild_node_tree(state_map=state_map) p = self._model.find_category_node('@'+nkey) self.show_item_at_path(p) @property def match_all(self): return (self.alter_tb and self.alter_tb.match_menu.actions()[1].isChecked()) def sort_changed(self, action): for i, ac in enumerate(self.alter_tb.sort_menu.actions()): if ac is action: config.set('sort_tags_by', self.db.CATEGORY_SORTS[i]) self.recount() break def match_changed(self, action): try: for i, ac in enumerate(self.alter_tb.match_menu.actions()): if ac is action: config.set('match_tags_type', self.db.MATCH_TYPE[i]) except: pass def mouseMoveEvent(self, event): dex = self.indexAt(event.pos()) if self.in_drag_drop or not dex.isValid(): QTreeView.mouseMoveEvent(self, event) return # Must deal with odd case where the node being dragged is 'virtual', # created to form a hierarchy. We can't really drag this node, but in # addition we can't allow drag recognition to notice going over some # other node and grabbing that one. So we set in_drag_drop to prevent # this from happening, turning it off when the user lifts the button. self.in_drag_drop = True if not self._model.flags(dex) & Qt.ItemIsDragEnabled: QTreeView.mouseMoveEvent(self, event) return md = self._model.mimeData([dex]) pixmap = dex.data(Qt.DecorationRole).toPyObject().pixmap(25, 25) drag = QDrag(self) drag.setPixmap(pixmap) drag.setMimeData(md) if self._model.is_in_user_category(dex): drag.exec_(Qt.CopyAction|Qt.MoveAction, Qt.CopyAction) else: drag.exec_(Qt.CopyAction) def mouseReleaseEvent(self, event): # Swallow everything except leftButton so context menus work correctly if event.button() == Qt.LeftButton or self.in_drag_drop: QTreeView.mouseReleaseEvent(self, event) self.in_drag_drop = False def mouseDoubleClickEvent(self, event): # swallow these to avoid toggling and editing at the same time pass @property def search_string(self): tokens = self._model.tokens() joiner = ' and ' if self.match_all else ' or ' return joiner.join(tokens) def toggle(self, index): self._toggle(index, None) def _toggle(self, index, set_to): ''' set_to: if None, advance the state. Otherwise must be one of the values in TAG_SEARCH_STATES ''' modifiers = int(QApplication.keyboardModifiers()) exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) if self._model.toggle(index, exclusive, set_to=set_to): self.tags_marked.emit(self.search_string) def conditional_clear(self, search_string): if search_string != self.search_string: self.clear() def context_menu_handler(self, action=None, category=None, key=None, index=None, search_state=None): if not action: return try: if action == 'set_icon': try: path = choose_files(self, 'choose_category_icon', _('Change Icon for: %s')%key, filters=[ ('Images', ['png', 'gif', 'jpg', 'jpeg'])], all_files=False, select_only_single_file=True) if path: path = path[0] p = QIcon(path).pixmap(QSize(128, 128)) d = os.path.join(config_dir, 'tb_icons') if not os.path.exists(d): os.makedirs(d) with open(os.path.join(d, 'icon_'+ sanitize_file_name_unicode(key)+'.png'), 'wb') as f: f.write(pixmap_to_data(p, format='PNG')) path = os.path.basename(f.name) self._model.set_custom_category_icon(key, unicode(path)) self.recount() except: import traceback traceback.print_exc() return if action == 'clear_icon': self._model.set_custom_category_icon(key, None) self.recount() return if action == 'edit_item': self.edit(index) return if action == 'delete_item': self.tag_item_delete.emit(key, index.id, index.original_name) return if action == 'open_editor': self.tags_list_edit.emit(category, key) return if action == 'manage_categories': self.edit_user_category.emit(category) return if action == 'search': self._toggle(index, set_to=search_state) return if action == 'add_to_category': tag = index.tag if len(index.children) > 0: for c in index.all_children(): self.add_item_to_user_cat.emit(category, c.tag.original_name, c.tag.category) self.add_item_to_user_cat.emit(category, tag.original_name, tag.category) return if action == 'add_subcategory': self.add_subcategory.emit(key) return if action == 'search_category': self._toggle(index, set_to=search_state) return if action == 'delete_user_category': self.delete_user_category.emit(key) return if action == 'delete_search': saved_searches().delete(key) self.rebuild_saved_searches.emit() return if action == 'delete_item_from_user_category': tag = index.tag if len(index.children) > 0: for c in index.children: self.del_item_from_user_cat.emit(key, c.tag.original_name, c.tag.category) self.del_item_from_user_cat.emit(key, tag.original_name, tag.category) return if action == 'manage_searches': self.saved_search_edit.emit(category) return if action == 'edit_author_sort': self.author_sort_edit.emit(self, index, True, False) return if action == 'edit_author_link': self.author_sort_edit.emit(self, index, False, True) return reset_filter_categories = True if action == 'hide': self.hidden_categories.add(category) elif action == 'show': self.hidden_categories.discard(category) elif action == 'categorization': changed = self.collapse_model != category self._model.collapse_model = category if changed: reset_filter_categories = False gprefs['tags_browser_partition_method'] = category elif action == 'defaults': self.hidden_categories.clear() self.db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) if reset_filter_categories: self._model.set_categories_filter(None) self._model.rebuild_node_tree() except: return def show_context_menu(self, point): def display_name( tag): if tag.category == 'search': n = tag.name if len(n) > 45: n = n[:45] + '...' return "'" + n + "'" return tag.name index = self.indexAt(point) self.context_menu = QMenu(self) if index.isValid(): item = index.data(Qt.UserRole).toPyObject() tag = None if item.type == TagTreeItem.TAG: tag_item = item tag = item.tag while item.type != TagTreeItem.CATEGORY: item = item.parent if item.type == TagTreeItem.CATEGORY: if not item.category_key.startswith('@'): while item.parent != self._model.root_item: item = item.parent category = unicode(item.name.toString()) key = item.category_key # Verify that we are working with a field that we know something about if key not in self.db.field_metadata: return True # Did the user click on a leaf node? if tag: # If the user right-clicked on an editable item, then offer # the possibility of renaming that item. if tag.is_editable: # Add the 'rename' items self.context_menu.addAction(self.rename_icon, _('Rename %s')%display_name(tag), partial(self.context_menu_handler, action='edit_item', index=index)) if key in ('tags', 'series', 'publisher') or \ self._model.db.field_metadata.is_custom_field(key): self.context_menu.addAction(self.delete_icon, _('Delete %s')%display_name(tag), partial(self.context_menu_handler, action='delete_item', key=key, index=tag)) if key == 'authors': self.context_menu.addAction(_('Edit sort for %s')%display_name(tag), partial(self.context_menu_handler, action='edit_author_sort', index=tag.id)) self.context_menu.addAction(_('Edit link for %s')%display_name(tag), partial(self.context_menu_handler, action='edit_author_link', index=tag.id)) # is_editable is also overloaded to mean 'can be added # to a user category' m = self.context_menu.addMenu(self.user_category_icon, _('Add %s to user category')%display_name(tag)) nt = self.model().category_node_tree def add_node_tree(tree_dict, m, path): p = path[:] for k in sorted(tree_dict.keys(), key=sort_key): p.append(k) n = k[1:] if k.startswith('@') else k m.addAction(self.user_category_icon, n, partial(self.context_menu_handler, 'add_to_category', category='.'.join(p), index=tag_item)) if len(tree_dict[k]): tm = m.addMenu(self.user_category_icon, _('Children of %s')%n) add_node_tree(tree_dict[k], tm, p) p.pop() add_node_tree(nt, m, []) elif key == 'search' and tag.is_searchable: self.context_menu.addAction(self.rename_icon, _('Rename %s')%display_name(tag), partial(self.context_menu_handler, action='edit_item', index=index)) self.context_menu.addAction(self.delete_icon, _('Delete search %s')%display_name(tag), partial(self.context_menu_handler, action='delete_search', key=tag.original_name)) if key.startswith('@') and not item.is_gst: self.context_menu.addAction(self.user_category_icon, _('Remove %(item)s from category %(cat)s')% dict(item=display_name(tag), cat=item.py_name), partial(self.context_menu_handler, action='delete_item_from_user_category', key = key, index = tag_item)) if tag.is_searchable: # Add the search for value items. All leaf nodes are searchable self.context_menu.addAction(self.search_icon, _('Search for %s')%display_name(tag), partial(self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_plus'], index=index)) self.context_menu.addAction(self.search_icon, _('Search for everything but %s')%display_name(tag), partial(self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() elif key.startswith('@') and not item.is_gst: if item.can_be_edited: self.context_menu.addAction(self.rename_icon, _('Rename %s')%item.py_name, partial(self.context_menu_handler, action='edit_item', index=index)) self.context_menu.addAction(self.user_category_icon, _('Add sub-category to %s')%item.py_name, partial(self.context_menu_handler, action='add_subcategory', key=key)) self.context_menu.addAction(self.delete_icon, _('Delete user category %s')%item.py_name, partial(self.context_menu_handler, action='delete_user_category', key=key)) self.context_menu.addSeparator() # Hide/Show/Restore categories self.context_menu.addAction(_('Hide category %s') % category, partial(self.context_menu_handler, action='hide', category=key)) if self.hidden_categories: m = self.context_menu.addMenu(_('Show category')) for col in sorted(self.hidden_categories, key=lambda x: sort_key(self.db.field_metadata[x]['name'])): m.addAction(self.db.field_metadata[col]['name'], partial(self.context_menu_handler, action='show', category=col)) # search by category. Some categories are not searchable, such # as search and news if item.tag.is_searchable: self.context_menu.addAction(self.search_icon, _('Search for books in category %s')%category, partial(self.context_menu_handler, action='search_category', index=self._model.createIndex(item.row(), 0, item), search_state=TAG_SEARCH_STATES['mark_plus'])) self.context_menu.addAction(self.search_icon, _('Search for books not in category %s')%category, partial(self.context_menu_handler, action='search_category', index=self._model.createIndex(item.row(), 0, item), search_state=TAG_SEARCH_STATES['mark_minus'])) # Offer specific editors for tags/series/publishers/saved searches self.context_menu.addSeparator() if key in ['tags', 'publisher', 'series'] or \ (self.db.field_metadata[key]['is_custom'] and self.db.field_metadata[key]['datatype'] != 'composite'): self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', category=tag.original_name if tag else None, key=key)) elif key == 'authors': self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='edit_author_sort')) elif key == 'search': self.context_menu.addAction(_('Manage Saved Searches'), partial(self.context_menu_handler, action='manage_searches', category=tag.name if tag else None)) self.context_menu.addSeparator() self.context_menu.addAction(_('Change category icon'), partial(self.context_menu_handler, action='set_icon', key=key)) self.context_menu.addAction(_('Restore default icon'), partial(self.context_menu_handler, action='clear_icon', key=key)) # Always show the user categories editor self.context_menu.addSeparator() if key.startswith('@') and \ key[1:] in self.db.prefs.get('user_categories', {}).keys(): self.context_menu.addAction(_('Manage User Categories'), partial(self.context_menu_handler, action='manage_categories', category=key[1:])) else: self.context_menu.addAction(_('Manage User Categories'), partial(self.context_menu_handler, action='manage_categories', category=None)) if self.hidden_categories: if not self.context_menu.isEmpty(): self.context_menu.addSeparator() self.context_menu.addAction(_('Show all categories'), partial(self.context_menu_handler, action='defaults')) m = self.context_menu.addMenu(_('Change sub-categorization scheme')) da = m.addAction(_('Disable'), partial(self.context_menu_handler, action='categorization', category='disable')) fla = m.addAction(_('By first letter'), partial(self.context_menu_handler, action='categorization', category='first letter')) pa = m.addAction(_('Partition'), partial(self.context_menu_handler, action='categorization', category='partition')) if self.collapse_model == 'disable': da.setCheckable(True) da.setChecked(True) elif self.collapse_model == 'first letter': fla.setCheckable(True) fla.setChecked(True) else: pa.setCheckable(True) pa.setChecked(True) if config['sort_tags_by'] != "name": fla.setEnabled(False) m.hovered.connect(self.collapse_menu_hovered) fla.setToolTip(_('First letter is usable only when sorting by name')) # Apparently one cannot set a tooltip to empty, so use a star and # deal with it in the hover method da.setToolTip('*') pa.setToolTip('*') if not self.context_menu.isEmpty(): self.context_menu.popup(self.mapToGlobal(point)) return True def collapse_menu_hovered(self, action): tip = action.toolTip() if tip == '*': tip = '' QToolTip.showText(QCursor.pos(), tip) def dragMoveEvent(self, event): QTreeView.dragMoveEvent(self, event) self.setDropIndicatorShown(False) index = self.indexAt(event.pos()) if not index.isValid(): return src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser') item = index.data(Qt.UserRole).toPyObject() if item.type == TagTreeItem.ROOT: return flags = self._model.flags(index) if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: self.setDropIndicatorShown(not src_is_tb) return if item.type == TagTreeItem.CATEGORY and not item.is_gst: fm_dest = self.db.metadata_for_field(item.category_key) if fm_dest['kind'] == 'user': if src_is_tb: if event.dropAction() == Qt.MoveAction: data = str(event.mimeData().data('application/calibre+from_tag_browser')) src = cPickle.loads(data) for s in src: if s[0] == TagTreeItem.TAG and \ (not s[1].startswith('@') or s[2]): return self.setDropIndicatorShown(True) return md = event.mimeData() if hasattr(md, 'column_name'): fm_src = self.db.metadata_for_field(md.column_name) if md.column_name in ['authors', 'publisher', 'series'] or \ (fm_src['is_custom'] and ( (fm_src['datatype'] in ['series', 'text', 'enumeration'] and not fm_src['is_multiple']) or (fm_src['datatype'] == 'composite' and fm_src['display'].get('make_category', False)))): self.setDropIndicatorShown(True) def clear(self): if self.model(): self.model().clear_state() def is_visible(self, idx): item = idx.data(Qt.UserRole).toPyObject() if getattr(item, 'type', None) == TagTreeItem.TAG: idx = idx.parent() return self.isExpanded(idx) def recount(self, *args): ''' Rebuild the category tree, expand any categories that were expanded, reset the search states, and reselect the current node. ''' if self.disable_recounting or not self.pane_is_visible: return self.refresh_signal_processed = True ci = self.currentIndex() if not ci.isValid(): ci = self.indexAt(QPoint(10, 10)) path = self.model().path_for_index(ci) if self.is_visible(ci) else None expanded_categories, state_map = self.get_state() self._model.rebuild_node_tree(state_map=state_map) self.blockSignals(True) for category in expanded_categories: idx = self._model.index_for_category(category) if idx is not None and idx.isValid(): self.expand(idx) self.show_item_at_path(path) self.blockSignals(False) def show_item_at_path(self, path, box=False, position=QTreeView.PositionAtCenter): ''' Scroll the browser and open categories to show the item referenced by path. If possible, the item is placed in the center. If box=True, a box is drawn around the item. ''' if path: self.show_item_at_index(self._model.index_for_path(path), box=box, position=position) def expand_parent(self, idx): # Needed otherwise Qt sometimes segfaults if the node is buried in a # collapsed, off screen hierarchy. To be safe, we expand from the # outermost in p = self._model.parent(idx) if p.isValid(): self.expand_parent(p) self.expand(idx) def show_item_at_index(self, idx, box=False, position=QTreeView.PositionAtCenter): if idx.isValid() and idx.data(Qt.UserRole).toPyObject() is not self._model.root_item: self.expand_parent(idx) self.setCurrentIndex(idx) self.scrollTo(idx, position) if box: self._model.set_boxed(idx) def item_expanded(self, idx): ''' Called by the expanded signal ''' self.setCurrentIndex(idx)
class SearchRestrictionMixin(object): no_restriction = _('<None>') def __init__(self): self.checked = QIcon(I('ok.png')) self.empty = QIcon(I('blank.png')) self.search_based_vl_name = None self.search_based_vl = None self.virtual_library_menu = QMenu() self.virtual_library.clicked.connect(self.virtual_library_clicked) self.virtual_library_tooltip = \ _('Use a "virtual library" to show only a subset of the books present in this library') self.virtual_library.setToolTip(self.virtual_library_tooltip) self.search_restriction = ComboBoxWithHelp(self) self.search_restriction.setVisible(False) self.search_count.setText(_("(all books)")) self.ar_menu = QMenu(_('Additional restriction')) self.edit_menu = QMenu(_('Edit Virtual Library')) self.rm_menu = QMenu(_('Remove Virtual Library')) def add_virtual_library(self, db, name, search): virt_libs = db.prefs.get('virtual_libraries', {}) virt_libs[name] = search db.prefs.set('virtual_libraries', virt_libs) def do_create_edit(self, name=None): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) cd = CreateVirtualLibrary(self, virt_libs.keys(), editing=name) if cd.exec_() == cd.Accepted: if name: self._remove_vl(name, reapply=False) self.add_virtual_library(db, cd.library_name, cd.library_search) if not name or name == db.data.get_base_restriction_name(): self.apply_virtual_library(cd.library_name) def virtual_library_clicked(self): m = self.virtual_library_menu m.clear() a = m.addAction(_('Create Virtual Library')) a.triggered.connect(partial(self.do_create_edit, name=None)) a = self.edit_menu self.build_virtual_library_list(a, self.do_create_edit) m.addMenu(a) a = self.rm_menu self.build_virtual_library_list(a, self.remove_vl_triggered) m.addMenu(a) m.addSeparator() db = self.library_view.model().db a = self.ar_menu a.clear() a.setIcon(self.checked if db.data.get_search_restriction_name() else self.empty) self.build_search_restriction_list() m.addMenu(a) m.addSeparator() current_lib = db.data.get_base_restriction_name() if current_lib == '': a = m.addAction(self.checked, self.no_restriction) else: a = m.addAction(self.empty, self.no_restriction) a.triggered.connect(partial(self.apply_virtual_library, library='')) a = m.addAction(self.empty, _('*current search')) a.triggered.connect(partial(self.apply_virtual_library, library='*')) if self.search_based_vl_name: a = m.addAction( self.checked if db.data.get_base_restriction_name().startswith('*') else self.empty, self.search_based_vl_name) a.triggered.connect(partial(self.apply_virtual_library, library=self.search_based_vl_name)) m.addSeparator() virt_libs = db.prefs.get('virtual_libraries', {}) for vl in sorted(virt_libs.keys(), key=sort_key): a = m.addAction(self.checked if vl == current_lib else self.empty, vl) a.triggered.connect(partial(self.apply_virtual_library, library=vl)) p = QPoint(0, self.virtual_library.height()) self.virtual_library_menu.popup(self.virtual_library.mapToGlobal(p)) def apply_virtual_library(self, library=None): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) if not library: db.data.set_base_restriction('') db.data.set_base_restriction_name('') elif library == '*': if not self.search.current_text: error_dialog(self, _('No search'), _('There is no current search to use'), show=True) return txt = _build_full_search_string(self) try: db.data.search_getting_ids('', txt, use_virtual_library=False) except ParseException as e: error_dialog(self, _('Invalid search'), _('The search in the search box is not valid'), det_msg=e.msg, show=True) return self.search_based_vl = txt db.data.set_base_restriction(txt) self.search_based_vl_name = self._trim_restriction_name('*' + txt) db.data.set_base_restriction_name(self.search_based_vl_name) elif library == self.search_based_vl_name: db.data.set_base_restriction(self.search_based_vl) db.data.set_base_restriction_name(self.search_based_vl_name) elif library in virt_libs: db.data.set_base_restriction(virt_libs[library]) db.data.set_base_restriction_name(library) self.virtual_library.setToolTip(self.virtual_library_tooltip + '\n' + db.data.get_base_restriction()) self._apply_search_restriction(db.data.get_search_restriction(), db.data.get_search_restriction_name()) def build_virtual_library_list(self, menu, handler): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) menu.clear() menu.setIcon(self.empty) def add_action(name, search): a = menu.addAction(name) a.triggered.connect(partial(handler, name=name)) a.setIcon(self.empty) libs = sorted(virt_libs.keys(), key=sort_key) if libs: menu.setEnabled(True) for n in libs: add_action(n, virt_libs[n]) else: menu.setEnabled(False) def remove_vl_triggered(self, name=None): if not question_dialog(self, _('Are you sure?'), _('Are you sure you want to remove ' 'the virtual library {0}').format(name), default_yes=False): return self._remove_vl(name, reapply=True) def _remove_vl(self, name, reapply=True): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) virt_libs.pop(name, None) db.prefs.set('virtual_libraries', virt_libs) if reapply and db.data.get_base_restriction_name() == name: self.apply_virtual_library('') def _trim_restriction_name(self, name): return name[0:MAX_VIRTUAL_LIBRARY_NAME_LENGTH].strip() def build_search_restriction_list(self): from calibre.gui2.ui import get_gui m = self.ar_menu m.clear() current_restriction_text = None if self.search_restriction.count() > 1: txt = unicode(self.search_restriction.itemText(2)) if txt.startswith('*'): current_restriction_text = txt self.search_restriction.clear() current_restriction = self.library_view.model().db.data.get_search_restriction_name() m.setIcon(self.checked if current_restriction else self.empty) def add_action(txt, index): self.search_restriction.addItem(txt) txt = self._trim_restriction_name(txt) if txt == current_restriction: a = m.addAction(self.checked, txt if txt else self.no_restriction) else: a = m.addAction(self.empty, txt if txt else self.no_restriction) a.triggered.connect(partial(self.search_restriction_triggered, action=a, index=index)) add_action('', 0) add_action(_('*current search'), 1) dex = 2 if current_restriction_text: add_action(current_restriction_text, 2) dex += 1 for n in sorted(get_gui().current_db.saved_search_names(), key=sort_key): add_action(n, dex) dex += 1 def search_restriction_triggered(self, action=None, index=None): self.search_restriction.setCurrentIndex(index) self.apply_search_restriction(index) def apply_named_search_restriction(self, name): if not name: r = 0 else: r = self.search_restriction.findText(name) if r < 0: r = 0 self.search_restriction.setCurrentIndex(r) self.apply_search_restriction(r) def apply_text_search_restriction(self, search): search = unicode(search) if not search: self.search_restriction.setCurrentIndex(0) self._apply_search_restriction('', '') else: s = '*' + search if self.search_restriction.count() > 1: txt = unicode(self.search_restriction.itemText(2)) if txt.startswith('*'): self.search_restriction.setItemText(2, s) else: self.search_restriction.insertItem(2, s) else: self.search_restriction.insertItem(2, s) self.search_restriction.setCurrentIndex(2) self._apply_search_restriction(search, self._trim_restriction_name(s)) def apply_search_restriction(self, i): if i == 1: self.apply_text_search_restriction(unicode(self.search.currentText())) elif i == 2 and unicode(self.search_restriction.currentText()).startswith('*'): self.apply_text_search_restriction( unicode(self.search_restriction.currentText())[1:]) else: r = unicode(self.search_restriction.currentText()) if r is not None and r != '': restriction = 'search:"%s"'%(r) else: restriction = '' self._apply_search_restriction(restriction, r) def clear_additional_restriction(self): self._apply_search_restriction('', '') def _apply_search_restriction(self, restriction, name): self.saved_search.clear() # The order below is important. Set the restriction, force a '' search # to apply it, reset the tag browser to take it into account, then set # the book count. self.library_view.model().db.data.set_search_restriction(restriction) self.library_view.model().db.data.set_search_restriction_name(name) self.search.clear(emit_search=True) self.tags_view.recount() self.set_number_of_books_shown() self.current_view().setFocus(Qt.OtherFocusReason) self.set_window_title() v = self.current_view() if not v.currentIndex().isValid(): v.set_current_row() v.refresh_book_details() def set_number_of_books_shown(self): db = self.library_view.model().db if self.current_view() == self.library_view and db is not None and \ db.data.search_restriction_applied(): restrictions = [x for x in (db.data.get_base_restriction_name(), db.data.get_search_restriction_name()) if x] t = ' :: '.join(restrictions) if len(t) > 20: t = t[:19] + u'…' self.search_count.setStyleSheet( 'QLabel { border-radius: 6px; background-color: %s }' % tweaks['highlight_virtual_library']) else: # No restriction or not library view t = '' self.search_count.setStyleSheet( 'QLabel { background-color: transparent; }') self.search_count.setText(t)
class ConfigWidget(ConfigWidgetBase, Ui_Form): def genesis(self, gui): self.gui = gui self.delegate = Delegate(self.tweaks_view) self.tweaks_view.setItemDelegate(self.delegate) self.tweaks_view.currentChanged = self.current_changed self.view = self.tweaks_view self.highlighter = PythonHighlighter(self.edit_tweak.document()) self.restore_default_button.clicked.connect(self.restore_to_default) self.apply_button.clicked.connect(self.apply_tweak) self.plugin_tweaks_button.clicked.connect(self.plugin_tweaks) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 100) self.next_button.clicked.connect(self.find_next) self.previous_button.clicked.connect(self.find_previous) self.search.initialize('tweaks_search_history', help_text= _('Search for tweak')) self.search.search.connect(self.find) self.view.setContextMenuPolicy(Qt.CustomContextMenu) self.view.customContextMenuRequested.connect(self.show_context_menu) self.copy_icon = QIcon(I('edit-copy.png')) def show_context_menu(self, point): idx = self.tweaks_view.currentIndex() if not idx.isValid(): return True tweak = self.tweaks.data(idx, Qt.UserRole) self.context_menu = QMenu(self) self.context_menu.addAction(self.copy_icon, _('Copy to clipboard'), partial(self.copy_item_to_clipboard, val=u"%s (%s: %s)"%(tweak.name, _('ID'), tweak.var_names[0]))) self.context_menu.popup(self.mapToGlobal(point)) return True def copy_item_to_clipboard(self, val): cb = QApplication.clipboard() cb.clear() cb.setText(val) def plugin_tweaks(self): raw = self.tweaks.plugin_tweaks_string d = PluginTweaks(raw, self) if d.exec_() == d.Accepted: g, l = {}, {} try: exec unicode(d.edit.toPlainText()) in g, l except: import traceback return error_dialog(self, _('Failed'), _('There was a syntax error in your tweak. Click ' 'the show details button for details.'), show=True, det_msg=traceback.format_exc()) self.tweaks.set_plugin_tweaks(l) self.changed() def current_changed(self, current, previous): self.tweaks_view.scrollTo(current) tweak = self.tweaks.data(current, Qt.UserRole) self.help.setPlainText(tweak.doc) self.edit_tweak.setPlainText(tweak.edit_text) def changed(self, *args): self.changed_signal.emit() def initialize(self): self.tweaks = self._model = Tweaks() self.tweaks_view.setModel(self.tweaks) def restore_to_default(self, *args): idx = self.tweaks_view.currentIndex() if idx.isValid(): self.tweaks.restore_to_default(idx) tweak = self.tweaks.data(idx, Qt.UserRole) self.edit_tweak.setPlainText(tweak.edit_text) self.changed() def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) self.tweaks.restore_to_defaults() self.changed() def apply_tweak(self): idx = self.tweaks_view.currentIndex() if idx.isValid(): l, g = {}, {} try: exec unicode(self.edit_tweak.toPlainText()) in g, l except: import traceback error_dialog(self.gui, _('Failed'), _('There was a syntax error in your tweak. Click ' 'the show details button for details.'), det_msg=traceback.format_exc(), show=True) return self.tweaks.update_tweak(idx, l) self.changed() def commit(self): raw = self.tweaks.to_string() try: exec raw except: import traceback error_dialog(self, _('Invalid tweaks'), _('The tweaks you entered are invalid, try resetting the' ' tweaks to default and changing them one by one until' ' you find the invalid setting.'), det_msg=traceback.format_exc(), show=True) raise AbortCommit('abort') write_tweaks(raw) ConfigWidgetBase.commit(self) return True def find(self, query): if not query: return try: idx = self._model.find(query) except ParseException: self.search.search_done(False) return self.search.search_done(True) if not idx.isValid(): info_dialog(self, _('No matches'), _('Could not find any shortcuts matching %s')%query, show=True, show_copy_button=False) return self.highlight_index(idx) def highlight_index(self, idx): if not idx.isValid(): return self.view.scrollTo(idx) self.view.selectionModel().select(idx, self.view.selectionModel().ClearAndSelect) self.view.setCurrentIndex(idx) def find_next(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0) idx = self._model.find_next(idx, unicode(self.search.currentText())) self.highlight_index(idx) def find_previous(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0) idx = self._model.find_next(idx, unicode(self.search.currentText()), backwards=True) self.highlight_index(idx)
class SearchRestrictionMixin(object): no_restriction = _('<None>') def __init__(self): self.checked = QIcon(I('ok.png')) self.empty = QIcon(I('blank.png')) self.search_based_vl_name = None self.search_based_vl = None self.virtual_library_menu = QMenu() self.virtual_library.clicked.connect(self.virtual_library_clicked) self.virtual_library_tooltip = \ _('Use a "virtual library" to show only a subset of the books present in this library') self.virtual_library.setToolTip(self.virtual_library_tooltip) self.search_restriction = ComboBoxWithHelp(self) self.search_restriction.setVisible(False) self.search_count.setText(_("(all books)")) self.ar_menu = QMenu(_('Additional restriction')) self.edit_menu = QMenu(_('Edit Virtual Library')) self.rm_menu = QMenu(_('Remove Virtual Library')) def add_virtual_library(self, db, name, search): virt_libs = db.prefs.get('virtual_libraries', {}) virt_libs[name] = search db.prefs.set('virtual_libraries', virt_libs) def do_create_edit(self, name=None): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) cd = CreateVirtualLibrary(self, virt_libs.keys(), editing=name) if cd.exec_() == cd.Accepted: if name: self._remove_vl(name, reapply=False) self.add_virtual_library(db, cd.library_name, cd.library_search) if not name or name == db.data.get_base_restriction_name(): self.apply_virtual_library(cd.library_name) def virtual_library_clicked(self): m = self.virtual_library_menu m.clear() a = m.addAction(_('Create Virtual Library')) a.triggered.connect(partial(self.do_create_edit, name=None)) a = self.edit_menu self.build_virtual_library_list(a, self.do_create_edit) m.addMenu(a) a = self.rm_menu self.build_virtual_library_list(a, self.remove_vl_triggered) m.addMenu(a) m.addSeparator() db = self.library_view.model().db a = self.ar_menu a.clear() a.setIcon(self.checked if db.data.get_search_restriction_name( ) else self.empty) self.build_search_restriction_list() m.addMenu(a) m.addSeparator() current_lib = db.data.get_base_restriction_name() if current_lib == '': a = m.addAction(self.checked, self.no_restriction) else: a = m.addAction(self.empty, self.no_restriction) a.triggered.connect(partial(self.apply_virtual_library, library='')) a = m.addAction(self.empty, _('*current search')) a.triggered.connect(partial(self.apply_virtual_library, library='*')) if self.search_based_vl_name: a = m.addAction( self.checked if db.data.get_base_restriction_name().startswith('*') else self.empty, self.search_based_vl_name) a.triggered.connect( partial(self.apply_virtual_library, library=self.search_based_vl_name)) m.addSeparator() virt_libs = db.prefs.get('virtual_libraries', {}) for vl in sorted(virt_libs.keys(), key=sort_key): a = m.addAction(self.checked if vl == current_lib else self.empty, vl) a.triggered.connect(partial(self.apply_virtual_library, library=vl)) p = QPoint(0, self.virtual_library.height()) self.virtual_library_menu.popup(self.virtual_library.mapToGlobal(p)) def apply_virtual_library(self, library=None): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) if not library: db.data.set_base_restriction('') db.data.set_base_restriction_name('') elif library == '*': if not self.search.current_text: error_dialog(self, _('No search'), _('There is no current search to use'), show=True) return txt = _build_full_search_string(self) try: db.data.search_getting_ids('', txt, use_virtual_library=False) except ParseException as e: error_dialog(self, _('Invalid search'), _('The search in the search box is not valid'), det_msg=e.msg, show=True) return self.search_based_vl = txt db.data.set_base_restriction(txt) self.search_based_vl_name = self._trim_restriction_name('*' + txt) db.data.set_base_restriction_name(self.search_based_vl_name) elif library == self.search_based_vl_name: db.data.set_base_restriction(self.search_based_vl) db.data.set_base_restriction_name(self.search_based_vl_name) elif library in virt_libs: db.data.set_base_restriction(virt_libs[library]) db.data.set_base_restriction_name(library) self.virtual_library.setToolTip(self.virtual_library_tooltip + '\n' + db.data.get_base_restriction()) self._apply_search_restriction(db.data.get_search_restriction(), db.data.get_search_restriction_name()) def build_virtual_library_list(self, menu, handler): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) menu.clear() menu.setIcon(self.empty) def add_action(name, search): a = menu.addAction(name) a.triggered.connect(partial(handler, name=name)) a.setIcon(self.empty) libs = sorted(virt_libs.keys(), key=sort_key) if libs: menu.setEnabled(True) for n in libs: add_action(n, virt_libs[n]) else: menu.setEnabled(False) def remove_vl_triggered(self, name=None): if not question_dialog(self, _('Are you sure?'), _('Are you sure you want to remove ' 'the virtual library {0}').format(name), default_yes=False): return self._remove_vl(name, reapply=True) def _remove_vl(self, name, reapply=True): db = self.library_view.model().db virt_libs = db.prefs.get('virtual_libraries', {}) virt_libs.pop(name, None) db.prefs.set('virtual_libraries', virt_libs) if reapply and db.data.get_base_restriction_name() == name: self.apply_virtual_library('') def _trim_restriction_name(self, name): return name[0:MAX_VIRTUAL_LIBRARY_NAME_LENGTH].strip() def build_search_restriction_list(self): m = self.ar_menu m.clear() current_restriction_text = None if self.search_restriction.count() > 1: txt = unicode(self.search_restriction.itemText(2)) if txt.startswith('*'): current_restriction_text = txt self.search_restriction.clear() current_restriction = self.library_view.model( ).db.data.get_search_restriction_name() m.setIcon(self.checked if current_restriction else self.empty) def add_action(txt, index): self.search_restriction.addItem(txt) txt = self._trim_restriction_name(txt) if txt == current_restriction: a = m.addAction(self.checked, txt if txt else self.no_restriction) else: a = m.addAction(self.empty, txt if txt else self.no_restriction) a.triggered.connect( partial(self.search_restriction_triggered, action=a, index=index)) add_action('', 0) add_action(_('*current search'), 1) dex = 2 if current_restriction_text: add_action(current_restriction_text, 2) dex += 1 for n in sorted(saved_searches().names(), key=sort_key): add_action(n, dex) dex += 1 def search_restriction_triggered(self, action=None, index=None): self.search_restriction.setCurrentIndex(index) self.apply_search_restriction(index) def apply_named_search_restriction(self, name): if not name: r = 0 else: r = self.search_restriction.findText(name) if r < 0: r = 0 self.search_restriction.setCurrentIndex(r) self.apply_search_restriction(r) def apply_text_search_restriction(self, search): search = unicode(search) if not search: self.search_restriction.setCurrentIndex(0) self._apply_search_restriction('', '') else: s = '*' + search if self.search_restriction.count() > 1: txt = unicode(self.search_restriction.itemText(2)) if txt.startswith('*'): self.search_restriction.setItemText(2, s) else: self.search_restriction.insertItem(2, s) else: self.search_restriction.insertItem(2, s) self.search_restriction.setCurrentIndex(2) self._apply_search_restriction(search, self._trim_restriction_name(s)) def apply_search_restriction(self, i): if i == 1: self.apply_text_search_restriction( unicode(self.search.currentText())) elif i == 2 and unicode( self.search_restriction.currentText()).startswith('*'): self.apply_text_search_restriction( unicode(self.search_restriction.currentText())[1:]) else: r = unicode(self.search_restriction.currentText()) if r is not None and r != '': restriction = 'search:"%s"' % (r) else: restriction = '' self._apply_search_restriction(restriction, r) def clear_additional_restriction(self): self._apply_search_restriction('', '') def _apply_search_restriction(self, restriction, name): self.saved_search.clear() # The order below is important. Set the restriction, force a '' search # to apply it, reset the tag browser to take it into account, then set # the book count. self.library_view.model().db.data.set_search_restriction(restriction) self.library_view.model().db.data.set_search_restriction_name(name) self.search.clear(emit_search=True) self.tags_view.recount() self.set_number_of_books_shown() self.current_view().setFocus(Qt.OtherFocusReason) self.set_window_title() v = self.current_view() if not v.currentIndex().isValid(): v.set_current_row() v.refresh_book_details() def set_number_of_books_shown(self): db = self.library_view.model().db if self.current_view() == self.library_view and db is not None and \ db.data.search_restriction_applied(): restrictions = [ x for x in (db.data.get_base_restriction_name(), db.data.get_search_restriction_name()) if x ] t = ' :: '.join(restrictions) if len(t) > 20: t = t[:19] + u'…' self.search_count.setStyleSheet( 'QLabel { border-radius: 6px; background-color: %s }' % tweaks['highlight_virtual_library']) else: # No restriction or not library view t = '' self.search_count.setStyleSheet( 'QLabel { background-color: transparent; }') self.search_count.setText(t)
class BooksView(QTableView): # {{{ files_dropped = pyqtSignal(object) add_column_signal = pyqtSignal() def viewportEvent(self, event): if (event.type() == event.ToolTip and not gprefs['book_list_tooltips']): return False return QTableView.viewportEvent(self, event) def __init__(self, parent, modelcls=BooksModel, use_edit_metadata_dialog=True): QTableView.__init__(self, parent) self.setProperty('highlight_current_item', 150) self.row_sizing_done = False if not tweaks['horizontal_scrolling_per_column']: self.setHorizontalScrollMode(self.ScrollPerPixel) self.setEditTriggers(self.EditKeyPressed) if tweaks['doubleclick_on_library_view'] == 'edit_cell': self.setEditTriggers(self.DoubleClicked | self.editTriggers()) elif tweaks['doubleclick_on_library_view'] == 'open_viewer': self.setEditTriggers(self.SelectedClicked | self.editTriggers()) self.doubleClicked.connect(parent.iactions['View'].view_triggered) elif tweaks['doubleclick_on_library_view'] == 'edit_metadata': # Must not enable single-click to edit, or the field will remain # open in edit mode underneath the edit metadata dialog if use_edit_metadata_dialog: self.doubleClicked.connect( partial(parent.iactions['Edit Metadata'].edit_metadata, checked=False)) else: self.setEditTriggers(self.DoubleClicked | self.editTriggers()) self.drag_allowed = True self.setDragEnabled(True) self.setDragDropOverwriteMode(False) self.setDragDropMode(self.DragDrop) self.drag_start_pos = None self.setAlternatingRowColors(True) self.setSelectionBehavior(self.SelectRows) self.setShowGrid(False) self.setWordWrap(False) self.rating_delegate = RatingDelegate(self) self.timestamp_delegate = DateDelegate(self) self.pubdate_delegate = PubDateDelegate(self) self.last_modified_delegate = DateDelegate( self, tweak_name='gui_last_modified_display_format') self.languages_delegate = LanguagesDelegate(self) self.tags_delegate = CompleteDelegate(self, ',', 'all_tag_names') self.authors_delegate = CompleteDelegate(self, '&', 'all_author_names', True) self.cc_names_delegate = CompleteDelegate(self, '&', 'all_custom', True) self.series_delegate = TextDelegate(self) self.publisher_delegate = TextDelegate(self) self.text_delegate = TextDelegate(self) self.cc_text_delegate = CcTextDelegate(self) self.cc_enum_delegate = CcEnumDelegate(self) self.cc_bool_delegate = CcBoolDelegate(self) self.cc_comments_delegate = CcCommentsDelegate(self) self.cc_template_delegate = CcTemplateDelegate(self) self.cc_number_delegate = CcNumberDelegate(self) self.display_parent = parent self._model = modelcls(self) self.setModel(self._model) self._model.count_changed_signal.connect(self.do_row_sizing, type=Qt.QueuedConnection) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSortingEnabled(True) self.selectionModel().currentRowChanged.connect( self._model.current_changed) self.preserve_state = partial(PreserveViewState, self) # {{{ Column Header setup self.can_add_columns = True self.was_restored = False self.column_header = HeaderView(Qt.Horizontal, self) self.setHorizontalHeader(self.column_header) self.column_header.setMovable(True) self.column_header.setClickable(True) self.column_header.sectionMoved.connect(self.save_state) self.column_header.setContextMenuPolicy(Qt.CustomContextMenu) self.column_header.customContextMenuRequested.connect( self.show_column_header_context_menu) self.column_header.sectionResized.connect(self.column_resized, Qt.QueuedConnection) self.row_header = HeaderView(Qt.Vertical, self) self.setVerticalHeader(self.row_header) # }}} self._model.database_changed.connect(self.database_changed) hv = self.verticalHeader() hv.setClickable(True) hv.setCursor(Qt.PointingHandCursor) self.selected_ids = [] self._model.about_to_be_sorted.connect(self.about_to_be_sorted) self._model.sorting_done.connect(self.sorting_done, type=Qt.QueuedConnection) # Column Header Context Menu {{{ def column_header_context_handler(self, action=None, column=None): if not action or not column: return try: idx = self.column_map.index(column) except: return h = self.column_header if action == 'hide': h.setSectionHidden(idx, True) elif action == 'show': h.setSectionHidden(idx, False) if h.sectionSize(idx) < 3: sz = h.sectionSizeHint(idx) h.resizeSection(idx, sz) elif action == 'ascending': self.sortByColumn(idx, Qt.AscendingOrder) elif action == 'descending': self.sortByColumn(idx, Qt.DescendingOrder) elif action == 'defaults': self.apply_state(self.get_default_state()) elif action == 'addcustcol': self.add_column_signal.emit() elif action.startswith('align_'): alignment = action.partition('_')[-1] self._model.change_alignment(column, alignment) elif action == 'quickview': from calibre.customize.ui import find_plugin qv = find_plugin('Show Quickview') if qv: rows = self.selectionModel().selectedRows() if len(rows) > 0: current_row = rows[0].row() current_col = self.column_map.index(column) index = self.model().index(current_row, current_col) qv.actual_plugin_.change_quickview_column(index) self.save_state() def show_column_header_context_menu(self, pos): idx = self.column_header.logicalIndexAt(pos) if idx > -1 and idx < len(self.column_map): col = self.column_map[idx] name = unicode(self.model().headerData(idx, Qt.Horizontal, Qt.DisplayRole).toString()) self.column_header_context_menu = QMenu(self) if col != 'ondevice': self.column_header_context_menu.addAction( _('Hide column %s') % name, partial(self.column_header_context_handler, action='hide', column=col)) m = self.column_header_context_menu.addMenu(_('Sort on %s') % name) a = m.addAction( _('Ascending'), partial(self.column_header_context_handler, action='ascending', column=col)) d = m.addAction( _('Descending'), partial(self.column_header_context_handler, action='descending', column=col)) if self._model.sorted_on[0] == col: ac = a if self._model.sorted_on[1] else d ac.setCheckable(True) ac.setChecked(True) if col not in ('ondevice', 'inlibrary') and \ (not self.model().is_custom_column(col) or self.model().custom_columns[col]['datatype'] not in ('bool', )): m = self.column_header_context_menu.addMenu( _('Change text alignment for %s') % name) al = self._model.alignment_map.get(col, 'left') for x, t in (('left', _('Left')), ('right', _('Right')), ('center', _('Center'))): a = m.addAction( t, partial(self.column_header_context_handler, action='align_' + x, column=col)) if al == x: a.setCheckable(True) a.setChecked(True) if not isinstance(self, DeviceBooksView): if self._model.db.field_metadata[col]['is_category']: act = self.column_header_context_menu.addAction( _('Quickview column %s') % name, partial(self.column_header_context_handler, action='quickview', column=col)) rows = self.selectionModel().selectedRows() if len(rows) > 1: act.setEnabled(False) hidden_cols = [ self.column_map[i] for i in range(self.column_header.count()) if self.column_header.isSectionHidden(i) ] try: hidden_cols.remove('ondevice') except: pass if hidden_cols: self.column_header_context_menu.addSeparator() m = self.column_header_context_menu.addMenu(_('Show column')) for col in hidden_cols: hidx = self.column_map.index(col) name = unicode(self.model().headerData( hidx, Qt.Horizontal, Qt.DisplayRole).toString()) m.addAction( name, partial(self.column_header_context_handler, action='show', column=col)) self.column_header_context_menu.addSeparator() self.column_header_context_menu.addAction( _('Shrink column if it is too wide to fit'), partial(self.resize_column_to_fit, column=self.column_map[idx])) self.column_header_context_menu.addAction( _('Restore default layout'), partial(self.column_header_context_handler, action='defaults', column=col)) if self.can_add_columns: self.column_header_context_menu.addAction( QIcon(I('column.png')), _('Add your own columns'), partial(self.column_header_context_handler, action='addcustcol', column=col)) self.column_header_context_menu.popup( self.column_header.mapToGlobal(pos)) # }}} # Sorting {{{ def about_to_be_sorted(self, idc): selected_rows = [r.row() for r in self.selectionModel().selectedRows()] self.selected_ids = [idc(r) for r in selected_rows] def sorting_done(self, indexc): pos = self.horizontalScrollBar().value() self.select_rows(self.selected_ids, using_ids=True, change_current=True, scroll=True) self.selected_ids = [] self.horizontalScrollBar().setValue(pos) def sort_by_named_field(self, field, order, reset=True): if field in self.column_map: idx = self.column_map.index(field) if order: self.sortByColumn(idx, Qt.AscendingOrder) else: self.sortByColumn(idx, Qt.DescendingOrder) else: self._model.sort_by_named_field(field, order, reset) def multisort(self, fields, reset=True, only_if_different=False): if len(fields) == 0: return sh = self.cleanup_sort_history(self._model.sort_history, ignore_column_map=True) if only_if_different and len(sh) >= len(fields): ret = True for i, t in enumerate(fields): if t[0] != sh[i][0]: ret = False break if ret: return for n, d in reversed(fields): if n in self._model.db.field_metadata.keys(): sh.insert(0, (n, d)) sh = self.cleanup_sort_history(sh, ignore_column_map=True) self._model.sort_history = [tuple(x) for x in sh] self._model.resort(reset=reset) col = fields[0][0] dir = Qt.AscendingOrder if fields[0][1] else Qt.DescendingOrder if col in self.column_map: col = self.column_map.index(col) hdrs = self.horizontalHeader() try: hdrs.setSortIndicator(col, dir) except: pass # }}} # Ondevice column {{{ def set_ondevice_column_visibility(self): m = self._model self.column_header.setSectionHidden(m.column_map.index('ondevice'), not m.device_connected) def set_device_connected(self, is_connected): self._model.set_device_connected(is_connected) self.set_ondevice_column_visibility() # }}} # Save/Restore State {{{ def get_state(self): h = self.column_header cm = self.column_map state = {} state['hidden_columns'] = [ cm[i] for i in range(h.count()) if h.isSectionHidden(i) and cm[i] != 'ondevice' ] state['last_modified_injected'] = True state['languages_injected'] = True state['sort_history'] = \ self.cleanup_sort_history(self.model().sort_history) state['column_positions'] = {} state['column_sizes'] = {} state['column_alignment'] = self._model.alignment_map for i in range(h.count()): name = cm[i] state['column_positions'][name] = h.visualIndex(i) if name != 'ondevice': state['column_sizes'][name] = h.sectionSize(i) return state def write_state(self, state): db = getattr(self.model(), 'db', None) name = unicode(self.objectName()) if name and db is not None: db.prefs.set(name + ' books view state', state) def save_state(self): # Only save if we have been initialized (set_database called) if len(self.column_map) > 0 and self.was_restored: state = self.get_state() self.write_state(state) def cleanup_sort_history(self, sort_history, ignore_column_map=False): history = [] for col, order in sort_history: if not isinstance(order, bool): continue if col == 'date': col = 'timestamp' if ignore_column_map or col in self.column_map: if (not history or history[-1][0] != col): history.append([col, order]) return history def apply_sort_history(self, saved_history, max_sort_levels=3): if not saved_history: return for col, order in reversed( self.cleanup_sort_history(saved_history)[:max_sort_levels]): self.sortByColumn( self.column_map.index(col), Qt.AscendingOrder if order else Qt.DescendingOrder) def apply_state(self, state, max_sort_levels=3): h = self.column_header cmap = {} hidden = state.get('hidden_columns', []) for i, c in enumerate(self.column_map): cmap[c] = i if c != 'ondevice': h.setSectionHidden(i, c in hidden) positions = state.get('column_positions', {}) pmap = {} for col, pos in positions.items(): if col in cmap: pmap[pos] = col for pos in sorted(pmap.keys()): col = pmap[pos] idx = cmap[col] current_pos = h.visualIndex(idx) if current_pos != pos: h.moveSection(current_pos, pos) sizes = state.get('column_sizes', {}) for col, size in sizes.items(): if col in cmap: sz = sizes[col] if sz < 3: sz = h.sectionSizeHint(cmap[col]) h.resizeSection(cmap[col], sz) self.apply_sort_history(state.get('sort_history', None), max_sort_levels=max_sort_levels) for col, alignment in state.get('column_alignment', {}).items(): self._model.change_alignment(col, alignment) for i in range(h.count()): if not h.isSectionHidden(i) and h.sectionSize(i) < 3: sz = h.sectionSizeHint(i) h.resizeSection(i, sz) def get_default_state(self): old_state = { 'hidden_columns': ['last_modified', 'languages'], 'sort_history': [DEFAULT_SORT], 'column_positions': {}, 'column_sizes': {}, 'column_alignment': { 'size': 'center', 'timestamp': 'center', 'pubdate': 'center' }, 'last_modified_injected': True, 'languages_injected': True, } h = self.column_header cm = self.column_map for i in range(h.count()): name = cm[i] old_state['column_positions'][name] = i if name != 'ondevice': old_state['column_sizes'][name] = \ min(350, max(self.sizeHintForColumn(i), h.sectionSizeHint(i))) if name in ('timestamp', 'last_modified'): old_state['column_sizes'][name] += 12 return old_state def get_old_state(self): ans = None name = unicode(self.objectName()) if name: name += ' books view state' db = getattr(self.model(), 'db', None) if db is not None: ans = db.prefs.get(name, None) if ans is None: ans = gprefs.get(name, None) try: del gprefs[name] except: pass if ans is not None: db.prefs[name] = ans else: injected = False if not ans.get('last_modified_injected', False): injected = True ans['last_modified_injected'] = True hc = ans.get('hidden_columns', []) if 'last_modified' not in hc: hc.append('last_modified') if not ans.get('languages_injected', False): injected = True ans['languages_injected'] = True hc = ans.get('hidden_columns', []) if 'languages' not in hc: hc.append('languages') if injected: db.prefs[name] = ans return ans def restore_state(self): old_state = self.get_old_state() if old_state is None: old_state = self.get_default_state() max_levels = 3 if tweaks['sort_columns_at_startup'] is not None: sh = [] try: for c, d in tweaks['sort_columns_at_startup']: if not isinstance(d, bool): d = True if d == 0 else False sh.append((c, d)) except: # Ignore invalid tweak values as users seem to often get them # wrong print( 'Ignoring invalid sort_columns_at_startup tweak, with error:' ) import traceback traceback.print_exc() old_state['sort_history'] = sh max_levels = max(3, len(sh)) self.column_header.blockSignals(True) self.apply_state(old_state, max_sort_levels=max_levels) self.column_header.blockSignals(False) self.do_row_sizing() self.was_restored = True def refresh_row_sizing(self): self.row_sizing_done = False self.do_row_sizing() def do_row_sizing(self): # Resize all rows to have the correct height if not self.row_sizing_done and self.model().rowCount( QModelIndex()) > 0: self.resizeRowToContents(0) self.verticalHeader().setDefaultSectionSize( self.rowHeight(0) + gprefs['extra_row_spacing']) self.row_sizing_done = True def resize_column_to_fit(self, column): col = self.column_map.index(column) self.column_resized(col, self.columnWidth(col), self.columnWidth(col)) def column_resized(self, col, old_size, new_size): # arbitrary: scroll bar + header + some max_width = self.width() - (self.verticalScrollBar().width() + self.verticalHeader().width() + 10) if max_width < 200: max_width = 200 if new_size > max_width: self.column_header.blockSignals(True) self.setColumnWidth(col, max_width) self.column_header.blockSignals(False) # }}} # Initialization/Delegate Setup {{{ def set_database(self, db): self.save_state() self._model.set_database(db) self.tags_delegate.set_database(db) self.cc_names_delegate.set_database(db) self.authors_delegate.set_database(db) self.series_delegate.set_auto_complete_function(db.all_series) self.publisher_delegate.set_auto_complete_function(db.all_publishers) def database_changed(self, db): for i in range(self.model().columnCount(None)): if self.itemDelegateForColumn(i) in (self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate, self.last_modified_delegate, self.languages_delegate): self.setItemDelegateForColumn(i, self.itemDelegate()) cm = self.column_map for colhead in cm: if self._model.is_custom_column(colhead): cc = self._model.custom_columns[colhead] if cc['datatype'] == 'datetime': delegate = CcDateDelegate(self) delegate.set_format(cc['display'].get('date_format', '')) self.setItemDelegateForColumn(cm.index(colhead), delegate) elif cc['datatype'] == 'comments': self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate) elif cc['datatype'] == 'text': if cc['is_multiple']: if cc['display'].get('is_names', False): self.setItemDelegateForColumn( cm.index(colhead), self.cc_names_delegate) else: self.setItemDelegateForColumn( cm.index(colhead), self.tags_delegate) else: self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] == 'series': self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] in ('int', 'float'): self.setItemDelegateForColumn(cm.index(colhead), self.cc_number_delegate) elif cc['datatype'] == 'bool': self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate) elif cc['datatype'] == 'rating': self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate) elif cc['datatype'] == 'composite': self.setItemDelegateForColumn(cm.index(colhead), self.cc_template_delegate) elif cc['datatype'] == 'enumeration': self.setItemDelegateForColumn(cm.index(colhead), self.cc_enum_delegate) else: dattr = colhead + '_delegate' delegate = colhead if hasattr(self, dattr) else 'text' self.setItemDelegateForColumn( cm.index(colhead), getattr(self, delegate + '_delegate')) self.restore_state() self.set_ondevice_column_visibility() #}}} # Context Menu {{{ def set_context_menu(self, menu, edit_collections_action): self.setContextMenuPolicy(Qt.DefaultContextMenu) self.context_menu = menu self.edit_collections_action = edit_collections_action def contextMenuEvent(self, event): self.context_menu.popup(event.globalPos()) event.accept() # }}} # Drag 'n Drop {{{ @classmethod def paths_from_event(cls, event): ''' Accept a drop event and return a list of paths that can be read from and represent files with extensions. ''' md = event.mimeData() if md.hasFormat('text/uri-list') and not \ md.hasFormat('application/calibre+from_library'): urls = [unicode(u.toLocalFile()) for u in md.urls()] return [ u for u in urls if os.path.splitext(u)[1] and os.path.exists(u) ] def drag_icon(self, cover, multiple): cover = cover.scaledToHeight(120, Qt.SmoothTransformation) if multiple: base_width = cover.width() base_height = cover.height() base = QImage(base_width + 21, base_height + 21, QImage.Format_ARGB32_Premultiplied) base.fill(QColor(255, 255, 255, 0).rgba()) p = QPainter(base) rect = QRect(20, 0, base_width, base_height) p.fillRect(rect, QColor('white')) p.drawRect(rect) rect.moveLeft(10) rect.moveTop(10) p.fillRect(rect, QColor('white')) p.drawRect(rect) rect.moveLeft(0) rect.moveTop(20) p.fillRect(rect, QColor('white')) p.save() p.setCompositionMode(p.CompositionMode_SourceAtop) p.drawImage(rect.topLeft(), cover) p.restore() p.drawRect(rect) p.end() cover = base return QPixmap.fromImage(cover) def drag_data(self): m = self.model() db = m.db rows = self.selectionModel().selectedRows() selected = list(map(m.id, rows)) ids = ' '.join(map(str, selected)) md = QMimeData() md.setData('application/calibre+from_library', ids) fmt = prefs['output_format'] def url_for_id(i): try: ans = db.format_path(i, fmt, index_is_id=True) except: ans = None if ans is None: fmts = db.formats(i, index_is_id=True) if fmts: fmts = fmts.split(',') else: fmts = [] for f in fmts: try: ans = db.format_path(i, f, index_is_id=True) except: ans = None if ans is None: ans = db.abspath(i, index_is_id=True) return QUrl.fromLocalFile(ans) md.setUrls([url_for_id(i) for i in selected]) drag = QDrag(self) col = self.selectionModel().currentIndex().column() md.column_name = self.column_map[col] drag.setMimeData(md) cover = self.drag_icon(m.cover(self.currentIndex().row()), len(selected) > 1) drag.setHotSpot(QPoint(-15, -15)) drag.setPixmap(cover) return drag def event_has_mods(self, event=None): mods = event.modifiers() if event is not None else \ QApplication.keyboardModifiers() return mods & Qt.ControlModifier or mods & Qt.ShiftModifier def mousePressEvent(self, event): ep = event.pos() if self.indexAt(ep) in self.selectionModel().selectedIndexes() and \ event.button() == Qt.LeftButton and not self.event_has_mods(): self.drag_start_pos = ep return QTableView.mousePressEvent(self, event) def mouseMoveEvent(self, event): if not self.drag_allowed: return if self.drag_start_pos is None: return QTableView.mouseMoveEvent(self, event) if self.event_has_mods(): self.drag_start_pos = None return if not (event.buttons() & Qt.LeftButton) or \ (event.pos() - self.drag_start_pos).manhattanLength() \ < QApplication.startDragDistance(): return index = self.indexAt(event.pos()) if not index.isValid(): return drag = self.drag_data() drag.exec_(Qt.CopyAction) self.drag_start_pos = None def dragEnterEvent(self, event): if int(event.possibleActions() & Qt.CopyAction) + \ int(event.possibleActions() & Qt.MoveAction) == 0: return paths = self.paths_from_event(event) if paths: event.acceptProposedAction() def dragMoveEvent(self, event): event.acceptProposedAction() def dropEvent(self, event): paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) event.accept() self.files_dropped.emit(paths) # }}} @property def column_map(self): return self._model.column_map def refresh_book_details(self): idx = self.currentIndex() if idx.isValid(): self._model.current_changed(idx, idx) def scrollContentsBy(self, dx, dy): # Needed as Qt bug causes headerview to not always update when scrolling QTableView.scrollContentsBy(self, dx, dy) if dy != 0: self.column_header.update() def scroll_to_row(self, row): if row > -1: h = self.horizontalHeader() for i in range(h.count()): if not h.isSectionHidden( i) and h.sectionViewportPosition(i) >= 0: self.scrollTo(self.model().index(row, i), self.PositionAtCenter) break def set_current_row(self, row=0, select=True): if row > -1 and row < self.model().rowCount(QModelIndex()): h = self.horizontalHeader() logical_indices = list(range(h.count())) logical_indices = [ x for x in logical_indices if not h.isSectionHidden(x) ] pairs = [(x, h.visualIndex(x)) for x in logical_indices if h.visualIndex(x) > -1] if not pairs: pairs = [(0, 0)] pairs.sort(cmp=lambda x, y: cmp(x[1], y[1])) i = pairs[0][0] index = self.model().index(row, i) self.setCurrentIndex(index) if select: sm = self.selectionModel() sm.select(index, sm.ClearAndSelect | sm.Rows) def keyPressEvent(self, ev): val = self.horizontalScrollBar().value() ret = super(BooksView, self).keyPressEvent(ev) if ev.isAccepted() and ev.key() in ( Qt.Key_Home, Qt.Key_End) and ev.modifiers() & Qt.ControlModifier: self.horizontalScrollBar().setValue(val) return ret def ids_to_rows(self, ids): row_map = OrderedDict() ids = frozenset(ids) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if len(row_map) >= len(ids): break c = m.id(row) if c in ids: row_map[c] = row return row_map def select_rows(self, identifiers, using_ids=True, change_current=True, scroll=True): ''' Select rows identified by identifiers. identifiers can be a set of ids, row numbers or QModelIndexes. ''' rows = set([x.row() if hasattr(x, 'row') else x for x in identifiers]) if using_ids: rows = set([]) identifiers = set(identifiers) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if m.id(row) in identifiers: rows.add(row) rows = list(sorted(rows)) if rows: row = rows[0] if change_current: self.set_current_row(row, select=False) if scroll: self.scroll_to_row(row) sm = self.selectionModel() sel = QItemSelection() m = self.model() max_col = m.columnCount(QModelIndex()) - 1 # Create a range based selector for each set of contiguous rows # as supplying selectors for each individual row causes very poor # performance if a large number of rows has to be selected. for k, g in itertools.groupby(enumerate(rows), lambda (i, x): i - x): group = list(map(operator.itemgetter(1), g)) sel.merge( QItemSelection(m.index(min(group), 0), m.index(max(group), max_col)), sm.Select) sm.select(sel, sm.ClearAndSelect)
class BooksView(QTableView): # {{{ files_dropped = pyqtSignal(object) add_column_signal = pyqtSignal() is_library_view = True def viewportEvent(self, event): if (event.type() == event.ToolTip and not gprefs['book_list_tooltips']): return False return QTableView.viewportEvent(self, event) def __init__(self, parent, modelcls=BooksModel, use_edit_metadata_dialog=True): QTableView.__init__(self, parent) self.gui = parent self.setProperty('highlight_current_item', 150) self.row_sizing_done = False self.alternate_views = AlternateViews(self) if not tweaks['horizontal_scrolling_per_column']: self.setHorizontalScrollMode(self.ScrollPerPixel) self.setEditTriggers(self.EditKeyPressed) if tweaks['doubleclick_on_library_view'] == 'edit_cell': self.setEditTriggers(self.DoubleClicked | self.editTriggers()) elif tweaks['doubleclick_on_library_view'] == 'open_viewer': self.setEditTriggers(self.SelectedClicked | self.editTriggers()) self.doubleClicked.connect(parent.iactions['View'].view_triggered) elif tweaks['doubleclick_on_library_view'] == 'edit_metadata': # Must not enable single-click to edit, or the field will remain # open in edit mode underneath the edit metadata dialog if use_edit_metadata_dialog: self.doubleClicked.connect( partial(parent.iactions['Edit Metadata'].edit_metadata, checked=False)) else: self.setEditTriggers(self.DoubleClicked | self.editTriggers()) setup_dnd_interface(self) self.setAlternatingRowColors(True) self.setShowGrid(False) self.setWordWrap(False) self.rating_delegate = RatingDelegate(self) self.timestamp_delegate = DateDelegate(self) self.pubdate_delegate = PubDateDelegate(self) self.last_modified_delegate = DateDelegate( self, tweak_name='gui_last_modified_display_format') self.languages_delegate = LanguagesDelegate(self) self.tags_delegate = CompleteDelegate(self, ',', 'all_tag_names') self.authors_delegate = CompleteDelegate(self, '&', 'all_author_names', True) self.cc_names_delegate = CompleteDelegate(self, '&', 'all_custom', True) self.series_delegate = TextDelegate(self) self.publisher_delegate = TextDelegate(self) self.text_delegate = TextDelegate(self) self.cc_text_delegate = CcTextDelegate(self) self.cc_enum_delegate = CcEnumDelegate(self) self.cc_bool_delegate = CcBoolDelegate(self) self.cc_comments_delegate = CcCommentsDelegate(self) self.cc_template_delegate = CcTemplateDelegate(self) self.cc_number_delegate = CcNumberDelegate(self) self.display_parent = parent self._model = modelcls(self) self.setModel(self._model) self._model.count_changed_signal.connect(self.do_row_sizing, type=Qt.QueuedConnection) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSortingEnabled(True) self.selectionModel().currentRowChanged.connect( self._model.current_changed) self.preserve_state = partial(PreserveViewState, self) self.marked_changed_listener = FunctionDispatcher(self.marked_changed) # {{{ Column Header setup self.can_add_columns = True self.was_restored = False self.column_header = HeaderView(Qt.Horizontal, self) self.setHorizontalHeader(self.column_header) self.column_header.sortIndicatorChanged.disconnect() self.column_header.sortIndicatorChanged.connect( self.user_sort_requested) self.column_header.setMovable(True) self.column_header.setClickable(True) self.column_header.sectionMoved.connect(self.save_state) self.column_header.setContextMenuPolicy(Qt.CustomContextMenu) self.column_header.customContextMenuRequested.connect( self.show_column_header_context_menu) self.column_header.sectionResized.connect(self.column_resized, Qt.QueuedConnection) self.row_header = HeaderView(Qt.Vertical, self) self.row_header.setResizeMode(self.row_header.Fixed) self.setVerticalHeader(self.row_header) # }}} self._model.database_changed.connect(self.database_changed) hv = self.verticalHeader() hv.setClickable(True) hv.setCursor(Qt.PointingHandCursor) self.selected_ids = [] self._model.about_to_be_sorted.connect(self.about_to_be_sorted) self._model.sorting_done.connect(self.sorting_done, type=Qt.QueuedConnection) # Column Header Context Menu {{{ def column_header_context_handler(self, action=None, column=None): if not action or not column: return try: idx = self.column_map.index(column) except: return h = self.column_header if action == 'hide': h.setSectionHidden(idx, True) elif action == 'show': h.setSectionHidden(idx, False) if h.sectionSize(idx) < 3: sz = h.sectionSizeHint(idx) h.resizeSection(idx, sz) elif action == 'ascending': self.sort_by_column_and_order(idx, True) elif action == 'descending': self.sort_by_column_and_order(idx, False) elif action == 'defaults': self.apply_state(self.get_default_state()) elif action == 'addcustcol': self.add_column_signal.emit() elif action.startswith('align_'): alignment = action.partition('_')[-1] self._model.change_alignment(column, alignment) elif action == 'quickview': from calibre.customize.ui import find_plugin qv = find_plugin('Show Quickview') if qv: rows = self.selectionModel().selectedRows() if len(rows) > 0: current_row = rows[0].row() current_col = self.column_map.index(column) index = self.model().index(current_row, current_col) qv.actual_plugin_.change_quickview_column(index) self.save_state() def show_column_header_context_menu(self, pos): idx = self.column_header.logicalIndexAt(pos) if idx > -1 and idx < len(self.column_map): col = self.column_map[idx] name = unicode(self.model().headerData(idx, Qt.Horizontal, Qt.DisplayRole).toString()) self.column_header_context_menu = QMenu(self) if col != 'ondevice': self.column_header_context_menu.addAction( _('Hide column %s') % name, partial(self.column_header_context_handler, action='hide', column=col)) m = self.column_header_context_menu.addMenu(_('Sort on %s') % name) a = m.addAction( _('Ascending'), partial(self.column_header_context_handler, action='ascending', column=col)) d = m.addAction( _('Descending'), partial(self.column_header_context_handler, action='descending', column=col)) if self._model.sorted_on[0] == col: ac = a if self._model.sorted_on[1] else d ac.setCheckable(True) ac.setChecked(True) if col not in ('ondevice', 'inlibrary') and \ (not self.model().is_custom_column(col) or self.model().custom_columns[col]['datatype'] not in ('bool', )): m = self.column_header_context_menu.addMenu( _('Change text alignment for %s') % name) al = self._model.alignment_map.get(col, 'left') for x, t in (('left', _('Left')), ('right', _('Right')), ('center', _('Center'))): a = m.addAction( t, partial(self.column_header_context_handler, action='align_' + x, column=col)) if al == x: a.setCheckable(True) a.setChecked(True) if not isinstance(self, DeviceBooksView): if self._model.db.field_metadata[col]['is_category']: act = self.column_header_context_menu.addAction( _('Quickview column %s') % name, partial(self.column_header_context_handler, action='quickview', column=col)) rows = self.selectionModel().selectedRows() if len(rows) > 1: act.setEnabled(False) hidden_cols = [ self.column_map[i] for i in range(self.column_header.count()) if self.column_header.isSectionHidden(i) ] try: hidden_cols.remove('ondevice') except: pass if hidden_cols: self.column_header_context_menu.addSeparator() m = self.column_header_context_menu.addMenu(_('Show column')) for col in hidden_cols: hidx = self.column_map.index(col) name = unicode(self.model().headerData( hidx, Qt.Horizontal, Qt.DisplayRole).toString()) m.addAction( name, partial(self.column_header_context_handler, action='show', column=col)) self.column_header_context_menu.addSeparator() self.column_header_context_menu.addAction( _('Shrink column if it is too wide to fit'), partial(self.resize_column_to_fit, column=self.column_map[idx])) self.column_header_context_menu.addAction( _('Restore default layout'), partial(self.column_header_context_handler, action='defaults', column=col)) if self.can_add_columns: self.column_header_context_menu.addAction( QIcon(I('column.png')), _('Add your own columns'), partial(self.column_header_context_handler, action='addcustcol', column=col)) self.column_header_context_menu.popup( self.column_header.mapToGlobal(pos)) # }}} # Sorting {{{ def sort_by_column_and_order(self, col, ascending): self.column_header.blockSignals(True) self.sortByColumn( col, Qt.AscendingOrder if ascending else Qt.DescendingOrder) self.column_header.blockSignals(False) def user_sort_requested(self, col, order=Qt.AscendingOrder): if col >= len(self.column_map) or col < 0: return QTableView.sortByColumn(self, col) field = self.column_map[col] self.intelligent_sort(field, order == Qt.AscendingOrder) def intelligent_sort(self, field, ascending): m = self.model() pname = 'previous_sort_order_' + self.__class__.__name__ previous = gprefs.get(pname, {}) if field == m.sorted_on[0] or field not in previous: self.sort_by_named_field(field, ascending) previous[field] = ascending gprefs[pname] = previous return previous[m.sorted_on[0]] = m.sorted_on[1] gprefs[pname] = previous self.sort_by_named_field(field, previous[field]) def about_to_be_sorted(self, idc): selected_rows = [r.row() for r in self.selectionModel().selectedRows()] self.selected_ids = [idc(r) for r in selected_rows] def sorting_done(self, indexc): pos = self.horizontalScrollBar().value() self.select_rows(self.selected_ids, using_ids=True, change_current=True, scroll=True) self.selected_ids = [] self.horizontalScrollBar().setValue(pos) def sort_by_named_field(self, field, order, reset=True): if field in self.column_map: idx = self.column_map.index(field) self.sort_by_column_and_order(idx, order) else: self._model.sort_by_named_field(field, order, reset) self.column_header.blockSignals(True) self.column_header.setSortIndicator(-1, Qt.AscendingOrder) self.column_header.blockSignals(False) def multisort(self, fields, reset=True, only_if_different=False): if len(fields) == 0: return sh = self.cleanup_sort_history(self._model.sort_history, ignore_column_map=True) if only_if_different and len(sh) >= len(fields): ret = True for i, t in enumerate(fields): if t[0] != sh[i][0]: ret = False break if ret: return for n, d in reversed(fields): if n in self._model.db.field_metadata.keys(): sh.insert(0, (n, d)) sh = self.cleanup_sort_history(sh, ignore_column_map=True) self._model.sort_history = [tuple(x) for x in sh] self._model.resort(reset=reset) col = fields[0][0] dir = Qt.AscendingOrder if fields[0][1] else Qt.DescendingOrder if col in self.column_map: col = self.column_map.index(col) self.column_header.blockSignals(True) try: self.column_header.setSortIndicator(col, dir) finally: self.column_header.blockSignals(False) # }}} # Ondevice column {{{ def set_ondevice_column_visibility(self): m = self._model self.column_header.setSectionHidden(m.column_map.index('ondevice'), not m.device_connected) def set_device_connected(self, is_connected): self._model.set_device_connected(is_connected) self.set_ondevice_column_visibility() # }}} # Save/Restore State {{{ def get_state(self): h = self.column_header cm = self.column_map state = {} state['hidden_columns'] = [ cm[i] for i in range(h.count()) if h.isSectionHidden(i) and cm[i] != 'ondevice' ] state['last_modified_injected'] = True state['languages_injected'] = True state['sort_history'] = \ self.cleanup_sort_history(self.model().sort_history, ignore_column_map=self.is_library_view) state['column_positions'] = {} state['column_sizes'] = {} state['column_alignment'] = self._model.alignment_map for i in range(h.count()): name = cm[i] state['column_positions'][name] = h.visualIndex(i) if name != 'ondevice': state['column_sizes'][name] = h.sectionSize(i) return state def write_state(self, state): db = getattr(self.model(), 'db', None) name = unicode(self.objectName()) if name and db is not None: db.prefs.set(name + ' books view state', state) def save_state(self): # Only save if we have been initialized (set_database called) if len(self.column_map) > 0 and self.was_restored: state = self.get_state() self.write_state(state) def cleanup_sort_history(self, sort_history, ignore_column_map=False): history = [] for col, order in sort_history: if not isinstance(order, bool): continue col = {'date': 'timestamp', 'sort': 'title'}.get(col, col) if ignore_column_map or col in self.column_map: if (not history or history[-1][0] != col): history.append([col, order]) return history def apply_sort_history(self, saved_history, max_sort_levels=3): if not saved_history: return if self.is_library_view: for col, order in reversed( self.cleanup_sort_history( saved_history, ignore_column_map=True)[:max_sort_levels]): self.sort_by_named_field(col, order) else: for col, order in reversed( self.cleanup_sort_history(saved_history) [:max_sort_levels]): self.sort_by_column_and_order(self.column_map.index(col), order) def apply_state(self, state, max_sort_levels=3): h = self.column_header cmap = {} hidden = state.get('hidden_columns', []) for i, c in enumerate(self.column_map): cmap[c] = i if c != 'ondevice': h.setSectionHidden(i, c in hidden) positions = state.get('column_positions', {}) pmap = {} for col, pos in positions.items(): if col in cmap: pmap[pos] = col for pos in sorted(pmap.keys()): col = pmap[pos] idx = cmap[col] current_pos = h.visualIndex(idx) if current_pos != pos: h.moveSection(current_pos, pos) sizes = state.get('column_sizes', {}) for col, size in sizes.items(): if col in cmap: sz = sizes[col] if sz < 3: sz = h.sectionSizeHint(cmap[col]) h.resizeSection(cmap[col], sz) self.apply_sort_history(state.get('sort_history', None), max_sort_levels=max_sort_levels) for col, alignment in state.get('column_alignment', {}).items(): self._model.change_alignment(col, alignment) for i in range(h.count()): if not h.isSectionHidden(i) and h.sectionSize(i) < 3: sz = h.sectionSizeHint(i) h.resizeSection(i, sz) def get_default_state(self): old_state = { 'hidden_columns': ['last_modified', 'languages'], 'sort_history': [DEFAULT_SORT], 'column_positions': {}, 'column_sizes': {}, 'column_alignment': { 'size': 'center', 'timestamp': 'center', 'pubdate': 'center' }, 'last_modified_injected': True, 'languages_injected': True, } h = self.column_header cm = self.column_map for i in range(h.count()): name = cm[i] old_state['column_positions'][name] = i if name != 'ondevice': old_state['column_sizes'][name] = \ min(350, max(self.sizeHintForColumn(i), h.sectionSizeHint(i))) if name in ('timestamp', 'last_modified'): old_state['column_sizes'][name] += 12 return old_state def get_old_state(self): ans = None name = unicode(self.objectName()) if name: name += ' books view state' db = getattr(self.model(), 'db', None) if db is not None: ans = db.prefs.get(name, None) if ans is None: ans = gprefs.get(name, None) try: del gprefs[name] except: pass if ans is not None: db.prefs[name] = ans else: injected = False if not ans.get('last_modified_injected', False): injected = True ans['last_modified_injected'] = True hc = ans.get('hidden_columns', []) if 'last_modified' not in hc: hc.append('last_modified') if not ans.get('languages_injected', False): injected = True ans['languages_injected'] = True hc = ans.get('hidden_columns', []) if 'languages' not in hc: hc.append('languages') if injected: db.prefs[name] = ans return ans def restore_state(self): old_state = self.get_old_state() if old_state is None: old_state = self.get_default_state() max_levels = 3 if tweaks['sort_columns_at_startup'] is not None: sh = [] try: for c, d in tweaks['sort_columns_at_startup']: if not isinstance(d, bool): d = True if d == 0 else False sh.append((c, d)) except: # Ignore invalid tweak values as users seem to often get them # wrong print( 'Ignoring invalid sort_columns_at_startup tweak, with error:' ) import traceback traceback.print_exc() old_state['sort_history'] = sh max_levels = max(3, len(sh)) self.column_header.blockSignals(True) self.apply_state(old_state, max_sort_levels=max_levels) self.column_header.blockSignals(False) self.do_row_sizing() self.was_restored = True def refresh_row_sizing(self): self.row_sizing_done = False self.do_row_sizing() def do_row_sizing(self): # Resize all rows to have the correct height if not self.row_sizing_done and self.model().rowCount( QModelIndex()) > 0: self.resizeRowToContents(0) self.verticalHeader().setDefaultSectionSize( self.rowHeight(0) + gprefs['extra_row_spacing']) self._model.set_row_height(self.rowHeight(0)) self.row_sizing_done = True def resize_column_to_fit(self, column): col = self.column_map.index(column) self.column_resized(col, self.columnWidth(col), self.columnWidth(col)) def column_resized(self, col, old_size, new_size): # arbitrary: scroll bar + header + some max_width = self.width() - (self.verticalScrollBar().width() + self.verticalHeader().width() + 10) if max_width < 200: max_width = 200 if new_size > max_width: self.column_header.blockSignals(True) self.setColumnWidth(col, max_width) self.column_header.blockSignals(False) # }}} # Initialization/Delegate Setup {{{ def set_database(self, db): self.alternate_views.set_database(db) self.save_state() self._model.set_database(db) self.tags_delegate.set_database(db) self.cc_names_delegate.set_database(db) self.authors_delegate.set_database(db) self.series_delegate.set_auto_complete_function(db.all_series) self.publisher_delegate.set_auto_complete_function(db.all_publishers) self.alternate_views.set_database(db, stage=1) def marked_changed(self, old_marked, current_marked): self.alternate_views.marked_changed(old_marked, current_marked) if bool(old_marked) == bool(current_marked): changed = old_marked | current_marked i = self.model().db.data.id_to_index def f(x): try: return i(x) except ValueError: pass sections = tuple(x for x in map(f, changed) if x is not None) if sections: self.row_header.headerDataChanged(Qt.Vertical, min(sections), max(sections)) else: # Marked items have either appeared or all been removed self.model().set_row_decoration(current_marked) self.row_header.headerDataChanged(Qt.Vertical, 0, self.row_header.count() - 1) self.row_header.geometriesChanged.emit() def database_changed(self, db): db.data.add_marked_listener(self.marked_changed_listener) for i in range(self.model().columnCount(None)): if self.itemDelegateForColumn(i) in (self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate, self.last_modified_delegate, self.languages_delegate): self.setItemDelegateForColumn(i, self.itemDelegate()) cm = self.column_map for colhead in cm: if self._model.is_custom_column(colhead): cc = self._model.custom_columns[colhead] if cc['datatype'] == 'datetime': delegate = CcDateDelegate(self) delegate.set_format(cc['display'].get('date_format', '')) self.setItemDelegateForColumn(cm.index(colhead), delegate) elif cc['datatype'] == 'comments': self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate) elif cc['datatype'] == 'text': if cc['is_multiple']: if cc['display'].get('is_names', False): self.setItemDelegateForColumn( cm.index(colhead), self.cc_names_delegate) else: self.setItemDelegateForColumn( cm.index(colhead), self.tags_delegate) else: self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] == 'series': self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] in ('int', 'float'): self.setItemDelegateForColumn(cm.index(colhead), self.cc_number_delegate) elif cc['datatype'] == 'bool': self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate) elif cc['datatype'] == 'rating': self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate) elif cc['datatype'] == 'composite': self.setItemDelegateForColumn(cm.index(colhead), self.cc_template_delegate) elif cc['datatype'] == 'enumeration': self.setItemDelegateForColumn(cm.index(colhead), self.cc_enum_delegate) else: dattr = colhead + '_delegate' delegate = colhead if hasattr(self, dattr) else 'text' self.setItemDelegateForColumn( cm.index(colhead), getattr(self, delegate + '_delegate')) self.restore_state() self.set_ondevice_column_visibility() #}}} # Context Menu {{{ def set_context_menu(self, menu, edit_collections_action): self.setContextMenuPolicy(Qt.DefaultContextMenu) self.context_menu = menu self.alternate_views.set_context_menu(menu) self.edit_collections_action = edit_collections_action def contextMenuEvent(self, event): sac = self.gui.iactions['Sort By'] sort_added = tuple(ac for ac in self.context_menu.actions() if ac is sac.qaction) if sort_added: sac.update_menu() self.context_menu.popup(event.globalPos()) event.accept() # }}} @property def column_map(self): return self._model.column_map @property def visible_columns(self): h = self.horizontalHeader() logical_indices = (x for x in xrange(h.count()) if not h.isSectionHidden(x)) rmap = {i: x for i, x in enumerate(self.column_map)} return (rmap[h.visualIndex(x)] for x in logical_indices if h.visualIndex(x) > -1) def refresh_book_details(self): idx = self.currentIndex() if idx.isValid(): self._model.current_changed(idx, idx) return True return False def scrollContentsBy(self, dx, dy): # Needed as Qt bug causes headerview to not always update when scrolling QTableView.scrollContentsBy(self, dx, dy) if dy != 0: self.column_header.update() def scroll_to_row(self, row): if row > -1: h = self.horizontalHeader() for i in range(h.count()): if not h.isSectionHidden( i) and h.sectionViewportPosition(i) >= 0: self.scrollTo(self.model().index(row, i), self.PositionAtCenter) break @property def current_book(self): ci = self.currentIndex() if ci.isValid(): try: return self.model().db.data.index_to_id(ci.row()) except (IndexError, ValueError, KeyError, TypeError, AttributeError): pass def current_book_state(self): return self.current_book, self.horizontalScrollBar().value() def restore_current_book_state(self, state): book_id, hpos = state try: row = self.model().db.data.id_to_index(book_id) except (IndexError, ValueError, KeyError, TypeError, AttributeError): return self.set_current_row(row) self.scroll_to_row(row) self.horizontalScrollBar().setValue(hpos) def set_current_row(self, row=0, select=True, for_sync=False): if row > -1 and row < self.model().rowCount(QModelIndex()): h = self.horizontalHeader() logical_indices = list(range(h.count())) logical_indices = [ x for x in logical_indices if not h.isSectionHidden(x) ] pairs = [(x, h.visualIndex(x)) for x in logical_indices if h.visualIndex(x) > -1] if not pairs: pairs = [(0, 0)] pairs.sort(cmp=lambda x, y: cmp(x[1], y[1])) i = pairs[0][0] index = self.model().index(row, i) if for_sync: sm = self.selectionModel() sm.setCurrentIndex(index, sm.NoUpdate) else: self.setCurrentIndex(index) if select: sm = self.selectionModel() sm.select(index, sm.ClearAndSelect | sm.Rows) def row_at_top(self): pos = 0 while pos < 100: ans = self.rowAt(pos) if ans > -1: return ans pos += 5 def row_at_bottom(self): pos = self.viewport().height() limit = pos - 100 while pos > limit: ans = self.rowAt(pos) if ans > -1: return ans pos -= 5 def moveCursor(self, action, modifiers): orig = self.currentIndex() index = QTableView.moveCursor(self, action, modifiers) if action == QTableView.MovePageDown: moved = index.row() - orig.row() try: rows = self.row_at_bottom() - self.row_at_top() except TypeError: rows = moved if moved > rows: index = self.model().index(orig.row() + rows, index.column()) elif action == QTableView.MovePageUp: moved = orig.row() - index.row() try: rows = self.row_at_bottom() - self.row_at_top() except TypeError: rows = moved if moved > rows: index = self.model().index(orig.row() - rows, index.column()) elif action == QTableView.MoveHome and modifiers & Qt.ControlModifier: return self.model().index(0, orig.column()) elif action == QTableView.MoveEnd and modifiers & Qt.ControlModifier: return self.model().index(self.model().rowCount(QModelIndex()) - 1, orig.column()) return index def ids_to_rows(self, ids): row_map = OrderedDict() ids = frozenset(ids) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if len(row_map) >= len(ids): break c = m.id(row) if c in ids: row_map[c] = row return row_map def select_rows(self, identifiers, using_ids=True, change_current=True, scroll=True): ''' Select rows identified by identifiers. identifiers can be a set of ids, row numbers or QModelIndexes. ''' rows = set([x.row() if hasattr(x, 'row') else x for x in identifiers]) if using_ids: rows = set([]) identifiers = set(identifiers) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if m.id(row) in identifiers: rows.add(row) rows = list(sorted(rows)) if rows: row = rows[0] if change_current: self.set_current_row(row, select=False) if scroll: self.scroll_to_row(row) sm = self.selectionModel() sel = QItemSelection() m = self.model() max_col = m.columnCount(QModelIndex()) - 1 # Create a range based selector for each set of contiguous rows # as supplying selectors for each individual row causes very poor # performance if a large number of rows has to be selected. for k, g in itertools.groupby(enumerate(rows), lambda (i, x): i - x): group = list(map(operator.itemgetter(1), g)) sel.merge( QItemSelection(m.index(min(group), 0), m.index(max(group), max_col)), sm.Select) sm.select(sel, sm.ClearAndSelect)
class ConfigWidget(ConfigWidgetBase, Ui_Form): def genesis(self, gui): self.gui = gui self.delegate = Delegate(self.tweaks_view) self.tweaks_view.setItemDelegate(self.delegate) self.tweaks_view.currentChanged = self.current_changed self.view = self.tweaks_view self.highlighter = PythonHighlighter(self.edit_tweak.document()) self.restore_default_button.clicked.connect(self.restore_to_default) self.apply_button.clicked.connect(self.apply_tweak) self.plugin_tweaks_button.clicked.connect(self.plugin_tweaks) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 100) self.next_button.clicked.connect(self.find_next) self.previous_button.clicked.connect(self.find_previous) self.search.initialize('tweaks_search_history', help_text=_('Search for tweak')) self.search.search.connect(self.find) self.view.setContextMenuPolicy(Qt.CustomContextMenu) self.view.customContextMenuRequested.connect(self.show_context_menu) self.copy_icon = QIcon(I('edit-copy.png')) def show_context_menu(self, point): idx = self.tweaks_view.currentIndex() if not idx.isValid(): return True tweak = self.tweaks.data(idx, Qt.UserRole) self.context_menu = QMenu(self) self.context_menu.addAction(self.copy_icon, _('Copy to clipboard'), partial(self.copy_item_to_clipboard, val=u"%s (%s: %s)"%(tweak.name, _('ID'), tweak.var_names[0]))) self.context_menu.popup(self.mapToGlobal(point)) return True def copy_item_to_clipboard(self, val): cb = QApplication.clipboard() cb.clear() cb.setText(val) def plugin_tweaks(self): raw = self.tweaks.plugin_tweaks_string d = PluginTweaks(raw, self) if d.exec_() == d.Accepted: g, l = {}, {} try: exec(unicode(d.edit.toPlainText()), g, l) except: import traceback return error_dialog(self, _('Failed'), _('There was a syntax error in your tweak. Click ' 'the show details button for details.'), show=True, det_msg=traceback.format_exc()) self.tweaks.set_plugin_tweaks(l) self.changed() def current_changed(self, current, previous): self.tweaks_view.scrollTo(current) tweak = self.tweaks.data(current, Qt.UserRole) self.help.setPlainText(tweak.doc) self.edit_tweak.setPlainText(tweak.edit_text) def changed(self, *args): self.changed_signal.emit() def initialize(self): self.tweaks = self._model = Tweaks() self.tweaks_view.setModel(self.tweaks) def restore_to_default(self, *args): idx = self.tweaks_view.currentIndex() if idx.isValid(): self.tweaks.restore_to_default(idx) tweak = self.tweaks.data(idx, Qt.UserRole) self.edit_tweak.setPlainText(tweak.edit_text) self.changed() def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) self.tweaks.restore_to_defaults() self.changed() def apply_tweak(self): idx = self.tweaks_view.currentIndex() if idx.isValid(): l, g = {}, {} try: exec(unicode(self.edit_tweak.toPlainText()), g, l) except: import traceback error_dialog(self.gui, _('Failed'), _('There was a syntax error in your tweak. Click ' 'the show details button for details.'), det_msg=traceback.format_exc(), show=True) return self.tweaks.update_tweak(idx, l) self.changed() def commit(self): raw = self.tweaks.to_string() try: exec(raw) except: import traceback error_dialog(self, _('Invalid tweaks'), _('The tweaks you entered are invalid, try resetting the' ' tweaks to default and changing them one by one until' ' you find the invalid setting.'), det_msg=traceback.format_exc(), show=True) raise AbortCommit('abort') write_tweaks(raw) ConfigWidgetBase.commit(self) return True def find(self, query): if not query: return try: idx = self._model.find(query) except ParseException: self.search.search_done(False) return self.search.search_done(True) if not idx.isValid(): info_dialog(self, _('No matches'), _('Could not find any shortcuts matching %s')%query, show=True, show_copy_button=False) return self.highlight_index(idx) def highlight_index(self, idx): if not idx.isValid(): return self.view.scrollTo(idx) self.view.selectionModel().select(idx, self.view.selectionModel().ClearAndSelect) self.view.setCurrentIndex(idx) def find_next(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0) idx = self._model.find_next(idx, unicode(self.search.currentText())) self.highlight_index(idx) def find_previous(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0) idx = self._model.find_next(idx, unicode(self.search.currentText()), backwards=True) self.highlight_index(idx)
class BooksView(QTableView): # {{{ files_dropped = pyqtSignal(object) add_column_signal = pyqtSignal() def viewportEvent(self, event): if (event.type() == event.ToolTip and not gprefs['book_list_tooltips']): return False return QTableView.viewportEvent(self, event) def __init__(self, parent, modelcls=BooksModel, use_edit_metadata_dialog=True): QTableView.__init__(self, parent) if not tweaks['horizontal_scrolling_per_column']: self.setHorizontalScrollMode(self.ScrollPerPixel) self.setEditTriggers(self.EditKeyPressed) if tweaks['doubleclick_on_library_view'] == 'edit_cell': self.setEditTriggers(self.DoubleClicked|self.editTriggers()) elif tweaks['doubleclick_on_library_view'] == 'open_viewer': self.setEditTriggers(self.SelectedClicked|self.editTriggers()) self.doubleClicked.connect(parent.iactions['View'].view_triggered) elif tweaks['doubleclick_on_library_view'] == 'edit_metadata': # Must not enable single-click to edit, or the field will remain # open in edit mode underneath the edit metadata dialog if use_edit_metadata_dialog: self.doubleClicked.connect( partial(parent.iactions['Edit Metadata'].edit_metadata, checked=False)) else: self.setEditTriggers(self.DoubleClicked|self.editTriggers()) self.drag_allowed = True self.setDragEnabled(True) self.setDragDropOverwriteMode(False) self.setDragDropMode(self.DragDrop) self.drag_start_pos = None self.setAlternatingRowColors(True) self.setSelectionBehavior(self.SelectRows) self.setShowGrid(False) self.setWordWrap(False) self.rating_delegate = RatingDelegate(self) self.timestamp_delegate = DateDelegate(self) self.pubdate_delegate = PubDateDelegate(self) self.last_modified_delegate = DateDelegate(self, tweak_name='gui_last_modified_display_format') self.languages_delegate = LanguagesDelegate(self) self.tags_delegate = CompleteDelegate(self, ',', 'all_tag_names') self.authors_delegate = CompleteDelegate(self, '&', 'all_author_names', True) self.cc_names_delegate = CompleteDelegate(self, '&', 'all_custom', True) self.series_delegate = TextDelegate(self) self.publisher_delegate = TextDelegate(self) self.text_delegate = TextDelegate(self) self.cc_text_delegate = CcTextDelegate(self) self.cc_enum_delegate = CcEnumDelegate(self) self.cc_bool_delegate = CcBoolDelegate(self) self.cc_comments_delegate = CcCommentsDelegate(self) self.cc_template_delegate = CcTemplateDelegate(self) self.cc_number_delegate = CcNumberDelegate(self) self.display_parent = parent self._model = modelcls(self) self.setModel(self._model) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSortingEnabled(True) self.selectionModel().currentRowChanged.connect(self._model.current_changed) self.preserve_state = partial(PreserveViewState, self) # {{{ Column Header setup self.can_add_columns = True self.was_restored = False self.column_header = self.horizontalHeader() self.column_header.setMovable(True) self.column_header.sectionMoved.connect(self.save_state) self.column_header.setContextMenuPolicy(Qt.CustomContextMenu) self.column_header.customContextMenuRequested.connect(self.show_column_header_context_menu) self.column_header.sectionResized.connect(self.column_resized, Qt.QueuedConnection) # }}} self._model.database_changed.connect(self.database_changed) hv = self.verticalHeader() hv.setClickable(True) hv.setCursor(Qt.PointingHandCursor) self.selected_ids = [] self._model.about_to_be_sorted.connect(self.about_to_be_sorted) self._model.sorting_done.connect(self.sorting_done, type=Qt.QueuedConnection) # Column Header Context Menu {{{ def column_header_context_handler(self, action=None, column=None): if not action or not column: return try: idx = self.column_map.index(column) except: return h = self.column_header if action == 'hide': h.setSectionHidden(idx, True) elif action == 'show': h.setSectionHidden(idx, False) if h.sectionSize(idx) < 3: sz = h.sectionSizeHint(idx) h.resizeSection(idx, sz) elif action == 'ascending': self.sortByColumn(idx, Qt.AscendingOrder) elif action == 'descending': self.sortByColumn(idx, Qt.DescendingOrder) elif action == 'defaults': self.apply_state(self.get_default_state()) elif action == 'addcustcol': self.add_column_signal.emit() elif action.startswith('align_'): alignment = action.partition('_')[-1] self._model.change_alignment(column, alignment) self.save_state() def show_column_header_context_menu(self, pos): idx = self.column_header.logicalIndexAt(pos) if idx > -1 and idx < len(self.column_map): col = self.column_map[idx] name = unicode(self.model().headerData(idx, Qt.Horizontal, Qt.DisplayRole).toString()) self.column_header_context_menu = QMenu(self) if col != 'ondevice': self.column_header_context_menu.addAction(_('Hide column %s') % name, partial(self.column_header_context_handler, action='hide', column=col)) m = self.column_header_context_menu.addMenu( _('Sort on %s') % name) a = m.addAction(_('Ascending'), partial(self.column_header_context_handler, action='ascending', column=col)) d = m.addAction(_('Descending'), partial(self.column_header_context_handler, action='descending', column=col)) if self._model.sorted_on[0] == col: ac = a if self._model.sorted_on[1] else d ac.setCheckable(True) ac.setChecked(True) if col not in ('ondevice', 'inlibrary') and \ (not self.model().is_custom_column(col) or \ self.model().custom_columns[col]['datatype'] not in ('bool', )): m = self.column_header_context_menu.addMenu( _('Change text alignment for %s') % name) al = self._model.alignment_map.get(col, 'left') for x, t in (('left', _('Left')), ('right', _('Right')), ('center', _('Center'))): a = m.addAction(t, partial(self.column_header_context_handler, action='align_'+x, column=col)) if al == x: a.setCheckable(True) a.setChecked(True) hidden_cols = [self.column_map[i] for i in range(self.column_header.count()) if self.column_header.isSectionHidden(i)] try: hidden_cols.remove('ondevice') except: pass if hidden_cols: self.column_header_context_menu.addSeparator() m = self.column_header_context_menu.addMenu(_('Show column')) for col in hidden_cols: hidx = self.column_map.index(col) name = unicode(self.model().headerData(hidx, Qt.Horizontal, Qt.DisplayRole).toString()) m.addAction(name, partial(self.column_header_context_handler, action='show', column=col)) self.column_header_context_menu.addSeparator() self.column_header_context_menu.addAction( _('Shrink column if it is too wide to fit'), partial(self.resize_column_to_fit, column=self.column_map[idx])) self.column_header_context_menu.addAction( _('Restore default layout'), partial(self.column_header_context_handler, action='defaults', column=col)) if self.can_add_columns: self.column_header_context_menu.addAction( QIcon(I('column.png')), _('Add your own columns'), partial(self.column_header_context_handler, action='addcustcol', column=col)) self.column_header_context_menu.popup(self.column_header.mapToGlobal(pos)) # }}} # Sorting {{{ def about_to_be_sorted(self, idc): selected_rows = [r.row() for r in self.selectionModel().selectedRows()] self.selected_ids = [idc(r) for r in selected_rows] def sorting_done(self, indexc): pos = self.horizontalScrollBar().value() self.select_rows(self.selected_ids, using_ids=True, change_current=True, scroll=True) self.selected_ids = [] self.horizontalScrollBar().setValue(pos) def sort_by_named_field(self, field, order, reset=True): if field in self.column_map: idx = self.column_map.index(field) if order: self.sortByColumn(idx, Qt.AscendingOrder) else: self.sortByColumn(idx, Qt.DescendingOrder) else: self._model.sort_by_named_field(field, order, reset) def multisort(self, fields, reset=True, only_if_different=False): if len(fields) == 0: return sh = self.cleanup_sort_history(self._model.sort_history, ignore_column_map=True) if only_if_different and len(sh) >= len(fields): ret=True for i,t in enumerate(fields): if t[0] != sh[i][0]: ret = False break if ret: return for n,d in reversed(fields): if n in self._model.db.field_metadata.keys(): sh.insert(0, (n, d)) sh = self.cleanup_sort_history(sh, ignore_column_map=True) self._model.sort_history = [tuple(x) for x in sh] self._model.resort(reset=reset) col = fields[0][0] dir = Qt.AscendingOrder if fields[0][1] else Qt.DescendingOrder if col in self.column_map: col = self.column_map.index(col) hdrs = self.horizontalHeader() try: hdrs.setSortIndicator(col, dir) except: pass # }}} # Ondevice column {{{ def set_ondevice_column_visibility(self): m = self._model self.column_header.setSectionHidden(m.column_map.index('ondevice'), not m.device_connected) def set_device_connected(self, is_connected): self._model.set_device_connected(is_connected) self.set_ondevice_column_visibility() # }}} # Save/Restore State {{{ def get_state(self): h = self.column_header cm = self.column_map state = {} state['hidden_columns'] = [cm[i] for i in range(h.count()) if h.isSectionHidden(i) and cm[i] != 'ondevice'] state['last_modified_injected'] = True state['languages_injected'] = True state['sort_history'] = \ self.cleanup_sort_history(self.model().sort_history) state['column_positions'] = {} state['column_sizes'] = {} state['column_alignment'] = self._model.alignment_map for i in range(h.count()): name = cm[i] state['column_positions'][name] = h.visualIndex(i) if name != 'ondevice': state['column_sizes'][name] = h.sectionSize(i) return state def write_state(self, state): db = getattr(self.model(), 'db', None) name = unicode(self.objectName()) if name and db is not None: db.prefs.set(name + ' books view state', state) def save_state(self): # Only save if we have been initialized (set_database called) if len(self.column_map) > 0 and self.was_restored: state = self.get_state() self.write_state(state) def cleanup_sort_history(self, sort_history, ignore_column_map=False): history = [] for col, order in sort_history: if not isinstance(order, bool): continue if col == 'date': col = 'timestamp' if ignore_column_map or col in self.column_map: if (not history or history[-1][0] != col): history.append([col, order]) return history def apply_sort_history(self, saved_history, max_sort_levels=3): if not saved_history: return for col, order in reversed(self.cleanup_sort_history( saved_history)[:max_sort_levels]): self.sortByColumn(self.column_map.index(col), Qt.AscendingOrder if order else Qt.DescendingOrder) def apply_state(self, state, max_sort_levels=3): h = self.column_header cmap = {} hidden = state.get('hidden_columns', []) for i, c in enumerate(self.column_map): cmap[c] = i if c != 'ondevice': h.setSectionHidden(i, c in hidden) positions = state.get('column_positions', {}) pmap = {} for col, pos in positions.items(): if col in cmap: pmap[pos] = col for pos in sorted(pmap.keys()): col = pmap[pos] idx = cmap[col] current_pos = h.visualIndex(idx) if current_pos != pos: h.moveSection(current_pos, pos) sizes = state.get('column_sizes', {}) for col, size in sizes.items(): if col in cmap: sz = sizes[col] if sz < 3: sz = h.sectionSizeHint(cmap[col]) h.resizeSection(cmap[col], sz) self.apply_sort_history(state.get('sort_history', None), max_sort_levels=max_sort_levels) for col, alignment in state.get('column_alignment', {}).items(): self._model.change_alignment(col, alignment) for i in range(h.count()): if not h.isSectionHidden(i) and h.sectionSize(i) < 3: sz = h.sectionSizeHint(i) h.resizeSection(i, sz) def get_default_state(self): old_state = { 'hidden_columns': ['last_modified', 'languages'], 'sort_history':[DEFAULT_SORT], 'column_positions': {}, 'column_sizes': {}, 'column_alignment': { 'size':'center', 'timestamp':'center', 'pubdate':'center'}, 'last_modified_injected': True, 'languages_injected': True, } h = self.column_header cm = self.column_map for i in range(h.count()): name = cm[i] old_state['column_positions'][name] = i if name != 'ondevice': old_state['column_sizes'][name] = \ min(350, max(self.sizeHintForColumn(i), h.sectionSizeHint(i))) if name in ('timestamp', 'last_modified'): old_state['column_sizes'][name] += 12 return old_state def get_old_state(self): ans = None name = unicode(self.objectName()) if name: name += ' books view state' db = getattr(self.model(), 'db', None) if db is not None: ans = db.prefs.get(name, None) if ans is None: ans = gprefs.get(name, None) try: del gprefs[name] except: pass if ans is not None: db.prefs[name] = ans else: injected = False if not ans.get('last_modified_injected', False): injected = True ans['last_modified_injected'] = True hc = ans.get('hidden_columns', []) if 'last_modified' not in hc: hc.append('last_modified') if not ans.get('languages_injected', False): injected = True ans['languages_injected'] = True hc = ans.get('hidden_columns', []) if 'languages' not in hc: hc.append('languages') if injected: db.prefs[name] = ans return ans def restore_state(self): old_state = self.get_old_state() if old_state is None: old_state = self.get_default_state() max_levels = 3 if tweaks['sort_columns_at_startup'] is not None: sh = [] try: for c,d in tweaks['sort_columns_at_startup']: if not isinstance(d, bool): d = True if d == 0 else False sh.append((c, d)) except: # Ignore invalid tweak values as users seem to often get them # wrong import traceback traceback.print_exc() old_state['sort_history'] = sh max_levels = max(3, len(sh)) self.column_header.blockSignals(True) self.apply_state(old_state, max_sort_levels=max_levels) self.column_header.blockSignals(False) # Resize all rows to have the correct height if self.model().rowCount(QModelIndex()) > 0: self.resizeRowToContents(0) self.verticalHeader().setDefaultSectionSize(self.rowHeight(0)) self.was_restored = True def resize_column_to_fit(self, column): col = self.column_map.index(column) self.column_resized(col, self.columnWidth(col), self.columnWidth(col)) def column_resized(self, col, old_size, new_size): # arbitrary: scroll bar + header + some max_width = self.width() - (self.verticalScrollBar().width() + self.verticalHeader().width() + 10) if max_width < 200: max_width = 200 if new_size > max_width: self.column_header.blockSignals(True) self.setColumnWidth(col, max_width) self.column_header.blockSignals(False) # }}} # Initialization/Delegate Setup {{{ def set_database(self, db): self.save_state() self._model.set_database(db) self.tags_delegate.set_database(db) self.cc_names_delegate.set_database(db) self.authors_delegate.set_database(db) self.series_delegate.set_auto_complete_function(db.all_series) self.publisher_delegate.set_auto_complete_function(db.all_publishers) def database_changed(self, db): for i in range(self.model().columnCount(None)): if self.itemDelegateForColumn(i) in (self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate, self.last_modified_delegate, self.languages_delegate): self.setItemDelegateForColumn(i, self.itemDelegate()) cm = self.column_map for colhead in cm: if self._model.is_custom_column(colhead): cc = self._model.custom_columns[colhead] if cc['datatype'] == 'datetime': delegate = CcDateDelegate(self) delegate.set_format(cc['display'].get('date_format','')) self.setItemDelegateForColumn(cm.index(colhead), delegate) elif cc['datatype'] == 'comments': self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate) elif cc['datatype'] == 'text': if cc['is_multiple']: if cc['display'].get('is_names', False): self.setItemDelegateForColumn(cm.index(colhead), self.cc_names_delegate) else: self.setItemDelegateForColumn(cm.index(colhead), self.tags_delegate) else: self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] == 'series': self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) elif cc['datatype'] in ('int', 'float'): self.setItemDelegateForColumn(cm.index(colhead), self.cc_number_delegate) elif cc['datatype'] == 'bool': self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate) elif cc['datatype'] == 'rating': self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate) elif cc['datatype'] == 'composite': self.setItemDelegateForColumn(cm.index(colhead), self.cc_template_delegate) elif cc['datatype'] == 'enumeration': self.setItemDelegateForColumn(cm.index(colhead), self.cc_enum_delegate) else: dattr = colhead+'_delegate' delegate = colhead if hasattr(self, dattr) else 'text' self.setItemDelegateForColumn(cm.index(colhead), getattr(self, delegate+'_delegate')) self.restore_state() self.set_ondevice_column_visibility() #}}} # Context Menu {{{ def set_context_menu(self, menu, edit_collections_action): self.setContextMenuPolicy(Qt.DefaultContextMenu) self.context_menu = menu self.edit_collections_action = edit_collections_action def contextMenuEvent(self, event): self.context_menu.popup(event.globalPos()) event.accept() # }}} # Drag 'n Drop {{{ @classmethod def paths_from_event(cls, event): ''' Accept a drop event and return a list of paths that can be read from and represent files with extensions. ''' md = event.mimeData() if md.hasFormat('text/uri-list') and not \ md.hasFormat('application/calibre+from_library'): urls = [unicode(u.toLocalFile()) for u in md.urls()] return [u for u in urls if os.path.splitext(u)[1] and os.path.exists(u)] def drag_icon(self, cover, multiple): cover = cover.scaledToHeight(120, Qt.SmoothTransformation) if multiple: base_width = cover.width() base_height = cover.height() base = QImage(base_width+21, base_height+21, QImage.Format_ARGB32_Premultiplied) base.fill(QColor(255, 255, 255, 0).rgba()) p = QPainter(base) rect = QRect(20, 0, base_width, base_height) p.fillRect(rect, QColor('white')) p.drawRect(rect) rect.moveLeft(10) rect.moveTop(10) p.fillRect(rect, QColor('white')) p.drawRect(rect) rect.moveLeft(0) rect.moveTop(20) p.fillRect(rect, QColor('white')) p.save() p.setCompositionMode(p.CompositionMode_SourceAtop) p.drawImage(rect.topLeft(), cover) p.restore() p.drawRect(rect) p.end() cover = base return QPixmap.fromImage(cover) def drag_data(self): m = self.model() db = m.db rows = self.selectionModel().selectedRows() selected = list(map(m.id, rows)) ids = ' '.join(map(str, selected)) md = QMimeData() md.setData('application/calibre+from_library', ids) fmt = prefs['output_format'] def url_for_id(i): try: ans = db.format_path(i, fmt, index_is_id=True) except: ans = None if ans is None: fmts = db.formats(i, index_is_id=True) if fmts: fmts = fmts.split(',') else: fmts = [] for f in fmts: try: ans = db.format_path(i, f, index_is_id=True) except: ans = None if ans is None: ans = db.abspath(i, index_is_id=True) return QUrl.fromLocalFile(ans) md.setUrls([url_for_id(i) for i in selected]) drag = QDrag(self) col = self.selectionModel().currentIndex().column() md.column_name = self.column_map[col] drag.setMimeData(md) cover = self.drag_icon(m.cover(self.currentIndex().row()), len(selected) > 1) drag.setHotSpot(QPoint(-15, -15)) drag.setPixmap(cover) return drag def event_has_mods(self, event=None): mods = event.modifiers() if event is not None else \ QApplication.keyboardModifiers() return mods & Qt.ControlModifier or mods & Qt.ShiftModifier def mousePressEvent(self, event): ep = event.pos() if self.indexAt(ep) in self.selectionModel().selectedIndexes() and \ event.button() == Qt.LeftButton and not self.event_has_mods(): self.drag_start_pos = ep return QTableView.mousePressEvent(self, event) def mouseMoveEvent(self, event): if not self.drag_allowed: return if self.drag_start_pos is None: return QTableView.mouseMoveEvent(self, event) if self.event_has_mods(): self.drag_start_pos = None return if not (event.buttons() & Qt.LeftButton) or \ (event.pos() - self.drag_start_pos).manhattanLength() \ < QApplication.startDragDistance(): return index = self.indexAt(event.pos()) if not index.isValid(): return drag = self.drag_data() drag.exec_(Qt.CopyAction) self.drag_start_pos = None def dragEnterEvent(self, event): if int(event.possibleActions() & Qt.CopyAction) + \ int(event.possibleActions() & Qt.MoveAction) == 0: return paths = self.paths_from_event(event) if paths: event.acceptProposedAction() def dragMoveEvent(self, event): event.acceptProposedAction() def dropEvent(self, event): paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) event.accept() self.files_dropped.emit(paths) # }}} @property def column_map(self): return self._model.column_map def refresh_book_details(self): idx = self.currentIndex() if idx.isValid(): self._model.current_changed(idx, idx) def scrollContentsBy(self, dx, dy): # Needed as Qt bug causes headerview to not always update when scrolling QTableView.scrollContentsBy(self, dx, dy) if dy != 0: self.column_header.update() def scroll_to_row(self, row): if row > -1: h = self.horizontalHeader() for i in range(h.count()): if not h.isSectionHidden(i) and h.sectionViewportPosition(i) >= 0: self.scrollTo(self.model().index(row, i), self.PositionAtCenter) break def set_current_row(self, row, select=True): if row > -1 and row < self.model().rowCount(QModelIndex()): h = self.horizontalHeader() logical_indices = list(range(h.count())) logical_indices = [x for x in logical_indices if not h.isSectionHidden(x)] pairs = [(x, h.visualIndex(x)) for x in logical_indices if h.visualIndex(x) > -1] if not pairs: pairs = [(0, 0)] pairs.sort(cmp=lambda x,y:cmp(x[1], y[1])) i = pairs[0][0] index = self.model().index(row, i) self.setCurrentIndex(index) if select: sm = self.selectionModel() sm.select(index, sm.ClearAndSelect|sm.Rows) def select_rows(self, identifiers, using_ids=True, change_current=True, scroll=True): ''' Select rows identified by identifiers. identifiers can be a set of ids, row numbers or QModelIndexes. ''' rows = set([x.row() if hasattr(x, 'row') else x for x in identifiers]) if using_ids: rows = set([]) identifiers = set(identifiers) m = self.model() for row in xrange(m.rowCount(QModelIndex())): if m.id(row) in identifiers: rows.add(row) rows = list(sorted(rows)) if rows: row = rows[0] if change_current: self.set_current_row(row, select=False) if scroll: self.scroll_to_row(row) sm = self.selectionModel() sel = QItemSelection() m = self.model() max_col = m.columnCount(QModelIndex()) - 1 # Create a range based selector for each set of contiguous rows # as supplying selectors for each individual row causes very poor # performance if a large number of rows has to be selected. for k, g in itertools.groupby(enumerate(rows), lambda (i,x):i-x): group = list(map(operator.itemgetter(1), g)) sel.merge(QItemSelection(m.index(min(group), 0), m.index(max(group), max_col)), sm.Select) sm.select(sel, sm.ClearAndSelect)
def show_context_menu(self, point): item = self.itemAt(point) if item is None or item in set(self.categories.itervalues()): return m = QMenu(self) sel = self.selectedItems() num = len(sel) container = current_container() ci = self.currentItem() if ci is not None: cn = unicode(ci.data(0, NAME_ROLE).toString()) mt = unicode(ci.data(0, MIME_ROLE).toString()) cat = unicode(ci.data(0, CATEGORY_ROLE).toString()) n = elided_text(cn.rpartition('/')[-1]) m.addAction(QIcon(I('save.png')), _('Export %s') % n, partial(self.export, cn)) if cn not in container.names_that_must_not_be_changed and cn not in container.names_that_must_not_be_removed and mt not in OEB_FONTS: m.addAction( _('Replace %s with file...') % n, partial(self.replace, cn)) m.addSeparator() m.addAction(QIcon(I('modified.png')), _('&Rename %s') % n, self.edit_current_item) if is_raster_image(mt): m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover image') % n, partial(self.mark_as_cover, cn)) elif current_container( ).SUPPORTS_TITLEPAGES and mt in OEB_DOCS and cat == 'text': m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover page') % n, partial(self.mark_as_titlepage, cn)) m.addSeparator() if num > 0: m.addSeparator() if num > 1: m.addAction(QIcon(I('modified.png')), _('&Bulk rename selected files'), self.request_bulk_rename) m.addAction(QIcon(I('trash.png')), _('&Delete the %d selected file(s)') % num, self.request_delete) m.addSeparator() selected_map = defaultdict(list) for item in sel: selected_map[unicode(item.data( 0, CATEGORY_ROLE).toString())].append( unicode(item.data(0, NAME_ROLE).toString())) for items in selected_map.itervalues(): items.sort(key=self.index_of_name) if selected_map['text']: m.addAction(QIcon(I('format-text-color.png')), _('Link &stylesheets...'), partial(self.link_stylesheets, selected_map['text'])) if len(selected_map['text']) > 1: m.addAction( QIcon(I('merge.png')), _('&Merge selected text files'), partial(self.start_merge, 'text', selected_map['text'])) if len(selected_map['styles']) > 1: m.addAction( QIcon(I('merge.png')), _('&Merge selected style files'), partial(self.start_merge, 'styles', selected_map['styles'])) if len(list(m.actions())) > 0: m.popup(self.mapToGlobal(point))
class TagsView(QTreeView): # {{{ refresh_required = pyqtSignal() tags_marked = pyqtSignal(object) edit_user_category = pyqtSignal(object) delete_user_category = pyqtSignal(object) del_item_from_user_cat = pyqtSignal(object, object, object) add_item_to_user_cat = pyqtSignal(object, object, object) add_subcategory = pyqtSignal(object) tags_list_edit = pyqtSignal(object, object) saved_search_edit = pyqtSignal(object) rebuild_saved_searches = pyqtSignal() author_sort_edit = pyqtSignal(object, object, object, object) tag_item_renamed = pyqtSignal() search_item_renamed = pyqtSignal() drag_drop_finished = pyqtSignal(object) restriction_error = pyqtSignal() tag_item_delete = pyqtSignal(object, object, object) def __init__(self, parent=None): QTreeView.__init__(self, parent=None) self.alter_tb = None self.disable_recounting = False self.setUniformRowHeights(True) self.setCursor(Qt.PointingHandCursor) self.setIconSize(QSize(20, 20)) self.setTabKeyNavigation(True) self.setAnimated(True) self.setHeaderHidden(True) self.setItemDelegate(TagDelegate(self)) self.made_connections = False self.setAcceptDrops(True) self.setDragEnabled(True) self.setDragDropMode(self.DragDrop) self.setDropIndicatorShown(True) self.in_drag_drop = False self.setAutoExpandDelay(500) self.pane_is_visible = False self.search_icon = QIcon(I('search.png')) self.user_category_icon = QIcon(I('tb_folder.png')) self.delete_icon = QIcon(I('list_remove.png')) self.rename_icon = QIcon(I('edit-undo.png')) self._model = TagsModel(self) self._model.search_item_renamed.connect(self.search_item_renamed) self._model.refresh_required.connect(self.refresh_required, type=Qt.QueuedConnection) self._model.tag_item_renamed.connect(self.tag_item_renamed) self._model.restriction_error.connect(self.restriction_error) self._model.user_categories_edited.connect(self.user_categories_edited, type=Qt.QueuedConnection) self._model.drag_drop_finished.connect(self.drag_drop_finished) stylish_tb = ''' QTreeView { background-color: palette(window); color: palette(window-text); border: none; } ''' self.setStyleSheet(''' QTreeView::item { border: 1px solid transparent; padding-top:0.9ex; padding-bottom:0.9ex; } 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; } ''' + ('' if gprefs['tag_browser_old_look'] else stylish_tb)) if gprefs['tag_browser_old_look']: self.setAlternatingRowColors(True) @property def hidden_categories(self): return self._model.hidden_categories @property def db(self): return self._model.db @property def collapse_model(self): return self._model.collapse_model def set_pane_is_visible(self, to_what): pv = self.pane_is_visible self.pane_is_visible = to_what if to_what and not pv: self.recount() def get_state(self): state_map = {} expanded_categories = [] for row, category in enumerate(self._model.category_nodes): if self.isExpanded(self._model.index(row, 0, QModelIndex())): expanded_categories.append(category.category_key) states = [c.tag.state for c in category.child_tags()] names = [(c.tag.name, c.tag.category) for c in category.child_tags()] state_map[category.category_key] = dict(izip(names, states)) return expanded_categories, state_map def reread_collapse_parameters(self): self._model.reread_collapse_model(self.get_state()[1]) def set_database(self, db, alter_tb): self._model.set_database(db) self.alter_tb = alter_tb self.pane_is_visible = True # because TagsModel.set_database did a recount self.setModel(self._model) self.setContextMenuPolicy(Qt.CustomContextMenu) pop = self.db.CATEGORY_SORTS.index(config['sort_tags_by']) self.alter_tb.sort_menu.actions()[pop].setChecked(True) try: match_pop = self.db.MATCH_TYPE.index(config['match_tags_type']) except ValueError: match_pop = 0 self.alter_tb.match_menu.actions()[match_pop].setChecked(True) if not self.made_connections: self.clicked.connect(self.toggle) self.customContextMenuRequested.connect(self.show_context_menu) self.refresh_required.connect(self.recount, type=Qt.QueuedConnection) self.alter_tb.sort_menu.triggered.connect(self.sort_changed) self.alter_tb.match_menu.triggered.connect(self.match_changed) self.made_connections = True self.refresh_signal_processed = True db.add_listener(self.database_changed) self.expanded.connect(self.item_expanded) def database_changed(self, event, ids): if self.refresh_signal_processed: self.refresh_signal_processed = False self.refresh_required.emit() def user_categories_edited(self, user_cats, nkey): state_map = self.get_state()[1] self.db.prefs.set('user_categories', user_cats) self._model.rebuild_node_tree(state_map=state_map) p = self._model.find_category_node('@' + nkey) self.show_item_at_path(p) @property def match_all(self): return (self.alter_tb and self.alter_tb.match_menu.actions()[1].isChecked()) def sort_changed(self, action): for i, ac in enumerate(self.alter_tb.sort_menu.actions()): if ac is action: config.set('sort_tags_by', self.db.CATEGORY_SORTS[i]) self.recount() break def match_changed(self, action): try: for i, ac in enumerate(self.alter_tb.match_menu.actions()): if ac is action: config.set('match_tags_type', self.db.MATCH_TYPE[i]) except: pass def mouseMoveEvent(self, event): dex = self.indexAt(event.pos()) if self.in_drag_drop or not dex.isValid(): QTreeView.mouseMoveEvent(self, event) return # Must deal with odd case where the node being dragged is 'virtual', # created to form a hierarchy. We can't really drag this node, but in # addition we can't allow drag recognition to notice going over some # other node and grabbing that one. So we set in_drag_drop to prevent # this from happening, turning it off when the user lifts the button. self.in_drag_drop = True if not self._model.flags(dex) & Qt.ItemIsDragEnabled: QTreeView.mouseMoveEvent(self, event) return md = self._model.mimeData([dex]) pixmap = dex.data(Qt.DecorationRole).toPyObject().pixmap(25, 25) drag = QDrag(self) drag.setPixmap(pixmap) drag.setMimeData(md) if self._model.is_in_user_category(dex): drag.exec_(Qt.CopyAction | Qt.MoveAction, Qt.CopyAction) else: drag.exec_(Qt.CopyAction) def mouseReleaseEvent(self, event): # Swallow everything except leftButton so context menus work correctly if event.button() == Qt.LeftButton or self.in_drag_drop: QTreeView.mouseReleaseEvent(self, event) self.in_drag_drop = False def mouseDoubleClickEvent(self, event): # swallow these to avoid toggling and editing at the same time pass @property def search_string(self): tokens = self._model.tokens() joiner = ' and ' if self.match_all else ' or ' return joiner.join(tokens) def toggle(self, index): self._toggle(index, None) def _toggle(self, index, set_to): ''' set_to: if None, advance the state. Otherwise must be one of the values in TAG_SEARCH_STATES ''' modifiers = int(QApplication.keyboardModifiers()) exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) if self._model.toggle(index, exclusive, set_to=set_to): self.tags_marked.emit(self.search_string) def conditional_clear(self, search_string): if search_string != self.search_string: self.clear() def context_menu_handler(self, action=None, category=None, key=None, index=None, search_state=None): if not action: return try: if action == 'set_icon': try: path = choose_files(self, 'choose_category_icon', _('Change Icon for: %s') % key, filters=[ ('Images', ['png', 'gif', 'jpg', 'jpeg']) ], all_files=False, select_only_single_file=True) if path: path = path[0] p = QIcon(path).pixmap(QSize(128, 128)) d = os.path.join(config_dir, 'tb_icons') if not os.path.exists(d): os.makedirs(d) with open( os.path.join( d, 'icon_' + sanitize_file_name_unicode(key) + '.png'), 'wb') as f: f.write(pixmap_to_data(p, format='PNG')) path = os.path.basename(f.name) self._model.set_custom_category_icon( key, unicode(path)) self.recount() except: import traceback traceback.print_exc() return if action == 'clear_icon': self._model.set_custom_category_icon(key, None) self.recount() return if action == 'edit_item': self.edit(index) return if action == 'delete_item': self.tag_item_delete.emit(key, index.id, index.original_name) return if action == 'open_editor': self.tags_list_edit.emit(category, key) return if action == 'manage_categories': self.edit_user_category.emit(category) return if action == 'search': self._toggle(index, set_to=search_state) return if action == 'add_to_category': tag = index.tag if len(index.children) > 0: for c in index.all_children(): self.add_item_to_user_cat.emit(category, c.tag.original_name, c.tag.category) self.add_item_to_user_cat.emit(category, tag.original_name, tag.category) return if action == 'add_subcategory': self.add_subcategory.emit(key) return if action == 'search_category': self._toggle(index, set_to=search_state) return if action == 'delete_user_category': self.delete_user_category.emit(key) return if action == 'delete_search': saved_searches().delete(key) self.rebuild_saved_searches.emit() return if action == 'delete_item_from_user_category': tag = index.tag if len(index.children) > 0: for c in index.children: self.del_item_from_user_cat.emit( key, c.tag.original_name, c.tag.category) self.del_item_from_user_cat.emit(key, tag.original_name, tag.category) return if action == 'manage_searches': self.saved_search_edit.emit(category) return if action == 'edit_author_sort': self.author_sort_edit.emit(self, index, True, False) return if action == 'edit_author_link': self.author_sort_edit.emit(self, index, False, True) return reset_filter_categories = True if action == 'hide': self.hidden_categories.add(category) elif action == 'show': self.hidden_categories.discard(category) elif action == 'categorization': changed = self.collapse_model != category self._model.collapse_model = category if changed: reset_filter_categories = False gprefs['tags_browser_partition_method'] = category elif action == 'defaults': self.hidden_categories.clear() self.db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) if reset_filter_categories: self._model.set_categories_filter(None) self._model.rebuild_node_tree() except: return def show_context_menu(self, point): def display_name(tag): if tag.category == 'search': n = tag.name if len(n) > 45: n = n[:45] + '...' return "'" + n + "'" return tag.name index = self.indexAt(point) self.context_menu = QMenu(self) if index.isValid(): item = index.data(Qt.UserRole).toPyObject() tag = None if item.type == TagTreeItem.TAG: tag_item = item tag = item.tag while item.type != TagTreeItem.CATEGORY: item = item.parent if item.type == TagTreeItem.CATEGORY: if not item.category_key.startswith('@'): while item.parent != self._model.root_item: item = item.parent category = unicode(item.name.toString()) key = item.category_key # Verify that we are working with a field that we know something about if key not in self.db.field_metadata: return True # Did the user click on a leaf node? if tag: # If the user right-clicked on an editable item, then offer # the possibility of renaming that item. if tag.is_editable: # Add the 'rename' items self.context_menu.addAction( self.rename_icon, _('Rename %s') % display_name(tag), partial(self.context_menu_handler, action='edit_item', index=index)) if key in ('tags', 'series', 'publisher') or \ self._model.db.field_metadata.is_custom_field(key): self.context_menu.addAction( self.delete_icon, _('Delete %s') % display_name(tag), partial(self.context_menu_handler, action='delete_item', key=key, index=tag)) if key == 'authors': self.context_menu.addAction( _('Edit sort for %s') % display_name(tag), partial(self.context_menu_handler, action='edit_author_sort', index=tag.id)) self.context_menu.addAction( _('Edit link for %s') % display_name(tag), partial(self.context_menu_handler, action='edit_author_link', index=tag.id)) # is_editable is also overloaded to mean 'can be added # to a user category' m = self.context_menu.addMenu( self.user_category_icon, _('Add %s to user category') % display_name(tag)) nt = self.model().category_node_tree def add_node_tree(tree_dict, m, path): p = path[:] for k in sorted(tree_dict.keys(), key=sort_key): p.append(k) n = k[1:] if k.startswith('@') else k m.addAction( self.user_category_icon, n, partial(self.context_menu_handler, 'add_to_category', category='.'.join(p), index=tag_item)) if len(tree_dict[k]): tm = m.addMenu(self.user_category_icon, _('Children of %s') % n) add_node_tree(tree_dict[k], tm, p) p.pop() add_node_tree(nt, m, []) elif key == 'search' and tag.is_searchable: self.context_menu.addAction( self.rename_icon, _('Rename %s') % display_name(tag), partial(self.context_menu_handler, action='edit_item', index=index)) self.context_menu.addAction( self.delete_icon, _('Delete search %s') % display_name(tag), partial(self.context_menu_handler, action='delete_search', key=tag.original_name)) if key.startswith('@') and not item.is_gst: self.context_menu.addAction( self.user_category_icon, _('Remove %(item)s from category %(cat)s') % dict(item=display_name(tag), cat=item.py_name), partial(self.context_menu_handler, action='delete_item_from_user_category', key=key, index=tag_item)) if tag.is_searchable: # Add the search for value items. All leaf nodes are searchable self.context_menu.addAction( self.search_icon, _('Search for %s') % display_name(tag), partial( self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_plus'], index=index)) self.context_menu.addAction( self.search_icon, _('Search for everything but %s') % display_name(tag), partial( self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() elif key.startswith('@') and not item.is_gst: if item.can_be_edited: self.context_menu.addAction( self.rename_icon, _('Rename %s') % item.py_name, partial(self.context_menu_handler, action='edit_item', index=index)) self.context_menu.addAction( self.user_category_icon, _('Add sub-category to %s') % item.py_name, partial(self.context_menu_handler, action='add_subcategory', key=key)) self.context_menu.addAction( self.delete_icon, _('Delete user category %s') % item.py_name, partial(self.context_menu_handler, action='delete_user_category', key=key)) self.context_menu.addSeparator() # Hide/Show/Restore categories self.context_menu.addAction( _('Hide category %s') % category, partial(self.context_menu_handler, action='hide', category=key)) if self.hidden_categories: m = self.context_menu.addMenu(_('Show category')) for col in sorted(self.hidden_categories, key=lambda x: sort_key( self.db.field_metadata[x]['name'])): m.addAction( self.db.field_metadata[col]['name'], partial(self.context_menu_handler, action='show', category=col)) # search by category. Some categories are not searchable, such # as search and news if item.tag.is_searchable: self.context_menu.addAction( self.search_icon, _('Search for books in category %s') % category, partial(self.context_menu_handler, action='search_category', index=self._model.createIndex( item.row(), 0, item), search_state=TAG_SEARCH_STATES['mark_plus'])) self.context_menu.addAction( self.search_icon, _('Search for books not in category %s') % category, partial(self.context_menu_handler, action='search_category', index=self._model.createIndex( item.row(), 0, item), search_state=TAG_SEARCH_STATES['mark_minus'])) # Offer specific editors for tags/series/publishers/saved searches self.context_menu.addSeparator() if key in ['tags', 'publisher', 'series'] or \ (self.db.field_metadata[key]['is_custom'] and self.db.field_metadata[key]['datatype'] != 'composite'): self.context_menu.addAction( _('Manage %s') % category, partial(self.context_menu_handler, action='open_editor', category=tag.original_name if tag else None, key=key)) elif key == 'authors': self.context_menu.addAction( _('Manage %s') % category, partial(self.context_menu_handler, action='edit_author_sort')) elif key == 'search': self.context_menu.addAction( _('Manage Saved Searches'), partial(self.context_menu_handler, action='manage_searches', category=tag.name if tag else None)) self.context_menu.addSeparator() self.context_menu.addAction( _('Change category icon'), partial(self.context_menu_handler, action='set_icon', key=key)) self.context_menu.addAction( _('Restore default icon'), partial(self.context_menu_handler, action='clear_icon', key=key)) # Always show the user categories editor self.context_menu.addSeparator() if key.startswith('@') and \ key[1:] in self.db.prefs.get('user_categories', {}).keys(): self.context_menu.addAction( _('Manage User Categories'), partial(self.context_menu_handler, action='manage_categories', category=key[1:])) else: self.context_menu.addAction( _('Manage User Categories'), partial(self.context_menu_handler, action='manage_categories', category=None)) if self.hidden_categories: if not self.context_menu.isEmpty(): self.context_menu.addSeparator() self.context_menu.addAction( _('Show all categories'), partial(self.context_menu_handler, action='defaults')) m = self.context_menu.addMenu(_('Change sub-categorization scheme')) da = m.addAction( _('Disable'), partial(self.context_menu_handler, action='categorization', category='disable')) fla = m.addAction( _('By first letter'), partial(self.context_menu_handler, action='categorization', category='first letter')) pa = m.addAction( _('Partition'), partial(self.context_menu_handler, action='categorization', category='partition')) if self.collapse_model == 'disable': da.setCheckable(True) da.setChecked(True) elif self.collapse_model == 'first letter': fla.setCheckable(True) fla.setChecked(True) else: pa.setCheckable(True) pa.setChecked(True) if config['sort_tags_by'] != "name": fla.setEnabled(False) m.hovered.connect(self.collapse_menu_hovered) fla.setToolTip( _('First letter is usable only when sorting by name')) # Apparently one cannot set a tooltip to empty, so use a star and # deal with it in the hover method da.setToolTip('*') pa.setToolTip('*') if not self.context_menu.isEmpty(): self.context_menu.popup(self.mapToGlobal(point)) return True def collapse_menu_hovered(self, action): tip = action.toolTip() if tip == '*': tip = '' QToolTip.showText(QCursor.pos(), tip) def dragMoveEvent(self, event): QTreeView.dragMoveEvent(self, event) self.setDropIndicatorShown(False) index = self.indexAt(event.pos()) if not index.isValid(): return src_is_tb = event.mimeData().hasFormat( 'application/calibre+from_tag_browser') item = index.data(Qt.UserRole).toPyObject() if item.type == TagTreeItem.ROOT: return flags = self._model.flags(index) if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: self.setDropIndicatorShown(not src_is_tb) return if item.type == TagTreeItem.CATEGORY and not item.is_gst: fm_dest = self.db.metadata_for_field(item.category_key) if fm_dest['kind'] == 'user': if src_is_tb: if event.dropAction() == Qt.MoveAction: data = str(event.mimeData().data( 'application/calibre+from_tag_browser')) src = cPickle.loads(data) for s in src: if s[0] == TagTreeItem.TAG and \ (not s[1].startswith('@') or s[2]): return self.setDropIndicatorShown(True) return md = event.mimeData() if hasattr(md, 'column_name'): fm_src = self.db.metadata_for_field(md.column_name) if md.column_name in ['authors', 'publisher', 'series'] or \ (fm_src['is_custom'] and ( (fm_src['datatype'] in ['series', 'text', 'enumeration'] and not fm_src['is_multiple']) or (fm_src['datatype'] == 'composite' and fm_src['display'].get('make_category', False)))): self.setDropIndicatorShown(True) def clear(self): if self.model(): self.model().clear_state() def is_visible(self, idx): item = idx.data(Qt.UserRole).toPyObject() if getattr(item, 'type', None) == TagTreeItem.TAG: idx = idx.parent() return self.isExpanded(idx) def recount(self, *args): ''' Rebuild the category tree, expand any categories that were expanded, reset the search states, and reselect the current node. ''' if self.disable_recounting or not self.pane_is_visible: return self.refresh_signal_processed = True ci = self.currentIndex() if not ci.isValid(): ci = self.indexAt(QPoint(10, 10)) path = self.model().path_for_index(ci) if self.is_visible(ci) else None expanded_categories, state_map = self.get_state() self._model.rebuild_node_tree(state_map=state_map) self.blockSignals(True) for category in expanded_categories: idx = self._model.index_for_category(category) if idx is not None and idx.isValid(): self.expand(idx) self.show_item_at_path(path) self.blockSignals(False) def show_item_at_path(self, path, box=False, position=QTreeView.PositionAtCenter): ''' Scroll the browser and open categories to show the item referenced by path. If possible, the item is placed in the center. If box=True, a box is drawn around the item. ''' if path: self.show_item_at_index(self._model.index_for_path(path), box=box, position=position) def expand_parent(self, idx): # Needed otherwise Qt sometimes segfaults if the node is buried in a # collapsed, off screen hierarchy. To be safe, we expand from the # outermost in p = self._model.parent(idx) if p.isValid(): self.expand_parent(p) self.expand(idx) def show_item_at_index(self, idx, box=False, position=QTreeView.PositionAtCenter): if idx.isValid() and idx.data( Qt.UserRole).toPyObject() is not self._model.root_item: self.expand_parent(idx) self.setCurrentIndex(idx) self.scrollTo(idx, position) if box: self._model.set_boxed(idx) def item_expanded(self, idx): ''' Called by the expanded signal ''' self.setCurrentIndex(idx)