class View(common.View): def __init__(self, scene): common.View.__init__(self, scene) self.setMouseTracking(True) # fix for not updating position for simulated events self.scene.text_changed.connect(self.viewport().update) # ensure a full redraw self.progress_loaded_timer = QTimer() self.progress_loaded_timer.setInterval(1500) self.progress_loaded_timer.setSingleShot(True) self.progress_loaded_timer.timeout.connect(self.viewport().update) def resizeEvent(self, e): common.View.resizeEvent(self, e) if not self.scene.playtest: self.fit() def fit(self): rect = self.scene.itemsBoundingRect().adjusted(-0.3, -0.3, 0.3, 0.3) self.setSceneRect(rect) self.fitInView(rect, qt.KeepAspectRatio) zoom = self.transform().mapRect(QRectF(0, 0, 1, 1)).width() if zoom > 100: self.resetTransform() self.scale(100, 100) def paintEvent(self, e): common.View.paintEvent(self, e) g = QPainter(self.viewport()) g.setRenderHints(self.renderHints()) area = self.viewport().rect().adjusted(5, 2, -5, -2) if self.progress_loaded_timer.isActive(): g.setPen(QPen(Color.dark_text)) g.drawText(area, qt.AlignTop | qt.AlignLeft, "Progress loaded") try: self._info_font except AttributeError: self._info_font = g.font() multiply_font_size(self._info_font, 3) try: txt = ('{r} ({m})' if self.scene.mistakes else '{r}').format(r=self.scene.remaining, m=self.scene.mistakes) g.setFont(self._info_font) g.setPen(QPen(Color.dark_text)) g.drawText(area, qt.AlignTop | qt.AlignRight, txt) except AttributeError: pass def wheelEvent(self, e): pass
class TagBrowserWidget(QFrame): # {{{ def __init__(self, parent): QFrame.__init__(self, parent) self.setFrameStyle(QFrame.Shape.NoFrame if gprefs['tag_browser_old_look'] else QFrame.Shape.StyledPanel) self._parent = parent self._layout = QVBoxLayout(self) self._layout.setContentsMargins(0,0,0,0) # Set up the find box & button self.tb_bar = tbb = TagBrowserBar(self) tbb.clear_find.connect(self.reset_find) self.alter_tb, self.item_search, self.search_button = tbb.alter_tb, tbb.item_search, tbb.search_button self.toggle_search_button = tbb.toggle_search_button self._layout.addWidget(tbb) self.current_find_position = None self.search_button.clicked.connect(self.find) self.item_search.lineEdit().textEdited.connect(self.find_text_changed) self.item_search.activated[str].connect(self.do_find) # The tags view parent.tags_view = TagsView(parent) self.tags_view = parent.tags_view self._layout.insertWidget(0, parent.tags_view) # Now the floating 'not found' box l = QLabel(self.tags_view) self.not_found_label = l l.setFrameStyle(QFrame.Shape.StyledPanel) l.setAutoFillBackground(True) l.setText('<p><b>'+_('No more matches.</b><p> Click Find again to go to first match')) l.setAlignment(Qt.AlignmentFlag.AlignVCenter) l.setWordWrap(True) l.resize(l.sizeHint()) l.move(10,20) l.setVisible(False) self.not_found_label_timer = QTimer() self.not_found_label_timer.setSingleShot(True) self.not_found_label_timer.timeout.connect(self.not_found_label_timer_event, type=Qt.ConnectionType.QueuedConnection) self.collapse_all_action = ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser collapse all', _('Collapse all'), default_keys=(), action=ac, group=_('Tag browser')) connect_lambda(ac.triggered, self, lambda self: self.tags_view.collapseAll()) # The Configure Tag Browser button l = self.alter_tb ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser alter', _('Configure Tag browser'), default_keys=(), action=ac, group=_('Tag browser')) ac.triggered.connect(l.showMenu) l.m.aboutToShow.connect(self.about_to_show_configure_menu) l.m.show_counts_action = ac = l.m.addAction('counts') ac.triggered.connect(self.toggle_counts) l.m.show_avg_rating_action = ac = l.m.addAction('avg rating') ac.triggered.connect(self.toggle_avg_rating) sb = l.m.addAction(_('Sort by')) sb.m = l.sort_menu = QMenu(l.m) sb.setMenu(sb.m) sb.bg = QActionGroup(sb) # Must be in the same order as db2.CATEGORY_SORTS for i, x in enumerate((_('Name'), _('Number of books'), _('Average rating'))): a = sb.m.addAction(x) sb.bg.addAction(a) a.setCheckable(True) if i == 0: a.setChecked(True) sb.setToolTip( _('Set the sort order for entries in the Tag browser')) sb.setStatusTip(sb.toolTip()) ma = l.m.addAction(_('Search type when selecting multiple items')) ma.m = l.match_menu = QMenu(l.m) ma.setMenu(ma.m) ma.ag = QActionGroup(ma) # Must be in the same order as db2.MATCH_TYPE for i, x in enumerate((_('Match any of the items'), _('Match all of the items'))): a = ma.m.addAction(x) ma.ag.addAction(a) a.setCheckable(True) if i == 0: a.setChecked(True) ma.setToolTip( _('When selecting multiple entries in the Tag browser ' 'match any or all of them')) ma.setStatusTip(ma.toolTip()) mt = l.m.addAction(_('Manage authors, tags, etc.')) mt.setToolTip(_('All of these category_managers are available by right-clicking ' 'on items in the Tag browser above')) mt.m = l.manage_menu = QMenu(l.m) mt.setMenu(mt.m) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser toggle item', _("'Click' found item"), default_keys=(), action=ac, group=_('Tag browser')) ac.triggered.connect(self.toggle_item) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser set focus', _("Give the Tag browser keyboard focus"), default_keys=(), action=ac, group=_('Tag browser')) ac.triggered.connect(self.give_tb_focus) # self.leak_test_timer = QTimer(self) # self.leak_test_timer.timeout.connect(self.test_for_leak) # self.leak_test_timer.start(5000) def about_to_show_configure_menu(self): ac = self.alter_tb.m.show_counts_action ac.setText(_('Hide counts') if gprefs['tag_browser_show_counts'] else _('Show counts')) ac = self.alter_tb.m.show_avg_rating_action ac.setText(_('Hide average rating') if config['show_avg_rating'] else _('Show average rating')) def toggle_counts(self): gprefs['tag_browser_show_counts'] ^= True def toggle_avg_rating(self): config['show_avg_rating'] ^= True def save_state(self): gprefs.set('tag browser search box visible', self.toggle_search_button.isChecked()) def toggle_item(self): self.tags_view.toggle_current_index() def give_tb_focus(self, *args): if gprefs['tag_browser_allow_keyboard_focus']: tb = self.tags_view if tb.hasFocus(): self._parent.shift_esc() elif self._parent.current_view() == self._parent.library_view: tb.setFocus() idx = tb.currentIndex() if not idx.isValid(): idx = tb.model().createIndex(0, 0) tb.setCurrentIndex(idx) def set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) if not to_what: self._parent.shift_esc() def find_text_changed(self, str_): self.current_find_position = None def set_focus_to_find_box(self): self.tb_bar.set_focus_to_find_box() def do_find(self, str_=None): self.current_find_position = None self.find() @property def find_text(self): return str(self.item_search.currentText()).strip() def reset_find(self): model = self.tags_view.model() model.clear_boxed() if model.get_categories_filter(): model.set_categories_filter(None) self.tags_view.recount() self.current_find_position = None def find(self): model = self.tags_view.model() model.clear_boxed() # When a key is specified don't use the auto-collapsing search. # A colon separates the lookup key from the search string. # A leading colon says not to use autocollapsing search but search all keys txt = self.find_text colon = txt.find(':') if colon >= 0: key = self._parent.library_view.model().db.\ field_metadata.search_term_to_field_key(txt[:colon]) if key in self._parent.library_view.model().db.field_metadata: txt = txt[colon+1:] else: key = '' txt = txt[1:] if colon == 0 else txt else: key = None # key is None indicates that no colon was found. # key == '' means either a leading : was found or the key is invalid # At this point the txt might have a leading =, in which case do an # exact match search if (gprefs.get('tag_browser_always_autocollapse', False) and key is None and not txt.startswith('*')): txt = '*' + txt if txt.startswith('*'): self.tags_view.collapseAll() model.set_categories_filter(txt[1:]) self.tags_view.recount() self.current_find_position = None return if model.get_categories_filter(): model.set_categories_filter(None) self.tags_view.recount() self.current_find_position = None if not txt: return self.item_search.lineEdit().blockSignals(True) self.search_button.setFocus(True) self.item_search.lineEdit().blockSignals(False) if txt.startswith('='): equals_match = True txt = txt[1:] else: equals_match = False self.current_find_position = \ model.find_item_node(key, txt, self.current_find_position, equals_match=equals_match) if self.current_find_position: self.tags_view.show_item_at_path(self.current_find_position, box=True) elif self.item_search.text(): self.not_found_label.setVisible(True) if self.tags_view.verticalScrollBar().isVisible(): sbw = self.tags_view.verticalScrollBar().width() else: sbw = 0 width = self.width() - 8 - sbw height = self.not_found_label.heightForWidth(width) + 20 self.not_found_label.resize(width, height) self.not_found_label.move(4, 10) self.not_found_label_timer.start(2000) def not_found_label_timer_event(self): self.not_found_label.setVisible(False) def keyPressEvent(self, ev): if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return) and self.item_search.hasFocus(): self.find() ev.accept() return return QFrame.keyPressEvent(self, ev)
class Splitter(QSplitter): state_changed = pyqtSignal(object) reapply_sizes = pyqtSignal(object) def __init__(self, name, label, icon, initial_show=True, initial_side_size=120, connect_button=True, orientation=Qt.Orientation.Horizontal, side_index=0, parent=None, shortcut=None, hide_handle_on_single_panel=True): QSplitter.__init__(self, parent) self.reapply_sizes.connect(self.setSizes, type=Qt.ConnectionType.QueuedConnection) self.hide_handle_on_single_panel = hide_handle_on_single_panel if hide_handle_on_single_panel: self.state_changed.connect(self.update_handle_width) self.original_handle_width = self.handleWidth() self.resize_timer = QTimer(self) self.resize_timer.setSingleShot(True) self.desired_side_size = initial_side_size self.desired_show = initial_show self.resize_timer.setInterval(5) self.resize_timer.timeout.connect(self.do_resize) self.setOrientation(orientation) self.side_index = side_index self._name = name self.label = label self.initial_side_size = initial_side_size self.initial_show = initial_show self.splitterMoved.connect(self.splitter_moved, type=Qt.ConnectionType.QueuedConnection) self.button = LayoutButton(icon, label, self, shortcut=shortcut) if connect_button: self.button.clicked.connect(self.double_clicked) if shortcut is not None: self.action_toggle = QAction(QIcon(icon), _('Toggle') + ' ' + label, self) self.action_toggle.changed.connect(self.update_shortcut) self.action_toggle.triggered.connect(self.toggle_triggered) if parent is not None: parent.addAction(self.action_toggle) if hasattr(parent, 'keyboard'): parent.keyboard.register_shortcut( 'splitter %s %s' % (name, label), str(self.action_toggle.text()), default_keys=(shortcut, ), action=self.action_toggle) else: self.action_toggle.setShortcut(shortcut) else: self.action_toggle.setShortcut(shortcut) def update_shortcut(self): self.button.update_shortcut(self.action_toggle) def toggle_triggered(self, *args): self.toggle_side_pane() def createHandle(self): return SplitterHandle(self.orientation(), self) def initialize(self): for i in range(self.count()): h = self.handle(i) if h is not None: h.splitter_moved() self.state_changed.emit(not self.is_side_index_hidden) def splitter_moved(self, *args): self.desired_side_size = self.side_index_size self.state_changed.emit(not self.is_side_index_hidden) def update_handle_width(self, not_one_panel): self.setHandleWidth(self.original_handle_width if not_one_panel else 0) @property def is_side_index_hidden(self): sizes = list(self.sizes()) try: return sizes[self.side_index] == 0 except IndexError: return True @property def save_name(self): ori = 'horizontal' if self.orientation() == Qt.Orientation.Horizontal \ else 'vertical' return self._name + '_' + ori def print_sizes(self): if self.count() > 1: print(self.save_name, 'side:', self.side_index_size, 'other:', end=' ') print(list(self.sizes())[self.other_index]) @property def side_index_size(self): if self.count() < 2: return 0 return self.sizes()[self.side_index] @side_index_size.setter def side_index_size(self, val): if self.count() < 2: return side_index_hidden = self.is_side_index_hidden if val == 0 and not side_index_hidden: self.save_state() sizes = list(self.sizes()) for i in range(len(sizes)): sizes[i] = val if i == self.side_index else 10 self.setSizes(sizes) sizes = list(self.sizes()) total = sum(sizes) total_needs_adjustment = self.hide_handle_on_single_panel and side_index_hidden if total_needs_adjustment: total -= self.original_handle_width for i in range(len(sizes)): sizes[i] = val if i == self.side_index else total - val self.setSizes(sizes) self.initialize() if total_needs_adjustment: # the handle visibility and therefore size distribution will change # when the event loop ticks self.reapply_sizes.emit(sizes) def do_resize(self, *args): orig = self.desired_side_size QSplitter.resizeEvent(self, self._resize_ev) if orig > 20 and self.desired_show: c = 0 while abs(self.side_index_size - orig) > 10 and c < 5: self.apply_state(self.get_state(), save_desired=False) c += 1 def resizeEvent(self, ev): if self.resize_timer.isActive(): self.resize_timer.stop() self._resize_ev = ev self.resize_timer.start() def get_state(self): if self.count() < 2: return (False, 200) return (self.desired_show, self.desired_side_size) def apply_state(self, state, save_desired=True): if state[0]: self.side_index_size = state[1] if save_desired: self.desired_side_size = self.side_index_size else: self.side_index_size = 0 self.desired_show = state[0] def default_state(self): return (self.initial_show, self.initial_side_size) # Public API {{{ def update_desired_state(self): self.desired_show = not self.is_side_index_hidden def save_state(self): if self.count() > 1: gprefs[self.save_name + '_state'] = self.get_state() @property def other_index(self): return (self.side_index + 1) % 2 def restore_state(self): if self.count() > 1: state = gprefs.get(self.save_name + '_state', self.default_state()) self.apply_state(state, save_desired=False) self.desired_side_size = state[1] def toggle_side_pane(self, hide=None): if hide is None: action = 'show' if self.is_side_index_hidden else 'hide' else: action = 'hide' if hide else 'show' getattr(self, action + '_side_pane')() def show_side_pane(self): if self.count() < 2 or not self.is_side_index_hidden: return if self.desired_side_size == 0: self.desired_side_size = self.initial_side_size self.apply_state((True, self.desired_side_size)) def hide_side_pane(self): if self.count() < 2 or self.is_side_index_hidden: return self.apply_state((False, self.desired_side_size)) def double_clicked(self, *args): self.toggle_side_pane()
class GridView(QListView): update_item = pyqtSignal(object) files_dropped = pyqtSignal(object) books_dropped = pyqtSignal(object) def __init__(self, parent): QListView.__init__(self, parent) self._ncols = None self.gesture_manager = GestureManager(self) setup_dnd_interface(self) self.setUniformItemSizes(True) self.setWrapping(True) self.setFlow(QListView.Flow.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(QListView.ResizeMode.Batched) self.setResizeMode(QListView.ResizeMode.Adjust) self.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.delegate = CoverDelegate(self) self.delegate.animation.valueChanged.connect( self.animation_value_changed) self.delegate.animation.finished.connect(self.animation_done) self.setItemDelegate(self.delegate) self.setSpacing(self.delegate.spacing) self.set_color() self.ignore_render_requests = Event() dpr = self.device_pixel_ratio self.thumbnail_cache = ThumbnailCache( max_size=gprefs['cover_grid_disk_cache_size'], thumbnail_size=(int(dpr * self.delegate.cover_size.width()), int(dpr * self.delegate.cover_size.height()))) self.render_thread = None self.update_item.connect(self.re_render, type=Qt.ConnectionType.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.CursorShape.PointingHandCursor) self.gui = parent self.context_menu = None self.update_timer = QTimer(self) self.update_timer.setInterval(200) self.update_timer.timeout.connect(self.update_viewport) self.update_timer.setSingleShot(True) self.resize_timer = t = QTimer(self) t.setInterval(200), t.setSingleShot(True) t.timeout.connect(self.update_memory_cover_cache_size) def viewportEvent(self, ev): try: ret = self.gesture_manager.handle_event(ev) except AttributeError: ret = None if ret is not None: return ret return QListView.viewportEvent(self, ev) @property def device_pixel_ratio(self): try: return self.devicePixelRatioF() except AttributeError: return self.devicePixelRatio() @property def first_visible_row(self): geom = self.viewport().geometry() for y in range(geom.top(), (self.spacing() * 2) + geom.top(), 5): for x in range(geom.left(), (self.spacing() * 2) + geom.left(), 5): ans = self.indexAt(QPoint(x, y)).row() if ans > -1: return ans @property def last_visible_row(self): geom = self.viewport().geometry() for y in range(geom.bottom(), geom.bottom() - 2 * self.spacing(), -5): for x in range(geom.left(), (self.spacing() * 2) + geom.left(), 5): ans = self.indexAt(QPoint(x, y)).row() if ans > -1: item_width = self.delegate.item_size.width( ) + 2 * self.spacing() return ans + (geom.width() // item_width) def update_viewport(self): self.ignore_render_requests.clear() self.update_timer.stop() m = self.model() for r in range(self.first_visible_row or 0, self.last_visible_row or (m.count() - 1)): self.update(m.index(r, 0)) def start_view_animation(self, index): d = self.delegate if d.animating is None and not config['disable_animations']: d.animating = index d.animation.start() def double_clicked(self, index): self.start_view_animation(index) if tweaks['doubleclick_on_library_view'] == 'open_viewer': self.gui.iactions['View'].view_triggered(index) elif tweaks['doubleclick_on_library_view'] in { 'edit_metadata', 'edit_cell' }: self.gui.iactions['Edit Metadata'].edit_metadata(False, False) def animation_value_changed(self, value): if self.delegate.animating is not None: self.update(self.delegate.animating) def animation_done(self): if self.delegate.animating is not None: idx = self.delegate.animating self.delegate.animating = None self.update(idx) def set_color(self): r, g, b = gprefs['cover_grid_color'] tex = gprefs['cover_grid_texture'] pal = self.palette() pal.setColor(QPalette.ColorRole.Base, QColor(r, g, b)) self.setPalette(pal) ss = '' if tex: from calibre.gui2.preferences.texture_chooser import texture_path path = texture_path(tex) if path: path = os.path.abspath(path).replace(os.sep, '/') ss += 'background-image: url({});'.format(path) ss += 'background-attachment: fixed;' pm = QPixmap(path) if not pm.isNull(): val = pm.scaled(1, 1).toImage().pixel(0, 0) r, g, b = qRed(val), qGreen(val), qBlue(val) dark = max(r, g, b) < 115 col = '#eee' if dark else '#111' ss += 'color: {};'.format(col) self.delegate.highlight_color = QColor(col) self.setStyleSheet('QListView {{ {} }}'.format(ss)) def refresh_settings(self): size_changed = ( gprefs['cover_grid_width'] != self.delegate.original_width or gprefs['cover_grid_height'] != self.delegate.original_height) if (size_changed or gprefs['cover_grid_show_title'] != self.delegate.original_show_title or gprefs['show_emblems'] != self.delegate.original_show_emblems or gprefs['emblem_size'] != self.delegate.orginal_emblem_size or gprefs['emblem_position'] != self.delegate.orginal_emblem_position): self.delegate.set_dimensions() self.setSpacing(self.delegate.spacing) if size_changed: self.delegate.cover_cache.clear() if gprefs['cover_grid_spacing'] != self.delegate.original_spacing: self.delegate.calculate_spacing() self.setSpacing(self.delegate.spacing) self.set_color() self.set_thumbnail_cache_image_size() cs = gprefs['cover_grid_disk_cache_size'] if (cs * (1024**2)) != self.thumbnail_cache.max_size: self.thumbnail_cache.set_size(cs) self.update_memory_cover_cache_size() def set_thumbnail_cache_image_size(self): dpr = self.device_pixel_ratio self.thumbnail_cache.set_thumbnail_size( int(dpr * self.delegate.cover_size.width()), int(dpr * self.delegate.cover_size.height())) def resizeEvent(self, ev): self._ncols = None self.resize_timer.start() return QListView.resizeEvent(self, ev) def update_memory_cover_cache_size(self): try: sz = self.delegate.item_size except AttributeError: return rows, cols = self.width() // sz.width(), self.height() // sz.height() num = (rows + 1) * (cols + 1) limit = max(100, num * max(2, gprefs['cover_grid_cache_size_multiple'])) if limit != self.delegate.cover_cache.limit: self.delegate.cover_cache.set_limit(limit) def shown(self): self.update_memory_cover_cache_size() if self.render_thread is None: self.thumbnail_cache.set_database(self.gui.current_db) self.render_thread = Thread(target=self.render_covers) self.render_thread.daemon = True self.render_thread.start() def render_covers(self): q = self.delegate.render_queue while True: book_id = q.get() try: if book_id is None: return if self.ignore_render_requests.is_set(): continue try: self.render_cover(book_id) except: import traceback traceback.print_exc() finally: q.task_done() def render_cover(self, book_id): if self.ignore_render_requests.is_set(): return dpr = self.device_pixel_ratio page_width = int(dpr * self.delegate.cover_size.width()) page_height = int(dpr * self.delegate.cover_size.height()) tcdata, timestamp = self.thumbnail_cache[book_id] use_cache = False if timestamp is None: # Not in cache has_cover, cdata, timestamp = self.model( ).db.new_api.cover_or_cache(book_id, 0) else: has_cover, cdata, timestamp = self.model( ).db.new_api.cover_or_cache(book_id, timestamp) if has_cover and cdata is None: # The cached cover is fresh cdata = tcdata use_cache = True if has_cover: p = QImage() p.loadFromData(cdata, CACHE_FORMAT if cdata is tcdata else 'JPEG') p.setDevicePixelRatio(dpr) if p.isNull() and cdata is tcdata: # Invalid image in cache self.thumbnail_cache.invalidate((book_id, )) self.update_item.emit(book_id) return cdata = None if p.isNull() else p if not use_cache: # cache is stale if cdata is not None: width, height = p.width(), p.height() scaled, nwidth, nheight = fit_image( width, height, page_width, page_height) if scaled: if self.ignore_render_requests.is_set(): return p = p.scaled( nwidth, nheight, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) p.setDevicePixelRatio(dpr) cdata = p # update cache if cdata is None: self.thumbnail_cache.invalidate((book_id, )) else: try: self.thumbnail_cache.insert(book_id, timestamp, image_to_data(cdata)) except EncodeError as err: self.thumbnail_cache.invalidate((book_id, )) prints(err) except Exception: import traceback traceback.print_exc() elif tcdata is not None: # Cover was removed, but it exists in cache, remove from cache self.thumbnail_cache.invalidate((book_id, )) self.delegate.cover_cache.set(book_id, cdata) self.update_item.emit(book_id) def re_render(self, book_id): self.delegate.cover_cache.clear_staging() m = self.model() try: index = m.db.row(book_id) except (IndexError, ValueError, KeyError): return self.update(m.index(index, 0)) def shutdown(self): self.ignore_render_requests.set() self.delegate.render_queue.put(None) self.thumbnail_cache.shutdown() def set_database(self, newdb, stage=0): if stage == 0: self.ignore_render_requests.set() try: for x in (self.delegate.cover_cache, self.thumbnail_cache): self.model().db.new_api.remove_cover_cache(x) except AttributeError: pass # db is None for x in (self.delegate.cover_cache, self.thumbnail_cache): newdb.new_api.add_cover_cache(x) try: # Use a timeout so that if, for some reason, the render thread # gets stuck, we dont deadlock, future covers wont get # rendered, but this is better than a deadlock join_with_timeout(self.delegate.render_queue) except RuntimeError: print('Cover rendering thread is stuck!') finally: self.ignore_render_requests.clear() else: self.delegate.cover_cache.clear() def select_rows(self, rows): sel = QItemSelection() sm = self.selectionModel() m = self.model() # 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[0] - i_x[1]): group = list(map(operator.itemgetter(1), g)) sel.merge( QItemSelection(m.index(min(group), 0), m.index(max(group), 0)), QItemSelectionModel.SelectionFlag.Select) sm.select(sel, QItemSelectionModel.SelectionFlag.ClearAndSelect) def selectAll(self): # We re-implement this to ensure that only indexes from column 0 are # selected. The base class implementation selects all columns. This # causes problems with selection syncing, see # https://bugs.launchpad.net/bugs/1236348 m = self.model() sm = self.selectionModel() sel = QItemSelection(m.index(0, 0), m.index(m.rowCount(QModelIndex()) - 1, 0)) sm.select(sel, QItemSelectionModel.SelectionFlag.ClearAndSelect) def set_current_row(self, row): sm = self.selectionModel() sm.setCurrentIndex(self.model().index(row, 0), QItemSelectionModel.SelectionFlag.NoUpdate) def set_context_menu(self, menu): self.context_menu = menu def contextMenuEvent(self, event): if self.context_menu is None: return from calibre.gui2.main_window import clone_menu m = clone_menu(self.context_menu) if islinux else self.context_menu m.popup(event.globalPos()) event.accept() def get_selected_ids(self): m = self.model() return [m.id(i) for i in self.selectionModel().selectedIndexes()] def restore_vpos(self, vpos): self.verticalScrollBar().setValue(vpos) def restore_hpos(self, hpos): pass def handle_mouse_press_event(self, ev): if QApplication.keyboardModifiers( ) & Qt.KeyboardModifier.ShiftModifier: # Shift-Click in QListView is broken. It selects extra items in # various circumstances, for example, click on some item in the # middle of a row then click on an item in the next row, all items # in the first row will be selected instead of only items after the # middle item. index = self.indexAt(ev.pos()) if not index.isValid(): return ci = self.currentIndex() sm = self.selectionModel() sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.NoUpdate) if not ci.isValid(): return if not sm.hasSelection(): sm.select(index, QItemSelectionModel.SelectionFlag.ClearAndSelect) return cr = ci.row() tgt = index.row() top = self.model().index(min(cr, tgt), 0) bottom = self.model().index(max(cr, tgt), 0) sm.select(QItemSelection(top, bottom), QItemSelectionModel.SelectionFlag.Select) else: return QListView.mousePressEvent(self, ev) def indices_for_merge(self, resolved=True): return self.selectionModel().selectedIndexes() def number_of_columns(self): # Number of columns currently visible in the grid if self._ncols is None: dpr = self.device_pixel_ratio width = int(dpr * self.delegate.cover_size.width()) height = int(dpr * self.delegate.cover_size.height()) step = max(10, self.spacing()) for y in range(step, 2 * height, step): for x in range(step, 2 * width, step): i = self.indexAt(QPoint(x, y)) if i.isValid(): for x in range(self.viewport().width() - step, self.viewport().width() - width, -step): j = self.indexAt(QPoint(x, y)) if j.isValid(): self._ncols = j.row() - i.row() + 1 return self._ncols return self._ncols def keyPressEvent(self, ev): if handle_enter_press(self, ev, self.start_view_animation, False): return k = ev.key() if ev.modifiers() & Qt.KeyboardModifier.ShiftModifier and k in ( Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): ci = self.currentIndex() if not ci.isValid(): return c = ci.row() ncols = self.number_of_columns() or 1 delta = { Qt.Key.Key_Left: -1, Qt.Key.Key_Right: 1, Qt.Key.Key_Up: -ncols, Qt.Key.Key_Down: ncols }[k] n = max(0, min(c + delta, self.model().rowCount(None) - 1)) if n == c: return sm = self.selectionModel() rows = {i.row() for i in sm.selectedIndexes()} if rows: mi, ma = min(rows), max(rows) end = mi if c == ma else ma if c == mi else c else: end = c top = self.model().index(min(n, end), 0) bottom = self.model().index(max(n, end), 0) sm.select(QItemSelection(top, bottom), QItemSelectionModel.SelectionFlag.ClearAndSelect) sm.setCurrentIndex(self.model().index(n, 0), QItemSelectionModel.SelectionFlag.NoUpdate) else: return QListView.keyPressEvent(self, ev) @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 def restore_current_book_state(self, state): book_id = state self.setFocus(Qt.FocusReason.OtherFocusReason) try: row = self.model().db.data.id_to_index(book_id) except (IndexError, ValueError, KeyError, TypeError, AttributeError): return self.set_current_row(row) self.select_rows((row, )) self.scrollTo(self.model().index(row, 0), QAbstractItemView.ScrollHint.PositionAtCenter) def marked_changed(self, old_marked, current_marked): changed = old_marked | current_marked m = self.model() for book_id in changed: try: self.update(m.index(m.db.data.id_to_index(book_id), 0)) except ValueError: pass def moveCursor(self, action, modifiers): index = QListView.moveCursor(self, action, modifiers) if action in ( QAbstractItemView.CursorAction.MoveLeft, QAbstractItemView.CursorAction.MoveRight) and index.isValid(): ci = self.currentIndex() if ci.isValid() and index.row() == ci.row(): nr = index.row() + (1 if action == QAbstractItemView. CursorAction.MoveRight else -1) if 0 <= nr < self.model().rowCount(QModelIndex()): index = self.model().index(nr, 0) return index def selectionCommand(self, index, event): if event and event.type() == QEvent.Type.KeyPress and event.key() in ( Qt.Key.Key_Home, Qt.Key.Key_End) and event.modifiers() & Qt.Modifier.CTRL: return QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows return super(GridView, self).selectionCommand(index, event) def wheelEvent(self, ev): if ev.phase() not in (Qt.ScrollPhase.ScrollUpdate, 0, Qt.ScrollPhase.ScrollMomentum): return number_of_pixels = ev.pixelDelta() number_of_degrees = ev.angleDelta() / 8.0 b = self.verticalScrollBar() if number_of_pixels.isNull() or islinux: # pixelDelta() is broken on linux with wheel mice dy = number_of_degrees.y() / 15.0 # Scroll by approximately half a row dy = int(math.ceil((dy) * b.singleStep() / 2.0)) else: dy = number_of_pixels.y() if abs(dy) > 0: b.setValue(b.value() - dy) def paintEvent(self, ev): dpr = self.device_pixel_ratio page_width = int(dpr * self.delegate.cover_size.width()) page_height = int(dpr * self.delegate.cover_size.height()) size_changed = self.thumbnail_cache.set_thumbnail_size( page_width, page_height) if size_changed: self.delegate.cover_cache.clear() return super(GridView, self).paintEvent(ev)
class SearchBox2(QComboBox): # {{{ ''' To use this class: * Call initialize() * Connect to the search() and cleared() signals from this widget. * Connect to the changed() signal to know when the box content changes * Connect to focus_to_library() signal to be told to manually change focus * Call search_done() after every search is complete * Call set_search_string() to perform a search programmatically * You can use the current_text property to get the current search text Be aware that if you are using it in a slot connected to the changed() signal, if the connection is not queued it will not be accurate. ''' INTERVAL = 1500 #: Time to wait before emitting search signal MAX_COUNT = 25 search = pyqtSignal(object) cleared = pyqtSignal() changed = pyqtSignal() focus_to_library = pyqtSignal() def __init__(self, parent=None, add_clear_action=True, as_url=None): QComboBox.__init__(self, parent) self.line_edit = SearchLineEdit(self) self.line_edit.as_url = as_url self.setLineEdit(self.line_edit) self.line_edit.clear_history.connect(self.clear_history) if add_clear_action: self.lineEdit().setClearButtonEnabled(True) ac = self.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.clear_clicked) c = self.line_edit.completer() c.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) c.highlighted[native_string_type].connect(self.completer_used) self.line_edit.key_pressed.connect(self.key_pressed, type=Qt.ConnectionType.DirectConnection) # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807 self.activated[native_string_type].connect(self.history_selected, type=Qt.ConnectionType.QueuedConnection) self.setEditable(True) self.as_you_type = True self.timer = QTimer() self.timer.setSingleShot(True) self.timer.timeout.connect(self.timer_event, type=Qt.ConnectionType.QueuedConnection) self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.setMaxCount(self.MAX_COUNT) self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self.setMinimumContentsLength(25) self._in_a_search = False self.tool_tip_text = self.toolTip() def add_action(self, icon, position=QLineEdit.ActionPosition.TrailingPosition): if not isinstance(icon, QIcon): icon = QIcon(I(icon)) return self.lineEdit().addAction(icon, position) def initialize(self, opt_name, colorize=False, help_text=_('Search'), as_you_type=None): self.as_you_type = config['search_as_you_type'] if as_you_type is None else as_you_type self.opt_name = opt_name items = [] for item in config[opt_name]: if item not in items: items.append(item) self.addItems(items) self.line_edit.setPlaceholderText(help_text) self.colorize = colorize self.clear() def clear_history(self): config[self.opt_name] = [] self.clear() clear_search_history = clear_history def hide_completer_popup(self): try: self.lineEdit().completer().popup().setVisible(False) except: pass def normalize_state(self): self.setToolTip(self.tool_tip_text) self.line_edit.setStyleSheet('') def text(self): return self.currentText() def clear(self, emit_search=True): self.normalize_state() self.setEditText('') if emit_search: self.search.emit('') self._in_a_search = False self.cleared.emit() def clear_clicked(self, *args): self.clear() self.setFocus(Qt.FocusReason.OtherFocusReason) def search_done(self, ok): if isinstance(ok, string_or_bytes): self.setToolTip(ok) ok = False if not unicode_type(self.currentText()).strip(): self.clear(emit_search=False) return self._in_a_search = ok if self.colorize: self.line_edit.setStyleSheet(QApplication.instance().stylesheet_for_line_edit(not ok)) else: self.line_edit.setStyleSheet('') # Comes from the lineEdit control def key_pressed(self, event): k = event.key() if k in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Home, Qt.Key.Key_End, Qt.Key.Key_PageUp, Qt.Key.Key_PageDown, Qt.Key.Key_unknown): return self.normalize_state() if self._in_a_search: self.changed.emit() self._in_a_search = False if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.do_search() self.focus_to_library.emit() elif self.as_you_type and unicode_type(event.text()): self.timer.start(1500) # Comes from the combobox itself def keyPressEvent(self, event): k = event.key() if k in (Qt.Key.Key_Enter, Qt.Key.Key_Return): return self.do_search() if k not in (Qt.Key.Key_Up, Qt.Key.Key_Down): return QComboBox.keyPressEvent(self, event) self.blockSignals(True) self.normalize_state() if k == Qt.Key.Key_Down and self.currentIndex() == 0 and not self.lineEdit().text(): self.setCurrentIndex(1), self.setCurrentIndex(0) event.accept() else: QComboBox.keyPressEvent(self, event) self.blockSignals(False) def completer_used(self, text): self.timer.stop() self.normalize_state() def timer_event(self): self._do_search(as_you_type=True) def history_selected(self, text): self.changed.emit() self.do_search() def _do_search(self, store_in_history=True, as_you_type=False): self.hide_completer_popup() text = unicode_type(self.currentText()).strip() if not text: return self.clear() if as_you_type: text = AsYouType(text) self.search.emit(text) if store_in_history: idx = self.findText(text, Qt.MatchFlag.MatchFixedString|Qt.MatchFlag.MatchCaseSensitive) self.block_signals(True) if idx < 0: self.insertItem(0, text) else: t = self.itemText(idx) self.removeItem(idx) self.insertItem(0, t) self.setCurrentIndex(0) self.block_signals(False) history = [unicode_type(self.itemText(i)) for i in range(self.count())] config[self.opt_name] = history def do_search(self, *args): self._do_search() def block_signals(self, yes): self.blockSignals(yes) self.line_edit.blockSignals(yes) def set_search_string(self, txt, store_in_history=False, emit_changed=True): if not store_in_history: self.activated[native_string_type].disconnect() try: self.setFocus(Qt.FocusReason.OtherFocusReason) if not txt: self.clear() else: self.normalize_state() # must turn on case sensitivity here so that tag browser strings # are not case-insensitively replaced from history self.line_edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) self.setEditText(txt) self.line_edit.end(False) if emit_changed: self.changed.emit() self._do_search(store_in_history=store_in_history) self.line_edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self.focus_to_library.emit() finally: if not store_in_history: # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807 self.activated[native_string_type].connect(self.history_selected, type=Qt.ConnectionType.QueuedConnection) def search_as_you_type(self, enabled): self.as_you_type = enabled def in_a_search(self): return self._in_a_search @property def current_text(self): return unicode_type(self.lineEdit().text())
class TagListEditor(QDialog, Ui_TagListEditor): def __init__(self, window, cat_name, tag_to_match, get_book_ids, sorter, ttm_is_first_letter=False, category=None, fm=None): QDialog.__init__(self, window) Ui_TagListEditor.__init__(self) self.setupUi(self) self.verticalLayout_2.setAlignment(Qt.AlignmentFlag.AlignCenter) self.search_box.setMinimumContentsLength(25) # Put the category name into the title bar t = self.windowTitle() self.category_name = cat_name self.category = category self.setWindowTitle(t + ' (' + cat_name + ')') # Remove help icon on title bar icon = self.windowIcon() self.setWindowFlags(self.windowFlags() & (~Qt.WindowType.WindowContextHelpButtonHint)) self.setWindowIcon(icon) # Get saved geometry info try: self.table_column_widths = \ gprefs.get('tag_list_editor_table_widths', None) except: pass # initialization self.to_rename = {} self.to_delete = set() self.all_tags = {} self.original_names = {} self.ordered_tags = [] self.sorter = sorter self.get_book_ids = get_book_ids self.text_before_editing = '' # Capture clicks on the horizontal header to sort the table columns hh = self.table.horizontalHeader() hh.sectionResized.connect(self.table_column_resized) hh.setSectionsClickable(True) hh.sectionClicked.connect(self.do_sort) hh.setSortIndicatorShown(True) self.last_sorted_by = 'name' self.name_order = 0 self.count_order = 1 self.was_order = 1 self.edit_delegate = EditColumnDelegate(self.table) self.edit_delegate.editing_finished.connect(self.stop_editing) self.edit_delegate.editing_started.connect(self.start_editing) self.table.setItemDelegateForColumn(0, self.edit_delegate) if prefs['case_sensitive']: self.string_contains = contains else: self.string_contains = self.case_insensitive_compare self.delete_button.clicked.connect(self.delete_tags) self.table.delete_pressed.connect(self.delete_pressed) self.rename_button.clicked.connect(self.rename_tag) self.undo_button.clicked.connect(self.undo_edit) self.table.itemDoubleClicked.connect(self._rename_tag) self.table.itemChanged.connect(self.finish_editing) self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText( _('&OK')) self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setText( _('&Cancel')) self.buttonBox.accepted.connect(self.accepted) self.search_box.initialize('tag_list_search_box_' + cat_name) le = self.search_box.lineEdit() ac = le.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.clear_search) self.search_box.textChanged.connect(self.search_text_changed) self.search_button.clicked.connect(self.do_search) self.search_button.setDefault(True) l = QLabel(self.table) self.not_found_label = l l.setFrameStyle(QFrame.Shape.StyledPanel) l.setAutoFillBackground(True) l.setText(_('No matches found')) l.setAlignment(Qt.AlignmentFlag.AlignVCenter) l.resize(l.sizeHint()) l.move(10, 0) l.setVisible(False) self.not_found_label_timer = QTimer() self.not_found_label_timer.setSingleShot(True) self.not_found_label_timer.timeout.connect( self.not_found_label_timer_event, type=Qt.ConnectionType.QueuedConnection) self.filter_box.initialize('tag_list_filter_box_' + cat_name) le = self.filter_box.lineEdit() ac = le.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.clear_filter) le.returnPressed.connect(self.do_filter) self.filter_button.clicked.connect(self.do_filter) self.apply_vl_checkbox.clicked.connect(self.vl_box_changed) self.table.setEditTriggers( QAbstractItemView.EditTrigger.EditKeyPressed) try: geom = gprefs.get('tag_list_editor_dialog_geometry', None) if geom is not None: QApplication.instance().safe_restore_geometry( self, QByteArray(geom)) else: self.resize(self.sizeHint() + QSize(150, 100)) except: pass self.is_enumerated = False if fm: if fm['datatype'] == 'enumeration': self.is_enumerated = True self.enum_permitted_values = fm.get('display', {}).get( 'enum_values', None) # Add the data self.search_item_row = -1 self.fill_in_table(None, tag_to_match, ttm_is_first_letter) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self.show_context_menu) def show_context_menu(self, point): idx = self.table.indexAt(point) if idx.column() != 0: return m = self.au_context_menu = QMenu(self) item = self.table.itemAt(point) disable_copy_paste_search = len( self.table.selectedItems()) != 1 or item.is_deleted ca = m.addAction(_('Copy')) ca.triggered.connect(partial(self.copy_to_clipboard, item)) ca.setIcon(QIcon(I('edit-copy.png'))) if disable_copy_paste_search: ca.setEnabled(False) ca = m.addAction(_('Paste')) ca.setIcon(QIcon(I('edit-paste.png'))) ca.triggered.connect(partial(self.paste_from_clipboard, item)) if disable_copy_paste_search: ca.setEnabled(False) ca = m.addAction(_('Undo')) ca.setIcon(QIcon(I('edit-undo.png'))) ca.triggered.connect(self.undo_edit) ca.setEnabled(False) for item in self.table.selectedItems(): if (item.text() != self.original_names[int( item.data(Qt.ItemDataRole.UserRole))] or item.is_deleted): ca.setEnabled(True) break ca = m.addAction(_('Edit')) ca.setIcon(QIcon(I('edit_input.png'))) ca.triggered.connect(self.rename_tag) ca = m.addAction(_('Delete')) ca.setIcon(QIcon(I('trash.png'))) ca.triggered.connect(self.delete_tags) item_name = str(item.text()) ca = m.addAction(_('Search for {}').format(item_name)) ca.setIcon(QIcon(I('search.png'))) ca.triggered.connect(partial(self.set_search_text, item_name)) item_name = str(item.text()) ca = m.addAction(_('Filter by {}').format(item_name)) ca.setIcon(QIcon(I('filter.png'))) ca.triggered.connect(partial(self.set_filter_text, item_name)) if self.category is not None: ca = m.addAction(_("Search the library for {0}").format(item_name)) ca.setIcon(QIcon(I('lt.png'))) ca.triggered.connect(partial(self.search_for_books, item)) if disable_copy_paste_search: ca.setEnabled(False) if self.table.state() == QAbstractItemView.State.EditingState: m.addSeparator() case_menu = QMenu(_('Change case')) case_menu.setIcon(QIcon(I('font_size_larger.png'))) action_upper_case = case_menu.addAction(_('Upper case')) action_lower_case = case_menu.addAction(_('Lower case')) action_swap_case = case_menu.addAction(_('Swap case')) action_title_case = case_menu.addAction(_('Title case')) action_capitalize = case_menu.addAction(_('Capitalize')) action_upper_case.triggered.connect( partial(self.do_case, icu_upper)) action_lower_case.triggered.connect( partial(self.do_case, icu_lower)) action_swap_case.triggered.connect( partial(self.do_case, self.swap_case)) action_title_case.triggered.connect( partial(self.do_case, titlecase)) action_capitalize.triggered.connect( partial(self.do_case, capitalize)) m.addMenu(case_menu) m.exec(self.table.mapToGlobal(point)) def search_for_books(self, item): from calibre.gui2.ui import get_gui get_gui().search.set_search_string('{}:"={}"'.format( self.category, str(item.text()).replace(r'"', r'\"'))) qv = get_quickview_action_plugin() if qv: view = get_gui().library_view rows = view.selectionModel().selectedRows() if len(rows) > 0: current_row = rows[0].row() current_col = view.column_map.index(self.category) index = view.model().index(current_row, current_col) qv.change_quickview_column(index, show=False) def copy_to_clipboard(self, item): cb = QApplication.clipboard() cb.setText(str(item.text())) def paste_from_clipboard(self, item): cb = QApplication.clipboard() item.setText(cb.text()) def case_insensitive_compare(self, l, r): if prefs['use_primary_find_in_search']: return primary_contains(l, r) return contains(l.lower(), r.lower()) def do_case(self, func): items = self.table.selectedItems() # block signals to avoid the "edit one changes all" behavior self.table.blockSignals(True) for item in items: item.setText(func(str(item.text()))) self.table.blockSignals(False) def swap_case(self, txt): return txt.swapcase() def vl_box_changed(self): self.search_item_row = -1 self.fill_in_table(None, None, False) def do_search(self): self.not_found_label.setVisible(False) find_text = str(self.search_box.currentText()) if not find_text: return for _ in range(0, self.table.rowCount()): r = self.search_item_row = (self.search_item_row + 1) % self.table.rowCount() if self.string_contains(find_text, self.table.item(r, 0).text()): self.table.setCurrentItem(self.table.item(r, 0)) self.table.setFocus(Qt.FocusReason.OtherFocusReason) return # Nothing found. Pop up the little dialog for 1.5 seconds self.not_found_label.setVisible(True) self.not_found_label_timer.start(1500) def search_text_changed(self): self.search_item_row = -1 def clear_search(self): self.search_item_row = -1 self.search_box.setText('') def set_search_text(self, txt): self.search_box.setText(txt) self.do_search() def fill_in_table(self, tags, tag_to_match, ttm_is_first_letter): data = self.get_book_ids(self.apply_vl_checkbox.isChecked()) self.all_tags = {} filter_text = icu_lower(str(self.filter_box.text())) for k, v, count in data: if not filter_text or self.string_contains(filter_text, icu_lower(v)): self.all_tags[v] = { 'key': k, 'count': count, 'cur_name': v, 'is_deleted': k in self.to_delete } self.original_names[k] = v if self.is_enumerated: self.edit_delegate.set_completion_data(self.enum_permitted_values) else: self.edit_delegate.set_completion_data( self.original_names.values()) self.ordered_tags = sorted(self.all_tags.keys(), key=self.sorter) if tags is None: tags = self.ordered_tags select_item = None self.table.blockSignals(True) self.table.clear() self.table.setColumnCount(3) self.name_col = QTableWidgetItem(self.category_name) self.table.setHorizontalHeaderItem(0, self.name_col) self.count_col = QTableWidgetItem(_('Count')) self.table.setHorizontalHeaderItem(1, self.count_col) self.was_col = QTableWidgetItem(_('Was')) self.table.setHorizontalHeaderItem(2, self.was_col) self.table.setRowCount(len(tags)) for row, tag in enumerate(tags): item = NameTableWidgetItem(self.sorter) item.set_is_deleted(self.all_tags[tag]['is_deleted']) _id = self.all_tags[tag]['key'] item.setData(Qt.ItemDataRole.UserRole, _id) item.set_initial_text(tag) if _id in self.to_rename: item.setText(self.to_rename[_id]) else: item.setText(tag) if self.is_enumerated and str( item.text()) not in self.enum_permitted_values: item.setBackground(QColor('#FF2400')) item.setToolTip( '<p>' + _("This is not one of this column's permitted values ({0})" ).format(', '.join(self.enum_permitted_values)) + '</p>') item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, 0, item) if select_item is None: if ttm_is_first_letter: if primary_startswith(tag, tag_to_match): select_item = item elif tag == tag_to_match: select_item = item item = CountTableWidgetItem(self.all_tags[tag]['count']) # only the name column can be selected item.setFlags( item.flags() & ~(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable)) self.table.setItem(row, 1, item) item = QTableWidgetItem() item.setFlags( item.flags() & ~(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable)) if _id in self.to_rename or _id in self.to_delete: item.setData(Qt.ItemDataRole.DisplayRole, tag) self.table.setItem(row, 2, item) if self.last_sorted_by == 'name': self.table.sortByColumn(0, self.name_order) elif self.last_sorted_by == 'count': self.table.sortByColumn(1, self.count_order) else: self.table.sortByColumn(2, self.was_order) if select_item is not None: self.table.setCurrentItem(select_item) self.table.setFocus(Qt.FocusReason.OtherFocusReason) self.start_find_pos = select_item.row() else: self.table.setCurrentCell(0, 0) self.search_box.setFocus() self.start_find_pos = -1 self.table.blockSignals(False) def not_found_label_timer_event(self): self.not_found_label.setVisible(False) def clear_filter(self): self.filter_box.setText('') self.do_filter() def set_filter_text(self, txt): self.filter_box.setText(txt) self.do_filter() def do_filter(self): self.fill_in_table(None, None, False) def table_column_resized(self, col, old, new): self.table_column_widths = [] for c in range(0, self.table.columnCount()): self.table_column_widths.append(self.table.columnWidth(c)) def resizeEvent(self, *args): QDialog.resizeEvent(self, *args) if self.table_column_widths is not None: for c, w in enumerate(self.table_column_widths): self.table.setColumnWidth(c, w) else: # the vertical scroll bar might not be rendered, so might not yet # have a width. Assume 25. Not a problem because user-changed column # widths will be remembered w = self.table.width() - 25 - self.table.verticalHeader().width() w //= self.table.columnCount() for c in range(0, self.table.columnCount()): self.table.setColumnWidth(c, w) def save_geometry(self): gprefs['tag_list_editor_table_widths'] = self.table_column_widths gprefs['tag_list_editor_dialog_geometry'] = bytearray( self.saveGeometry()) def start_editing(self, on_row): items = self.table.selectedItems() self.table.blockSignals(True) for item in items: if item.row() != on_row: item.set_placeholder(_('Editing...')) else: self.text_before_editing = item.text() self.table.blockSignals(False) def stop_editing(self, on_row): items = self.table.selectedItems() self.table.blockSignals(True) for item in items: if item.row() != on_row and item.is_placeholder: item.reset_placeholder() self.table.blockSignals(False) def finish_editing(self, edited_item): if not edited_item.text(): error_dialog( self, _('Item is blank'), _('An item cannot be set to nothing. Delete it instead.'), show=True) self.table.blockSignals(True) edited_item.setText(self.text_before_editing) self.table.blockSignals(False) return new_text = str(edited_item.text()) if self.is_enumerated and new_text not in self.enum_permitted_values: error_dialog( self, _('Item is not a permitted value'), '<p>' + _("This column has a fixed set of permitted values. The entered " "text must be one of ({0}).").format(', '.join( self.enum_permitted_values)) + '</p>', show=True) self.table.blockSignals(True) edited_item.setText(self.text_before_editing) self.table.blockSignals(False) return items = self.table.selectedItems() self.table.blockSignals(True) for item in items: id_ = int(item.data(Qt.ItemDataRole.UserRole)) self.to_rename[id_] = new_text orig = self.table.item(item.row(), 2) item.setText(new_text) orig.setData(Qt.ItemDataRole.DisplayRole, item.initial_text()) self.table.blockSignals(False) def undo_edit(self): indexes = self.table.selectionModel().selectedRows() if not indexes: error_dialog( self, _('No item selected'), _('You must select one item from the list of available items.') ).exec() return if not confirm(_('Do you really want to undo your changes?'), 'tag_list_editor_undo'): return self.table.blockSignals(True) for idx in indexes: row = idx.row() item = self.table.item(row, 0) item.setText(item.initial_text()) item.set_is_deleted(False) self.to_delete.discard(int(item.data(Qt.ItemDataRole.UserRole))) self.to_rename.pop(int(item.data(Qt.ItemDataRole.UserRole)), None) self.table.item(row, 2).setData(Qt.ItemDataRole.DisplayRole, '') self.table.blockSignals(False) def rename_tag(self): item = self.table.item(self.table.currentRow(), 0) self._rename_tag(item) def _rename_tag(self, item): if item is None: error_dialog( self, _('No item selected'), _('You must select one item from the list of available items.') ).exec() return for col_zero_item in self.table.selectedItems(): if col_zero_item.is_deleted: if not question_dialog( self, _('Undelete items?'), '<p>' + _('Items must be undeleted to continue. Do you want ' 'to do this?') + '<br>'): return self.table.blockSignals(True) for col_zero_item in self.table.selectedItems(): # undelete any deleted items if col_zero_item.is_deleted: col_zero_item.set_is_deleted(False) self.to_delete.discard( int(col_zero_item.data(Qt.ItemDataRole.UserRole))) orig = self.table.item(col_zero_item.row(), 2) orig.setData(Qt.ItemDataRole.DisplayRole, '') self.table.blockSignals(False) self.table.editItem(item) def delete_pressed(self): if self.table.currentColumn() == 0: self.delete_tags() def delete_tags(self): deletes = self.table.selectedItems() if not deletes: error_dialog( self, _('No items selected'), _('You must select at least one item from the list.')).exec() return to_del = [] for item in deletes: if not item.is_deleted: to_del.append(item) if to_del: ct = ', '.join([str(item.text()) for item in to_del]) if not confirm( '<p>' + _('Are you sure you want to delete the following items?') + '<br>' + ct, 'tag_list_editor_delete'): return row = self.table.row(deletes[0]) self.table.blockSignals(True) for item in deletes: id_ = int(item.data(Qt.ItemDataRole.UserRole)) self.to_delete.add(id_) item.set_is_deleted(True) orig = self.table.item(item.row(), 2) orig.setData(Qt.ItemDataRole.DisplayRole, item.initial_text()) self.table.blockSignals(False) if row >= self.table.rowCount(): row = self.table.rowCount() - 1 if row >= 0: self.table.scrollToItem(self.table.item(row, 0)) def do_sort(self, section): (self.do_sort_by_name, self.do_sort_by_count, self.do_sort_by_was)[section]() def do_sort_by_name(self): self.name_order = 1 - self.name_order self.last_sorted_by = 'name' self.table.sortByColumn(0, self.name_order) def do_sort_by_count(self): self.count_order = 1 - self.count_order self.last_sorted_by = 'count' self.table.sortByColumn(1, self.count_order) def do_sort_by_was(self): self.was_order = 1 - self.was_order self.last_sorted_by = 'count' self.table.sortByColumn(2, self.was_order) def accepted(self): self.save_geometry()
class DiffView(QWidget): # {{{ SYNC_POSITION = 0.4 line_activated = pyqtSignal(object, object, object) def __init__(self, parent=None, show_open_in_editor=False): QWidget.__init__(self, parent) self.changes = [[], [], []] self.delta = 0 self.l = l = QHBoxLayout(self) self.setLayout(l) self.syncpos = 0 l.setContentsMargins(0, 0, 0, 0), l.setSpacing(0) self.view = DiffSplit(self, show_open_in_editor=show_open_in_editor) l.addWidget(self.view) self.add_diff = self.view.add_diff self.scrollbar = QScrollBar(self) l.addWidget(self.scrollbar) self.syncing = False self.bars = [] self.resize_timer = QTimer(self) self.resize_timer.setSingleShot(True) self.resize_timer.timeout.connect(self.resize_debounced) for bar in (self.scrollbar, self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar()): self.bars.append(bar) bar.scroll_idx = len(self.bars) - 1 connect_lambda(bar.valueChanged[int], self, lambda self: self.scrolled(self.sender().scroll_idx)) self.view.left.resized.connect(self.resized) for v in (self.view.left, self.view.right, self.view.handle(1)): v.wheel_event.connect(self.scrollbar.wheelEvent) if v is self.view.left or v is self.view.right: v.next_change.connect(self.next_change) v.line_activated.connect(self.line_activated) connect_lambda(v.scrolled, self, lambda self: self.scrolled(1 if self.sender() is self.view.left else 2)) def next_change(self, delta): assert delta in (1, -1) position = self.get_position_from_scrollbar(0) if position[0] == 'in': p = n = position[1] else: p, n = position[1], position[1] + 1 if p < 0: p = None if n >= len(self.changes[0]): n = None if p == n: nc = p + delta if nc < 0 or nc >= len(self.changes[0]): nc = None else: nc = {1:n, -1:p}[delta] if nc is None: self.scrollbar.setValue(0 if delta == -1 else self.scrollbar.maximum()) else: val = self.scrollbar.value() self.scroll_to(0, ('in', nc, 0)) nval = self.scrollbar.value() if nval == val: nval += 5 * delta if 0 <= nval <= self.scrollbar.maximum(): self.scrollbar.setValue(nval) def resized(self): self.resize_timer.start(300) def resize_debounced(self): self.view.resized() self.calculate_length() self.adjust_range() self.view.handle(1).update() def get_position_from_scrollbar(self, which): changes = self.changes[which] bar = self.bars[which] syncpos = self.syncpos + bar.value() prev = 0 for i, (top, bot, kind) in enumerate(changes): if syncpos <= bot: if top <= syncpos: # syncpos is inside a change try: ratio = float(syncpos - top) / (bot - top) except ZeroDivisionError: ratio = 0 return 'in', i, ratio else: # syncpos is after the previous change offset = syncpos - prev return 'after', i - 1, offset else: # syncpos is after the current change prev = bot offset = syncpos - prev return 'after', len(changes) - 1, offset def scroll_to(self, which, position): changes = self.changes[which] bar = self.bars[which] val = None if position[0] == 'in': change_idx, ratio = position[1:] start, end = changes[change_idx][:2] val = start + int((end - start) * ratio) else: change_idx, offset = position[1:] start = 0 if change_idx < 0 else changes[change_idx][1] val = start + offset bar.setValue(val - self.syncpos) def scrolled(self, which, *args): if self.syncing: return position = self.get_position_from_scrollbar(which) with self: for x in {0, 1, 2} - {which}: self.scroll_to(x, position) self.view.handle(1).update() def __enter__(self): self.syncing = True def __exit__(self, *args): self.syncing = False def clear(self): with self: self.view.clear() self.changes = [[], [], []] self.delta = 0 self.scrollbar.setRange(0, 0) def adjust_range(self): ls, rs = self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar() self.scrollbar.setPageStep(min(ls.pageStep(), rs.pageStep())) self.scrollbar.setSingleStep(min(ls.singleStep(), rs.singleStep())) self.scrollbar.setRange(0, ls.maximum() + self.delta) self.scrollbar.setVisible(self.view.left.document().lineCount() > ls.pageStep() or self.view.right.document().lineCount() > rs.pageStep()) self.syncpos = int(ceil(self.scrollbar.pageStep() * self.SYNC_POSITION)) def finalize(self): self.view.finalize() self.changes = [[], [], []] self.calculate_length() self.adjust_range() def calculate_length(self): delta = 0 line_number_changes = ([], []) for v, lmap, changes in zip((self.view.left, self.view.right), ({}, {}), line_number_changes): b = v.document().firstBlock() ebl = v.document().documentLayout().ensureBlockLayout last_line_count = 0 while b.isValid(): ebl(b) lmap[b.blockNumber()] = last_line_count last_line_count += b.layout().lineCount() b = b.next() for top, bot, kind in v.changes: changes.append((lmap[top], lmap[bot], kind)) changes = [] for (l_top, l_bot, kind), (r_top, r_bot, kind) in zip(*line_number_changes): height = max(l_bot - l_top, r_bot - r_top) top = delta + l_top changes.append((top, top + height, kind)) delta = top + height - l_bot self.changes, self.delta = (changes,) + line_number_changes, delta def handle_key(self, ev): amount, d = None, 1 key = ev.key() if key in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_J, Qt.Key.Key_K): amount = self.scrollbar.singleStep() if key in (Qt.Key.Key_Up, Qt.Key.Key_K): d = -1 elif key in (Qt.Key.Key_PageUp, Qt.Key.Key_PageDown): amount = self.scrollbar.pageStep() if key in (Qt.Key.Key_PageUp,): d = -1 elif key in (Qt.Key.Key_Home, Qt.Key.Key_End): self.scrollbar.setValue(0 if key == Qt.Key.Key_Home else self.scrollbar.maximum()) return True elif key in (Qt.Key.Key_N, Qt.Key.Key_P): self.next_change(1 if key == Qt.Key.Key_N else -1) return True if amount is not None: self.scrollbar.setValue(self.scrollbar.value() + d * amount) return True return False
class LiveCSS(QWidget): goto_declaration = pyqtSignal(object) def __init__(self, preview, parent=None): QWidget.__init__(self, parent) self.preview = preview preview.live_css_data.connect(self.got_live_css_data) self.preview_is_refreshing = False self.refresh_needed = False preview.refresh_starting.connect(self.preview_refresh_starting) preview.refreshed.connect(self.preview_refreshed) self.apply_theme() self.setAutoFillBackground(True) self.update_timer = QTimer(self) self.update_timer.timeout.connect(self.update_data) self.update_timer.setSingleShot(True) self.update_timer.setInterval(500) self.now_showing = (None, None, None) self.stack = s = QStackedLayout(self) self.setLayout(s) self.clear_label = la = QLabel( '<h3>' + _('No style information found') + '</h3><p>' + _('Move the cursor inside a HTML tag to see what styles' ' apply to that tag.')) la.setWordWrap(True) la.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) s.addWidget(la) self.box = box = Box(self) box.hyperlink_activated.connect( self.goto_declaration, type=Qt.ConnectionType.QueuedConnection) self.scroll = sc = QScrollArea(self) sc.setWidget(box) sc.setWidgetResizable(True) s.addWidget(sc) def preview_refresh_starting(self): self.preview_is_refreshing = True def preview_refreshed(self): self.preview_is_refreshing = False self.refresh_needed = True self.start_update_timer() def apply_theme(self): f = self.font() f.setFamily(tprefs['editor_font_family'] or default_font_family()) f.setPointSizeF(tprefs['editor_font_size']) self.setFont(f) theme = get_theme(tprefs['editor_theme']) pal = self.palette() pal.setColor(QPalette.ColorRole.Window, theme_color(theme, 'Normal', 'bg')) pal.setColor(QPalette.ColorRole.WindowText, theme_color(theme, 'Normal', 'fg')) pal.setColor(QPalette.ColorRole.AlternateBase, theme_color(theme, 'HighlightRegion', 'bg')) pal.setColor(QPalette.ColorRole.Link, theme_color(theme, 'Link', 'fg')) pal.setColor(QPalette.ColorRole.LinkVisited, theme_color(theme, 'Keyword', 'fg')) self.setPalette(pal) if hasattr(self, 'box'): self.box.relayout() self.update() def clear(self): self.stack.setCurrentIndex(0) def show_data(self, editor_name, sourceline, tags): if self.preview_is_refreshing: return if sourceline is None: self.clear() else: self.preview.request_live_css_data(editor_name, sourceline, tags) def got_live_css_data(self, result): maximum_specificities = {} for node in result['nodes']: for rule in node['css']: self.process_rule(rule, node['ancestor_specificity'], maximum_specificities) for node in result['nodes']: for rule in node['css']: for prop in rule['properties']: if prop.specificity < maximum_specificities[prop.name]: prop.is_overriden = True self.display_received_live_css_data(result) def display_received_live_css_data(self, data): editor_name = data['editor_name'] sourceline = data['sourceline'] tags = data['tags'] if data is None or len(data['computed_css']) < 1: if editor_name == self.current_name and (editor_name, sourceline, tags) == self.now_showing: # Try again in a little while in case there was a transient # error in the web view self.start_update_timer() return self.clear() return self.now_showing = (editor_name, sourceline, tags) data['html_name'] = editor_name self.box.show_data(data) self.refresh_needed = False self.stack.setCurrentIndex(1) def process_rule(self, rule, ancestor_specificity, maximum_specificities): selector = rule['selector'] sheet_index = rule['sheet_index'] rule_address = rule['rule_address'] or () if selector is not None: try: specificity = [0] + list(parse(selector)[0].specificity()) except (AttributeError, TypeError, SelectorError): specificity = [0, 0, 0, 0] else: # style attribute specificity = [1, 0, 0, 0] specificity.extend((sheet_index, tuple(rule_address))) properties = [] for prop in rule['properties']: important = 1 if prop[-1] == 'important' else 0 p = Property(prop, [ancestor_specificity] + [important] + specificity) properties.append(p) if p.specificity > maximum_specificities.get( p.name, lowest_specificity): maximum_specificities[p.name] = p.specificity rule['properties'] = properties href = rule['href'] if hasattr(href, 'startswith') and href.startswith( f'{FAKE_PROTOCOL}://{FAKE_HOST}'): qurl = QUrl(href) name = qurl.path()[1:] if name: rule['href'] = name @property def current_name(self): return self.preview.current_name @property def is_visible(self): return self.isVisible() def showEvent(self, ev): self.update_timer.start() actions['auto-reload-preview'].setEnabled(True) return QWidget.showEvent(self, ev) def sync_to_editor(self): self.update_data() def update_data(self): if not self.is_visible or self.preview_is_refreshing: return editor_name = self.current_name ed = editors.get(editor_name, None) if self.update_timer.isActive() or (ed is None and editor_name is not None): return QTimer.singleShot(100, self.update_data) if ed is not None: sourceline, tags = ed.current_tag(for_position_sync=False) if self.refresh_needed or self.now_showing != (editor_name, sourceline, tags): self.show_data(editor_name, sourceline, tags) def start_update_timer(self): if self.is_visible: self.update_timer.start() def stop_update_timer(self): self.update_timer.stop() def navigate_to_declaration(self, data, editor): if data['type'] == 'inline': sourceline, tags = data['sourceline_address'] editor.goto_sourceline(sourceline, tags, attribute='style') elif data['type'] == 'sheet': editor.goto_css_rule(data['rule_address']) elif data['type'] == 'elem': editor.goto_css_rule(data['rule_address'], sourceline_address=data['sourceline_address'])
class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog): def __init__(self, parent, db, id_to_select, select_sort, select_link, find_aut_func, is_first_letter=False): QDialog.__init__(self, parent) Ui_EditAuthorsDialog.__init__(self) self.setupUi(self) # Remove help icon on title bar icon = self.windowIcon() self.setWindowFlags(self.windowFlags() & (~Qt.WindowType.WindowContextHelpButtonHint)) self.setWindowIcon(icon) try: self.table_column_widths = \ gprefs.get('manage_authors_table_widths', None) geom = gprefs.get('manage_authors_dialog_geometry', None) if geom: QApplication.instance().safe_restore_geometry( self, QByteArray(geom)) except Exception: pass self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText( _('&OK')) self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setText( _('&Cancel')) self.buttonBox.accepted.connect(self.accepted) self.apply_vl_checkbox.stateChanged.connect(self.use_vl_changed) # Set up the heading for sorting self.table.setSelectionMode( QAbstractItemView.SelectionMode.SingleSelection) self.find_aut_func = find_aut_func self.table.resizeColumnsToContents() if self.table.columnWidth(2) < 200: self.table.setColumnWidth(2, 200) # set up the cellChanged signal only after the table is filled self.table.cellChanged.connect(self.cell_changed) self.recalc_author_sort.clicked.connect(self.do_recalc_author_sort) self.auth_sort_to_author.clicked.connect(self.do_auth_sort_to_author) # Capture clicks on the horizontal header to sort the table columns hh = self.table.horizontalHeader() hh.sectionResized.connect(self.table_column_resized) hh.setSectionsClickable(True) hh.sectionClicked.connect(self.do_sort) hh.setSortIndicatorShown(True) # set up the search & filter boxes self.find_box.initialize('manage_authors_search') le = self.find_box.lineEdit() ac = le.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.clear_find) le.returnPressed.connect(self.do_find) self.find_box.editTextChanged.connect(self.find_text_changed) self.find_button.clicked.connect(self.do_find) self.find_button.setDefault(True) self.filter_box.initialize('manage_authors_filter') le = self.filter_box.lineEdit() ac = le.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.clear_filter) self.filter_box.lineEdit().returnPressed.connect(self.do_filter) self.filter_button.clicked.connect(self.do_filter) self.not_found_label = l = QLabel(self.table) l.setFrameStyle(QFrame.Shape.StyledPanel) l.setAutoFillBackground(True) l.setText(_('No matches found')) l.setAlignment(Qt.AlignmentFlag.AlignVCenter) l.resize(l.sizeHint()) l.move(10, 2) l.setVisible(False) self.not_found_label_timer = QTimer() self.not_found_label_timer.setSingleShot(True) self.not_found_label_timer.timeout.connect( self.not_found_label_timer_event, type=Qt.ConnectionType.QueuedConnection) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self.show_context_menu) # Fetch the data self.authors = {} self.original_authors = {} auts = db.new_api.author_data() self.completion_data = [] for id_, v in auts.items(): name = v['name'] name = name.replace('|', ',') self.completion_data.append(name) self.authors[id_] = { 'name': name, 'sort': v['sort'], 'link': v['link'] } self.original_authors[id_] = { 'name': name, 'sort': v['sort'], 'link': v['link'] } self.edited_icon = QIcon(I('modified.png')) self.empty_icon = QIcon() if prefs['use_primary_find_in_search']: self.string_contains = primary_contains else: self.string_contains = contains self.last_sorted_by = 'sort' self.author_order = 1 self.author_sort_order = 0 self.link_order = 1 self.show_table(id_to_select, select_sort, select_link, is_first_letter) def use_vl_changed(self, x): self.show_table(None, None, None, False) def clear_filter(self): self.filter_box.setText('') self.show_table(None, None, None, False) def do_filter(self): self.show_table(None, None, None, False) def show_table(self, id_to_select, select_sort, select_link, is_first_letter): auts_to_show = { t[0] for t in self.find_aut_func( use_virtual_library=self.apply_vl_checkbox.isChecked()) } filter_text = icu_lower(unicode_type(self.filter_box.text())) if filter_text: auts_to_show = { id_ for id_ in auts_to_show if self.string_contains( filter_text, icu_lower(self.authors[id_]['name'])) } self.table.blockSignals(True) self.table.clear() self.table.setColumnCount(3) self.table.setRowCount(len(auts_to_show)) row = 0 for id_, v in self.authors.items(): if id_ not in auts_to_show: continue name, sort, link = (v['name'], v['sort'], v['link']) name = name.replace('|', ',') name_item = tableItem(name) name_item.setData(Qt.ItemDataRole.UserRole, id_) sort_item = tableItem(sort) link_item = tableItem(link) self.table.setItem(row, 0, name_item) self.table.setItem(row, 1, sort_item) self.table.setItem(row, 2, link_item) self.set_icon(name_item, id_) self.set_icon(sort_item, id_) self.set_icon(link_item, id_) row += 1 self.table.setItemDelegate(EditColumnDelegate(self.completion_data)) self.table.setHorizontalHeaderLabels( [_('Author'), _('Author sort'), _('Link')]) if self.last_sorted_by == 'sort': self.author_sort_order = 1 - self.author_sort_order self.do_sort_by_author_sort() elif self.last_sorted_by == 'author': self.author_order = 1 - self.author_order self.do_sort_by_author() else: self.link_order = 1 - self.link_order self.do_sort_by_link() # Position on the desired item select_item = None if id_to_select: use_as = tweaks[ 'categories_use_field_for_author_name'] == 'author_sort' for row in range(0, len(auts_to_show)): if is_first_letter: item_txt = unicode_type( self.table.item(row, 1).text() if use_as else self. table.item(row, 0).text()) if primary_startswith(item_txt, id_to_select): select_item = self.table.item(row, 1 if use_as else 0) break elif id_to_select == self.table.item(row, 0).data( Qt.ItemDataRole.UserRole): if select_sort: select_item = self.table.item(row, 1) elif select_link: select_item = self.table.item(row, 2) else: select_item = (self.table.item(row, 1) if use_as else self.table.item(row, 0)) break if select_item: self.table.setCurrentItem(select_item) self.table.setFocus(True) if select_sort or select_link: self.table.editItem(select_item) self.start_find_pos = select_item.row() * 2 + select_item.column() else: self.table.setCurrentCell(0, 0) self.find_box.setFocus() self.start_find_pos = -1 self.table.blockSignals(False) def save_state(self): self.table_column_widths = [] for c in range(0, self.table.columnCount()): self.table_column_widths.append(self.table.columnWidth(c)) gprefs['manage_authors_table_widths'] = self.table_column_widths gprefs['manage_authors_dialog_geometry'] = bytearray( self.saveGeometry()) def table_column_resized(self, col, old, new): self.table_column_widths = [] for c in range(0, self.table.columnCount()): self.table_column_widths.append(self.table.columnWidth(c)) def resizeEvent(self, *args): QDialog.resizeEvent(self, *args) if self.table_column_widths is not None: for c, w in enumerate(self.table_column_widths): self.table.setColumnWidth(c, w) else: # the vertical scroll bar might not be rendered, so might not yet # have a width. Assume 25. Not a problem because user-changed column # widths will be remembered w = self.table.width() - 25 - self.table.verticalHeader().width() w //= self.table.columnCount() for c in range(0, self.table.columnCount()): self.table.setColumnWidth(c, w) self.save_state() def get_column_name(self, column): return ['name', 'sort', 'link'][column] def show_context_menu(self, point): self.context_item = self.table.itemAt(point) case_menu = QMenu(_('Change case')) action_upper_case = case_menu.addAction(_('Upper case')) action_lower_case = case_menu.addAction(_('Lower case')) action_swap_case = case_menu.addAction(_('Swap case')) action_title_case = case_menu.addAction(_('Title case')) action_capitalize = case_menu.addAction(_('Capitalize')) action_upper_case.triggered.connect(self.upper_case) action_lower_case.triggered.connect(self.lower_case) action_swap_case.triggered.connect(self.swap_case) action_title_case.triggered.connect(self.title_case) action_capitalize.triggered.connect(self.capitalize) m = self.au_context_menu = QMenu(self) idx = self.table.indexAt(point) id_ = int(self.table.item(idx.row(), 0).data(Qt.ItemDataRole.UserRole)) sub = self.get_column_name(idx.column()) if self.context_item.text() != self.original_authors[id_][sub]: ca = m.addAction(_('Undo')) ca.triggered.connect( partial(self.undo_cell, old_value=self.original_authors[id_][sub])) m.addSeparator() ca = m.addAction(_('Copy')) ca.triggered.connect(self.copy_to_clipboard) ca = m.addAction(_('Paste')) ca.triggered.connect(self.paste_from_clipboard) m.addSeparator() if self.context_item is not None and self.context_item.column() == 0: ca = m.addAction(_('Copy to author sort')) ca.triggered.connect(self.copy_au_to_aus) m.addSeparator() ca = m.addAction(_("Show books by author in book list")) ca.triggered.connect(self.search_in_book_list) else: ca = m.addAction(_('Copy to author')) ca.triggered.connect(self.copy_aus_to_au) m.addSeparator() m.addMenu(case_menu) m.exec_(self.table.mapToGlobal(point)) def undo_cell(self, old_value): self.context_item.setText(old_value) def search_in_book_list(self): from calibre.gui2.ui import get_gui row = self.context_item.row() get_gui().search.set_search_string( 'authors:="%s"' % unicode_type(self.table.item(row, 0).text()).replace(r'"', r'\"')) def copy_to_clipboard(self): cb = QApplication.clipboard() cb.setText(unicode_type(self.context_item.text())) def paste_from_clipboard(self): cb = QApplication.clipboard() self.context_item.setText(cb.text()) def upper_case(self): self.context_item.setText( icu_upper(unicode_type(self.context_item.text()))) def lower_case(self): self.context_item.setText( icu_lower(unicode_type(self.context_item.text()))) def swap_case(self): self.context_item.setText( unicode_type(self.context_item.text()).swapcase()) def title_case(self): from calibre.utils.titlecase import titlecase self.context_item.setText( titlecase(unicode_type(self.context_item.text()))) def capitalize(self): from calibre.utils.icu import capitalize self.context_item.setText( capitalize(unicode_type(self.context_item.text()))) def copy_aus_to_au(self): row = self.context_item.row() dest = self.table.item(row, 0) dest.setText(self.context_item.text()) def copy_au_to_aus(self): row = self.context_item.row() dest = self.table.item(row, 1) dest.setText(self.context_item.text()) def not_found_label_timer_event(self): self.not_found_label.setVisible(False) def clear_find(self): self.find_box.setText('') self.start_find_pos = -1 self.do_find() def find_text_changed(self): self.start_find_pos = -1 def do_find(self): self.not_found_label.setVisible(False) # For some reason the button box keeps stealing the RETURN shortcut. # Steal it back self.buttonBox.button( QDialogButtonBox.StandardButton.Ok).setDefault(False) self.buttonBox.button( QDialogButtonBox.StandardButton.Ok).setAutoDefault(False) self.buttonBox.button( QDialogButtonBox.StandardButton.Cancel).setDefault(False) self.buttonBox.button( QDialogButtonBox.StandardButton.Cancel).setAutoDefault(False) st = icu_lower(unicode_type(self.find_box.currentText())) if not st: return for _ in range(0, self.table.rowCount() * 2): self.start_find_pos = (self.start_find_pos + 1) % (self.table.rowCount() * 2) r = (self.start_find_pos // 2) % self.table.rowCount() c = self.start_find_pos % 2 item = self.table.item(r, c) text = icu_lower(unicode_type(item.text())) if st in text: self.table.setCurrentItem(item) self.table.setFocus(True) return # Nothing found. Pop up the little dialog for 1.5 seconds self.not_found_label.setVisible(True) self.not_found_label_timer.start(1500) def do_sort(self, section): (self.do_sort_by_author, self.do_sort_by_author_sort, self.do_sort_by_link)[section]() def do_sort_by_author(self): self.last_sorted_by = 'author' self.author_order = 1 - self.author_order self.table.sortByColumn(0, self.author_order) def do_sort_by_author_sort(self): self.last_sorted_by = 'sort' self.author_sort_order = 1 - self.author_sort_order self.table.sortByColumn(1, self.author_sort_order) def do_sort_by_link(self): self.last_sorted_by = 'link' self.link_order = 1 - self.link_order self.table.sortByColumn(2, self.link_order) def accepted(self): self.save_state() self.result = [] for id_, v in self.authors.items(): orig = self.original_authors[id_] if orig != v: self.result.append( (id_, orig['name'], v['name'], v['sort'], v['link'])) def do_recalc_author_sort(self): self.table.cellChanged.disconnect() for row in range(0, self.table.rowCount()): item_aut = self.table.item(row, 0) id_ = int(item_aut.data(Qt.ItemDataRole.UserRole)) aut = unicode_type(item_aut.text()).strip() item_aus = self.table.item(row, 1) # Sometimes trailing commas are left by changing between copy algs aus = unicode_type(author_to_author_sort(aut)).rstrip(',') item_aus.setText(aus) self.authors[id_]['sort'] = aus self.set_icon(item_aus, id_) self.table.setFocus(Qt.FocusReason.OtherFocusReason) self.table.cellChanged.connect(self.cell_changed) def do_auth_sort_to_author(self): self.table.cellChanged.disconnect() for row in range(0, self.table.rowCount()): aus = unicode_type(self.table.item(row, 1).text()).strip() item_aut = self.table.item(row, 0) id_ = int(item_aut.data(Qt.ItemDataRole.UserRole)) item_aut.setText(aus) self.authors[id_]['name'] = aus self.set_icon(item_aut, id_) self.table.setFocus(Qt.FocusReason.OtherFocusReason) self.table.cellChanged.connect(self.cell_changed) def set_icon(self, item, id_): col_name = self.get_column_name(item.column()) if unicode_type(item.text()) != self.original_authors[id_][col_name]: item.setIcon(self.edited_icon) else: item.setIcon(self.empty_icon) def cell_changed(self, row, col): id_ = int(self.table.item(row, 0).data(Qt.ItemDataRole.UserRole)) if col == 0: item = self.table.item(row, 0) aut = unicode_type(item.text()).strip() aut_list = string_to_authors(aut) if len(aut_list) != 1: error_dialog( self.parent(), _('Invalid author name'), _('You cannot change an author to multiple authors.') ).exec_() aut = ' % '.join(aut_list) self.table.item(row, 0).setText(aut) item.set_sort_key() self.authors[id_]['name'] = aut self.set_icon(item, id_) c = self.table.item(row, 1) txt = author_to_author_sort(aut) self.authors[id_]['sort'] = txt c.setText(txt) # This triggers another cellChanged event item = c else: item = self.table.item(row, col) item.set_sort_key() self.set_icon(item, id_) self.authors[id_][self.get_column_name(col)] = unicode_type( item.text()) self.table.setCurrentItem(item) self.table.scrollToItem(item)