class TagBrowserWidget(QFrame): # {{{ def __init__(self, parent): QFrame.__init__(self, parent) self.setFrameStyle(QFrame.NoFrame if gprefs['tag_browser_old_look'] else QFrame.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) 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.StyledPanel) l.setAutoFillBackground(True) l.setText('<p><b>'+_('No More Matches.</b><p> Click Find again to go to first match')) l.setAlignment(Qt.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.QueuedConnection) # The Alter Tag Browser button l = self.alter_tb 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()) 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) 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) # self.leak_test_timer = QTimer(self) # self.leak_test_timer.timeout.connect(self.test_for_leak) # self.leak_test_timer.start(5000) 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 set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) 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 unicode_type(self.item_search.currentText()).strip() def find(self): model = self.tags_view.model() model.clear_boxed() txt = self.find_text if txt.startswith('*'): 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) key = None colon = txt.rfind(':') if len(txt) > 2 else 0 if colon > 0: key = self._parent.library_view.model().db.\ field_metadata.search_term_to_field_key(txt[:colon]) txt = txt[colon+1:] self.current_find_position = \ model.find_item_node(key, txt, self.current_find_position) 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_Enter, Qt.Key_Return) and self.find_text: self.find() ev.accept() return return QFrame.keyPressEvent(self, ev)
class HistoryFilterModel(QSortFilterProxyModel): def __init__(self, parent): ''' @param: parent QAbstractItemModel ''' super().__init__(parent) self._pattern = '' self._filterTimer = None # QTimer self.setSourceModel(parent) self.setFilterCaseSensitivity(Qt.CaseInsensitive) self._filterTimer = QTimer(self) self._filterTimer.setSingleShot(True) self._filterTimer.setInterval(300) self._filterTimer.timeout.connect(self._startFiltering) # public Q_SLOTS def setFilterFixedString(self, pattern): ''' @param: pattern QString ''' self._pattern = pattern self._filterTimer.start() # Q_SIGNALS expandAllItems = pyqtSignal() collapseAllItems = pyqtSignal() # protected: # override def filterAcceptsRow(self, sourceRow, sourceParent): index = self.sourceModel().index(sourceRow, 0, sourceParent) isTopLevel = index.data(HistoryModel.IsTopLevelRole) if isTopLevel: return True urlString = index.data(HistoryModel.UrlStringRole).lower() title = index.data(HistoryModel.TitleRole).lower() return self._pattern.lower() in urlString or self._pattern.lower( ) in title # private Q_SLOTS: def _startFiltering(self): if not self._pattern: self.collapseAllItems.emit() super().setFilterFixedString(self._pattern) return QApplication.setOverrideCursor(Qt.WaitCursor) # Expand all items also calls fetchmore self.expandAllItems.emit() super().setFilterFixedString(self._pattern) QApplication.restoreOverrideCursor()
def get(self, qurl, html=None, num_retries=1, delay=10, timeout=10): t1 = time() loop = QEventLoop() timer = QTimer() timer.setSingleShot(True) timer.timeout.connect(loop.quit) self.loadFinished.connect(loop.quit) if qurl: if html: self.setHtml(html, qurl) else: self.mainFrame().load(QUrl(qurl)) timer.start(timeout * 1000) loop.exec_() # delay here until download finished or timeout if timer.isActive(): # downloaded successfully timer.stop() self._wait(delay - (time() - t1)) parsed_html = self.mainFrame().toHtml() else: # did not download in time if num_retries > 0: logging.debug('Timeout - retrying') parsed_html = self.get(qurl, num_retries=num_retries - 1, timerout=timeout, delay=delay) else: logging.debug('Timed out') parsed_html = '' self.mainFrame().setHtml(None) return parsed_html
def showMsg(self, obj=None, msgAlert=None): time = QTimer(self) obj.setText(msgAlert) QT_widgetsCustom.successDatabase(obj=obj) time.start(10000) time.setSingleShot(True) time.timeout.connect(self.toNormal)
def get(self, qurl, html=None, num_retries=1, delay = 10, timeout = 10): t1 = time() loop = QEventLoop() timer = QTimer() timer.setSingleShot(True) timer.timeout.connect(loop.quit) self.loadFinished.connect(loop.quit) if qurl: if html: self.setHtml(html, qurl) else: self.mainFrame().load(QUrl(qurl)) timer.start(timeout * 1000) loop.exec_() # delay here until download finished or timeout if timer.isActive(): # downloaded successfully timer.stop() self._wait(delay - (time() - t1)) parsed_html = self.mainFrame().toHtml() else: # did not download in time if num_retries > 0: logging.debug('Timeout - retrying') parsed_html = self.get(qurl, num_retries=num_retries-1, timerout=timeout, delay=delay) else: logging.debug('Timed out') parsed_html = '' self.mainFrame().setHtml(None) return parsed_html
class MyWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("测试用") self.widget = QWidget(self) self.widget.setAutoFillBackground(True) self.setCentralWidget(self.widget) start_listen_noblock(9009) self.timer = QTimer() self.timer.setSingleShot(False) self.timer.timeout.connect(self.update_image) self.timer.start(300) def update_image(self): w, h, c, dt = get_pic(10) if not isinstance(dt, bytes): return # array = np.frombuffer(dt, dtype=np.uint8).reshape((h, w, c)) # array = array.transpose((1, 0, 2)).copy() # array = cv2.transpose(array) # cv2.imshow("aa", array) image = QImage(dt, w, h, QImage.Format_RGB888) palette = self.widget.palette() image = image.scaled(self.widget.geometry().width(), self.widget.geometry().height()) palette.setBrush(QPalette.Background, QBrush(image)) self.widget.setPalette(palette) self.widget.update()
def start_timer(self): """ Start timer to close banner """ timer = QTimer(self) timer.timeout.connect(self.close_banner) timer.setSingleShot(True) timer.start(int(self.timer))
class ReloadStopButton(ToolButton): def __init__(self, parent=0): super().__init__(parent) self._loadInProgress = False self._updateTimer = None self.setToolButtonStyle(Qt.ToolButtonIconOnly) self.setToolbarButtonLook(True) self.setAutoRaise(True) self.setFocusPolicy(Qt.NoFocus) self._updateTimer = QTimer(self) self._updateTimer.setInterval(50) self._updateTimer.setSingleShot(True) self._updateTimer.timeout.connect(self._updateButton) self.clicked.connect(self._buttonClicked) self._updateButton() def showStopButton(self): self._loadInProgress = True self._updateTimer.start() def showReloadButton(self): self._loadInProgress = False self._updateTimer.start() # public Q_SIGNALS stopClicked = pyqtSignal() reloadClicked = pyqtSignal() # private Q_SLOTS def _updateButton(self): if self._loadInProgress: self.setToolTip(_('Stop')) self.setObjectName('navigation-button-stop') else: self.setToolTip(_('Reload')) self.setObjectName('navigation-button-reload') # Update the stylesheet for the changed object name self.style().unpolish(self) self.style().polish(self) def _buttonClicked(self): if self._loadInProgress: self.stopClicked.emit() else: self.reloadClicked.emit()
class BookmarksFilterModel(QSortFilterProxyModel): def __init__(self, parent): ''' @param: parent QAbstractItemModel ''' super().__init__(parent) self._pattern = '' self._filterTimer = None # QTimer self.setSourceModel(parent) self.setFilterCaseSensitivity(Qt.CaseInsensitive) self._filterTimer = QTimer(self) self._filterTimer.setSingleShot(True) self._filterTimer.setInterval(300) self._filterTimer.timeout.connect(self._startFiltering) # public Q_SLOTS: def setFilterFixedString(self, pattern): self._pattern = pattern self._filterTimer.start() # protected: # override def filterAcceptsRow(self, sourceRow, sourceParent): index = self.sourceModel().index(sourceRow, 0, sourceParent) if index.data(BookmarksModel.TypeRole) == BookmarkItem.Folder: return True pattern = self._pattern title = index.data(BookmarksModel.TitleRole) url = index.data(BookmarksModel.UrlStringRole) desc = index.data(BookmarksModel.DescriptionRole) keyword = index.data(BookmarksModel.KeywordRole) if self.filterCaseSensitivity() == Qt.CaseInsensitive: pattern = pattern.lower() title = title.lower() url = url.lower() desc = desc.lower() keyword = keyword.lower() return pattern in title or pattern in url or pattern in desc or \ pattern == keyword # private Q_SLOTS: def _startFiltering(self): super().setFilterFixedString(self._pattern)
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(self.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(self.Batched) self.setResizeMode(self.Adjust) self.setSelectionMode(self.ExtendedSelection) self.setVerticalScrollMode(self.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.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.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(pal.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 ss += 'color: {};'.format('white' if dark else 'black') self.delegate.highlight_color = QColor(Qt.white if dark else Qt.black) 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.IgnoreAspectRatio, Qt.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)), sm.Select) sm.select(sel, sm.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, sm.ClearAndSelect) def set_current_row(self, row): sm = self.selectionModel() sm.setCurrentIndex(self.model().index(row, 0), sm.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.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, sm.NoUpdate) if not ci.isValid(): return if not sm.hasSelection(): sm.select(index, sm.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), sm.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.ShiftModifier and k in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down): ci = self.currentIndex() if not ci.isValid(): return c = ci.row() ncols = self.number_of_columns() or 1 delta = {Qt.Key_Left: -1, Qt.Key_Right: 1, Qt.Key_Up: -ncols, Qt.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), sm.ClearAndSelect) sm.setCurrentIndex(self.model().index(n, 0), sm.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.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), self.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 (QListView.MoveLeft, QListView.MoveRight) and index.isValid(): ci = self.currentIndex() if ci.isValid() and index.row() == ci.row(): nr = index.row() + (1 if action == QListView.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() == event.KeyPress and event.key() in (Qt.Key_Home, Qt.Key_End) and event.modifiers() & Qt.CTRL: return QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows return super(GridView, self).selectionCommand(index, event) def wheelEvent(self, ev): if ev.phase() not in (Qt.ScrollUpdate, 0): 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 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.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.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['use_primary_find_in_search']: self.string_contains = primary_contains else: self.string_contains = contains 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.Ok).setText(_('&OK')) self.buttonBox.button(QDialogButtonBox.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.StyledPanel) l.setAutoFillBackground(True) l.setText(_('No matches found')) l.setAlignment(Qt.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.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(QTableWidget.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.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)) if disable_copy_paste_search: ca.setEnabled(False) ca = m.addAction(_('Paste')) ca.triggered.connect(partial(self.paste_from_clipboard, item)) if disable_copy_paste_search: ca.setEnabled(False) ca = m.addAction(_('Undo')) 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.UserRole))] or item.is_deleted): ca.setEnabled(True) break ca = m.addAction(_('Edit')) ca.triggered.connect(self.rename_tag) ca = m.addAction(_('Delete')) ca.triggered.connect(self.delete_tags) if self.category is not None: ca = m.addAction( _("Search the library for '{0}'").format( unicode_type(item.text()))) ca.triggered.connect(partial(self.search_for_books, item)) if disable_copy_paste_search: ca.setEnabled(False) if self.table.state() == self.table.EditingState: m.addSeparator() 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( 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('{0}:"{1}"'.format( self.category, unicode_type(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) def copy_to_clipboard(self, item): cb = QApplication.clipboard() cb.setText(unicode_type(item.text())) def paste_from_clipboard(self, item): cb = QApplication.clipboard() item.setText(cb.text()) 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(unicode_type(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 = icu_lower(unicode_type(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(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 search_text_changed(self): self.search_item_row = -1 def clear_search(self): self.search_item_row = -1 self.search_box.setText('') 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(unicode_type(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.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 unicode_type( 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.ItemIsSelectable | Qt.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.ItemIsSelectable | Qt.ItemIsEditable)) self.table.setItem(row, 1, item) item = QTableWidgetItem() item.setFlags(item.flags() & ~(Qt.ItemIsSelectable | Qt.ItemIsEditable)) if _id in self.to_rename or _id in self.to_delete: item.setData(Qt.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(True) 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.fill_in_table(None, None, False) 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 = unicode_type(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.UserRole)) self.to_rename[id_] = new_text orig = self.table.item(item.row(), 2) item.setText(new_text) orig.setData(Qt.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.UserRole))) self.to_rename.pop(int(item.data(Qt.UserRole)), None) self.table.item(row, 2).setData(Qt.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.UserRole))) orig = self.table.item(col_zero_item.row(), 2) orig.setData(Qt.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([unicode_type(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.UserRole)) self.to_delete.add(id_) item.set_is_deleted(True) orig = self.table.item(item.row(), 2) orig.setData(Qt.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_Up, Qt.Key_Down, Qt.Key_J, Qt.Key_K): amount = self.scrollbar.singleStep() if key in (Qt.Key_Up, Qt.Key_K): d = -1 elif key in (Qt.Key_PageUp, Qt.Key_PageDown): amount = self.scrollbar.pageStep() if key in (Qt.Key_PageUp,): d = -1 elif key in (Qt.Key_Home, Qt.Key_End): self.scrollbar.setValue(0 if key == Qt.Key_Home else self.scrollbar.maximum()) return True elif key in (Qt.Key_N, Qt.Key_P): self.next_change(1 if key == Qt.Key_N else -1) return True if amount is not None: self.scrollbar.setValue(self.scrollbar.value() + d * amount) return True return False
class Splitter(QSplitter): state_changed = pyqtSignal(object) def __init__(self, name, label, icon, initial_show=True, initial_side_size=120, connect_button=True, orientation=Qt.Horizontal, side_index=0, parent=None, shortcut=None, hide_handle_on_single_panel=True): QSplitter.__init__(self, parent) 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.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), unicode_type(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.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 if val == 0 and not self.is_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) total = sum(self.sizes()) sizes = list(self.sizes()) for i in range(len(sizes)): sizes[i] = val if i == self.side_index else total - val self.setSizes(sizes) self.initialize() 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 EbookViewer(MainWindow): STATE_VERSION = 2 FLOW_MODE_TT = _('Switch to paged mode - where the text is broken up ' 'into pages like a paper book') PAGED_MODE_TT = _('Switch to flow mode - where the text is not broken up ' 'into pages') AUTOSAVE_INTERVAL = 10 # seconds msg_from_anotherinstance = pyqtSignal(object) def __init__(self, pathtoebook=None, debug_javascript=False, open_at=None, start_in_fullscreen=False, continue_reading=False, listener=None): MainWindow.__init__(self, debug_javascript) self.view.magnification_changed.connect(self.magnification_changed) self.closed = False self.show_toc_on_open = False self.listener = listener if listener is not None: t = Thread(name='ConnListener', target=listen, args=(self,)) t.daemon = True t.start() self.msg_from_anotherinstance.connect(self.another_instance_wants_to_talk, type=Qt.QueuedConnection) self.current_book_has_toc = False self.iterator = None self.current_page = None self.pending_search = None self.pending_search_dir= None self.pending_anchor = None self.pending_reference = None self.pending_bookmark = None self.pending_restore = False self.pending_goto_page = None self.cursor_hidden = False self.existing_bookmarks= [] self.selected_text = None self.was_maximized = False self.page_position_on_footnote_toggle = [] self.read_settings() self.autosave_timer = t = QTimer(self) t.setInterval(self.AUTOSAVE_INTERVAL * 1000), t.setSingleShot(True) t.timeout.connect(self.autosave) self.pos.value_changed.connect(self.update_pos_label) self.pos.value_changed.connect(self.autosave_timer.start) self.pos.setMinimumWidth(150) self.setFocusPolicy(Qt.StrongFocus) self.view.set_manager(self) self.pi = ProgressIndicator(self) self.action_quit = QAction(_('&Quit'), self) self.addAction(self.action_quit) self.view_resized_timer = QTimer(self) self.view_resized_timer.timeout.connect(self.viewport_resize_finished) self.view_resized_timer.setSingleShot(True) self.resize_in_progress = False self.action_reload = QAction(_('&Reload book'), self) self.action_reload.triggered.connect(self.reload_book) self.action_quit.triggered.connect(self.quit) self.action_reference_mode.triggered[bool].connect(self.view.reference_mode) self.action_metadata.triggered[bool].connect(self.metadata.setVisible) self.action_table_of_contents.toggled[bool].connect(self.set_toc_visible) self.action_copy.triggered[bool].connect(self.copy) self.action_font_size_larger.triggered.connect(self.font_size_larger) self.action_font_size_smaller.triggered.connect(self.font_size_smaller) self.action_open_ebook.triggered[bool].connect(self.open_ebook) self.action_next_page.triggered.connect(self.view.next_page) self.action_previous_page.triggered.connect(self.view.previous_page) self.action_find_next.triggered.connect(self.find_next) self.action_find_previous.triggered.connect(self.find_previous) self.action_full_screen.triggered[bool].connect(self.toggle_fullscreen) self.action_back.triggered[bool].connect(self.back) self.action_forward.triggered[bool].connect(self.forward) self.action_preferences.triggered.connect(self.do_config) self.pos.editingFinished.connect(self.goto_page_num) self.vertical_scrollbar.valueChanged[int].connect(lambda x:self.goto_page(x/100.)) self.search.search.connect(self.find) self.search.focus_to_library.connect(lambda: self.view.setFocus(Qt.OtherFocusReason)) self.toc.pressed[QModelIndex].connect(self.toc_clicked) self.toc.searched.connect(partial(self.toc_clicked, force=True)) def toggle_toc(ev): try: key = self.view.shortcuts.get_match(ev) except AttributeError: pass if key == 'Table of Contents': ev.accept() self.action_table_of_contents.trigger() return True return False self.toc.handle_shortcuts = toggle_toc self.reference.goto.connect(self.goto) self.bookmarks.edited.connect(self.bookmarks_edited) self.bookmarks.activated.connect(self.goto_bookmark) self.bookmarks.create_requested.connect(self.bookmark) self.set_bookmarks([]) self.load_theme_menu() if pathtoebook is not None: f = functools.partial(self.load_ebook, pathtoebook, open_at=open_at) QTimer.singleShot(50, f) elif continue_reading: QTimer.singleShot(50, self.continue_reading) self.window_mode_changed = None self.toggle_toolbar_action = QAction(_('Show/hide controls'), self) self.toggle_toolbar_action.setCheckable(True) self.toggle_toolbar_action.triggered.connect(self.toggle_toolbars) self.toolbar_hidden = None self.addAction(self.toggle_toolbar_action) self.full_screen_label_anim = QPropertyAnimation( self.full_screen_label, b'size') self.clock_timer = QTimer(self) self.clock_timer.timeout.connect(self.update_clock) self.action_print.triggered.connect(self.print_book) self.clear_recent_history_action = QAction( _('Clear list of recently opened books'), self) self.clear_recent_history_action.triggered.connect(self.clear_recent_history) self.build_recent_menu() self.open_history_menu.triggered.connect(self.open_recent) for x in ('tool_bar', 'tool_bar2'): x = getattr(self, x) for action in x.actions(): # So that the keyboard shortcuts for these actions will # continue to function even when the toolbars are hidden self.addAction(action) for plugin in self.view.document.all_viewer_plugins: plugin.customize_ui(self) self.view.document.settings_changed.connect(self.settings_changed) self.restore_state() self.settings_changed() self.action_toggle_paged_mode.toggled[bool].connect(self.toggle_paged_mode) if (start_in_fullscreen or self.view.document.start_in_fullscreen): self.action_full_screen.trigger() self.hide_cursor_timer = t = QTimer(self) t.setSingleShot(True), t.setInterval(3000) t.timeout.connect(self.hide_cursor) t.start() def eventFilter(self, obj, ev): if ev.type() == ev.MouseMove: if self.cursor_hidden: self.cursor_hidden = False QApplication.instance().restoreOverrideCursor() self.hide_cursor_timer.start() return False def hide_cursor(self): self.cursor_hidden = True QApplication.instance().setOverrideCursor(Qt.BlankCursor) def toggle_paged_mode(self, checked, at_start=False): in_paged_mode = not self.action_toggle_paged_mode.isChecked() self.view.document.in_paged_mode = in_paged_mode self.action_toggle_paged_mode.setToolTip(self.FLOW_MODE_TT if self.action_toggle_paged_mode.isChecked() else self.PAGED_MODE_TT) if at_start: return self.reload() def settings_changed(self): for x in ('', '2'): x = getattr(self, 'tool_bar'+x) x.setVisible(self.view.document.show_controls) def reload(self): if hasattr(self, 'current_index') and self.current_index > -1: self.view.document.page_position.save(overwrite=False) self.pending_restore = True self.load_path(self.view.last_loaded_path) def set_toc_visible(self, yes): self.toc_dock.setVisible(yes) if not yes: self.show_toc_on_open = False def clear_recent_history(self, *args): vprefs.set('viewer_open_history', []) self.build_recent_menu() def build_recent_menu(self): m = self.open_history_menu m.clear() recent = vprefs.get('viewer_open_history', []) if recent: m.addAction(self.clear_recent_history_action) m.addSeparator() count = 0 for path in recent: if count > 9: break if os.path.exists(path): m.addAction(RecentAction(path, m)) count += 1 def continue_reading(self): actions = self.open_history_menu.actions()[2:] if actions: actions[0].trigger() def shutdown(self): if self.isFullScreen() and not self.view.document.start_in_fullscreen: self.action_full_screen.trigger() return False self.save_state() if self.listener is not None: self.listener.close() return True def quit(self): if self.shutdown(): QApplication.instance().quit() def closeEvent(self, e): if self.closed: e.ignore() return if self.shutdown(): self.closed = True return MainWindow.closeEvent(self, e) else: e.ignore() def toggle_toolbars(self): for x in ('tool_bar', 'tool_bar2'): x = getattr(self, x) x.setVisible(not x.isVisible()) def save_state(self): state = bytearray(self.saveState(self.STATE_VERSION)) vprefs['main_window_state'] = state if not self.isFullScreen(): vprefs.set('viewer_window_geometry', bytearray(self.saveGeometry())) if self.current_book_has_toc: vprefs.set('viewer_toc_isvisible', self.show_toc_on_open or bool(self.toc_dock.isVisible())) vprefs['multiplier'] = self.view.multiplier vprefs['in_paged_mode'] = not self.action_toggle_paged_mode.isChecked() def restore_state(self): state = vprefs.get('main_window_state', None) if state is not None: try: state = QByteArray(state) self.restoreState(state, self.STATE_VERSION) except: pass self.initialize_dock_state() mult = vprefs.get('multiplier', None) if mult: self.view.multiplier = mult # On windows Qt lets the user hide toolbars via a right click in a very # specific location, ensure they are visible. self.tool_bar.setVisible(True) self.tool_bar2.setVisible(True) self.toc_dock.close() # This will be opened on book open, if the book has a toc and it was previously opened self.action_toggle_paged_mode.setChecked(not vprefs.get('in_paged_mode', True)) self.toggle_paged_mode(self.action_toggle_paged_mode.isChecked(), at_start=True) def lookup(self, word): from urllib import quote word = quote(word.encode('utf-8')) try: url = lookup_website(canonicalize_lang(self.view.current_language) or 'en').format(word=word) except Exception: traceback.print_exc() url = default_lookup_website(canonicalize_lang(self.view.current_language) or 'en').format(word=word) open_url(url) def print_book(self): if self.iterator is None: return error_dialog(self, _('No book opened'), _( 'Cannot print as no book is opened'), show=True) from calibre.gui2.viewer.printing import print_book print_book(self.iterator.pathtoebook, self, self.current_title) def toggle_fullscreen(self): if self.isFullScreen(): self.showNormal() else: self.showFullScreen() def showFullScreen(self): self.view.document.page_position.save() self.window_mode_changed = 'fullscreen' self.tool_bar.setVisible(False) self.tool_bar2.setVisible(False) self.was_maximized = self.isMaximized() if not self.view.document.fullscreen_scrollbar: self.vertical_scrollbar.setVisible(False) super(EbookViewer, self).showFullScreen() def show_full_screen_label(self): f = self.full_screen_label height = f.final_height width = int(0.7*self.view.width()) f.resize(width, height) if self.view.document.show_fullscreen_help: f.setVisible(True) a = self.full_screen_label_anim a.setDuration(500) a.setStartValue(QSize(width, 0)) a.setEndValue(QSize(width, height)) a.start() QTimer.singleShot(3500, self.full_screen_label.hide) if self.view.document.fullscreen_clock: self.show_clock() if self.view.document.fullscreen_pos: self.show_pos_label() self.relayout_fullscreen_labels() def show_clock(self): self.clock_label.setVisible(True) self.clock_label.setText(QTime(22, 33, 33).toString(Qt.SystemLocaleShortDate)) self.clock_timer.start(1000) self.clock_label.setStyleSheet(self.info_label_style%( 'rgba(0, 0, 0, 0)', self.view.document.colors()[1])) self.clock_label.resize(self.clock_label.sizeHint()) self.update_clock() def show_pos_label(self): self.pos_label.setVisible(True) self.pos_label.setStyleSheet(self.info_label_style%( 'rgba(0, 0, 0, 0)', self.view.document.colors()[1])) self.update_pos_label() def relayout_fullscreen_labels(self): vswidth = (self.vertical_scrollbar.width() if self.vertical_scrollbar.isVisible() else 0) p = self.pos_label p.move(15, p.parent().height() - p.height()-10) c = self.clock_label c.move(c.parent().width() - vswidth - 15 - c.width(), c.parent().height() - c.height() - 10) f = self.full_screen_label f.move((f.parent().width() - f.width())//2, (f.parent().height() - f.final_height)//2) def update_clock(self): self.clock_label.setText(QTime.currentTime().toString(Qt.SystemLocaleShortDate)) def update_pos_label(self, *args): if self.pos_label.isVisible(): try: value, maximum = args except: value, maximum = self.pos.value(), self.pos.maximum() text = '%g/%g'%(value, maximum) self.pos_label.setText(text) self.pos_label.resize(self.pos_label.sizeHint()) def showNormal(self): self.view.document.page_position.save() self.clock_label.setVisible(False) self.pos_label.setVisible(False) self.clock_timer.stop() self.vertical_scrollbar.setVisible(True) self.window_mode_changed = 'normal' self.settings_changed() self.full_screen_label.setVisible(False) if self.was_maximized: super(EbookViewer, self).showMaximized() else: super(EbookViewer, self).showNormal() def goto(self, ref): if ref: tokens = ref.split('.') if len(tokens) > 1: spine_index = int(tokens[0]) -1 if spine_index == self.current_index: self.view.goto(ref) else: self.pending_reference = ref self.load_path(self.iterator.spine[spine_index]) def goto_bookmark(self, bm): spine_index = bm['spine'] if spine_index > -1 and self.current_index == spine_index: if self.resize_in_progress: self.view.document.page_position.set_pos(bm['pos']) else: self.view.goto_bookmark(bm) # Going to a bookmark does not call scrolled() so we update the # page position explicitly. Use a timer to ensure it is # accurate. QTimer.singleShot(100, self.update_page_number) else: self.pending_bookmark = bm if spine_index < 0 or spine_index >= len(self.iterator.spine): spine_index = 0 self.pending_bookmark = None self.load_path(self.iterator.spine[spine_index]) def toc_clicked(self, index, force=False): if force or QApplication.mouseButtons() & Qt.LeftButton: item = self.toc_model.itemFromIndex(index) if item.abspath is not None: if not os.path.exists(item.abspath): return error_dialog(self, _('No such location'), _('The location pointed to by this item' ' does not exist.'), det_msg=item.abspath, show=True) url = QUrl.fromLocalFile(item.abspath) if item.fragment: url.setFragment(item.fragment) self.link_clicked(url) self.view.setFocus(Qt.OtherFocusReason) def selection_changed(self, selected_text): self.selected_text = selected_text.strip() self.action_copy.setEnabled(bool(self.selected_text)) def copy(self, x): if self.selected_text: QApplication.clipboard().setText(self.selected_text) def back(self, x): pos = self.history.back(self.pos.value()) if pos is not None: self.goto_page(pos) def goto_page_num(self): num = self.pos.value() self.goto_page(num) def forward(self, x): pos = self.history.forward(self.pos.value()) if pos is not None: self.goto_page(pos) def goto_start(self): self.goto_page(1) def goto_end(self): self.goto_page(self.pos.maximum()) def goto_page(self, new_page, loaded_check=True): if self.current_page is not None or not loaded_check: for page in self.iterator.spine: if new_page >= page.start_page and new_page <= page.max_page: try: frac = float(new_page-page.start_page)/(page.pages-1) except ZeroDivisionError: frac = 0 if page == self.current_page: self.view.scroll_to(frac) else: self.load_path(page, pos=frac) def open_ebook(self, checked): files = choose_files(self, 'ebook viewer open dialog', _('Choose ebook'), [(_('Ebooks'), available_input_formats())], all_files=False, select_only_single_file=True) if files: self.load_ebook(files[0]) def open_recent(self, action): self.load_ebook(action.path) def font_size_larger(self): self.view.magnify_fonts() def font_size_smaller(self): self.view.shrink_fonts() def magnification_changed(self, val): tt = '%(action)s [%(sc)s]\n'+_('Current magnification: %(mag).1f') sc = _(' or ').join(self.view.shortcuts.get_shortcuts('Font larger')) self.action_font_size_larger.setToolTip( tt %dict(action=unicode(self.action_font_size_larger.text()), mag=val, sc=sc)) sc = _(' or ').join(self.view.shortcuts.get_shortcuts('Font smaller')) self.action_font_size_smaller.setToolTip( tt %dict(action=unicode(self.action_font_size_smaller.text()), mag=val, sc=sc)) self.action_font_size_larger.setEnabled(self.view.multiplier < 3) self.action_font_size_smaller.setEnabled(self.view.multiplier > 0.2) def find(self, text, repeat=False, backwards=False): if not text: self.view.search('') return self.search.search_done(False) if self.view.search(text, backwards=backwards): self.scrolled(self.view.scroll_fraction) return self.search.search_done(True) index = self.iterator.search(text, self.current_index, backwards=backwards) if index is None: if self.current_index > 0: index = self.iterator.search(text, 0) if index is None: info_dialog(self, _('No matches found'), _('No matches found for: %s')%text).exec_() return self.search.search_done(True) return self.search.search_done(True) self.pending_search = text self.pending_search_dir = 'backwards' if backwards else 'forwards' self.load_path(self.iterator.spine[index]) def find_next(self): self.find(unicode(self.search.text()), repeat=True) def find_previous(self): self.find(unicode(self.search.text()), repeat=True, backwards=True) def do_search(self, text, backwards): self.pending_search = None self.pending_search_dir = None if self.view.search(text, backwards=backwards): self.scrolled(self.view.scroll_fraction) def internal_link_clicked(self, prev_pos): self.history.add(prev_pos) def link_clicked(self, url): path = os.path.abspath(unicode(url.toLocalFile())) frag = None if path in self.iterator.spine: self.update_page_number() # Ensure page number is accurate as it is used for history self.history.add(self.pos.value()) path = self.iterator.spine[self.iterator.spine.index(path)] if url.hasFragment(): frag = unicode(url.fragment()) if path != self.current_page: self.pending_anchor = frag self.load_path(path) else: oldpos = self.view.document.ypos if frag: self.view.scroll_to(frag) else: # Scroll to top self.view.scroll_to(0) if self.view.document.ypos == oldpos: # If we are coming from goto_next_section() call this will # cause another goto next section call with the next toc # entry, since this one did not cause any scrolling at all. QTimer.singleShot(10, self.update_indexing_state) else: open_url(url) def load_started(self): self.open_progress_indicator(_('Loading flow...')) def load_finished(self, ok): self.close_progress_indicator() path = self.view.path() try: index = self.iterator.spine.index(path) except (ValueError, AttributeError): return -1 self.current_page = self.iterator.spine[index] self.current_index = index self.set_page_number(self.view.scroll_fraction) QTimer.singleShot(100, self.update_indexing_state) if self.pending_search is not None: self.do_search(self.pending_search, self.pending_search_dir=='backwards') self.pending_search = None self.pending_search_dir = None if self.pending_anchor is not None: self.view.scroll_to(self.pending_anchor) self.pending_anchor = None if self.pending_reference is not None: self.view.goto(self.pending_reference) self.pending_reference = None if self.pending_bookmark is not None: self.goto_bookmark(self.pending_bookmark) self.pending_bookmark = None if self.pending_restore: self.view.document.page_position.restore() return self.current_index def goto_next_section(self): if hasattr(self, 'current_index'): entry = self.toc_model.next_entry(self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode) if entry is not None: self.pending_goto_next_section = ( self.toc_model.currently_viewed_entry, entry, False) self.toc_clicked(entry.index(), force=True) def goto_previous_section(self): if hasattr(self, 'current_index'): entry = self.toc_model.next_entry(self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, backwards=True) if entry is not None: self.pending_goto_next_section = ( self.toc_model.currently_viewed_entry, entry, True) self.toc_clicked(entry.index(), force=True) def update_indexing_state(self, anchor_positions=None): pgns = getattr(self, 'pending_goto_next_section', None) if hasattr(self, 'current_index'): if anchor_positions is None: anchor_positions = self.view.document.read_anchor_positions() items = self.toc_model.update_indexing_state(self.current_index, self.view.viewport_rect, anchor_positions, self.view.document.in_paged_mode) if items: self.toc.scrollTo(items[-1].index()) if pgns is not None: self.pending_goto_next_section = None # Check that we actually progressed if pgns[0] is self.toc_model.currently_viewed_entry: entry = self.toc_model.next_entry(self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, backwards=pgns[2], current_entry=pgns[1]) if entry is not None: self.pending_goto_next_section = ( self.toc_model.currently_viewed_entry, entry, pgns[2]) self.toc_clicked(entry.index(), force=True) def load_path(self, path, pos=0.0): self.open_progress_indicator(_('Laying out %s')%self.current_title) self.view.load_path(path, pos=pos) def footnote_visibility_changed(self, is_visible): if self.view.document.in_paged_mode: pp = namedtuple('PagePosition', 'time is_visible page_dimensions multiplier last_loaded_path page_number after_resize_page_number') self.page_position_on_footnote_toggle.append(pp( time.time(), is_visible, self.view.document.page_dimensions, self.view.multiplier, self.view.last_loaded_path, self.view.document.page_number, None)) def pre_footnote_toggle_position(self): num = len(self.page_position_on_footnote_toggle) if self.view.document.in_paged_mode and num > 1 and num % 2 == 0: two, one = self.page_position_on_footnote_toggle.pop(), self.page_position_on_footnote_toggle.pop() if ( time.time() - two.time < 1 and not two.is_visible and one.is_visible and one.last_loaded_path == two.last_loaded_path and two.last_loaded_path == self.view.last_loaded_path and one.page_dimensions == self.view.document.page_dimensions and one.multiplier == self.view.multiplier and one.after_resize_page_number == self.view.document.page_number ): return one.page_number def viewport_resize_started(self, event): if not self.resize_in_progress: # First resize, so save the current page position self.resize_in_progress = True if not self.window_mode_changed: # The special handling for window mode changed will already # have saved page position, so only save it if this is not a # mode change self.view.document.page_position.save() if self.resize_in_progress: self.view_resized_timer.start(75) def viewport_resize_finished(self): # There hasn't been a resize event for some time # restore the current page position. self.resize_in_progress = False wmc, self.window_mode_changed = self.window_mode_changed, None fs = wmc == 'fullscreen' if wmc: # Sets up body text margins, which can be limited in fs mode by a # separate config option, so must be done before relayout of text (self.view.document.switch_to_fullscreen_mode if fs else self.view.document.switch_to_window_mode)() # Re-layout text, must be done before restoring page position self.view.document.after_resize() if wmc: # This resize is part of a window mode change, special case it if fs: self.show_full_screen_label() self.view.document.page_position.restore() self.scrolled(self.view.scroll_fraction) else: if self.isFullScreen(): self.relayout_fullscreen_labels() pre_footnote_pos = self.pre_footnote_toggle_position() if pre_footnote_pos is not None: self.view.document.page_number = pre_footnote_pos else: self.view.document.page_position.restore() self.update_page_number() if len(self.page_position_on_footnote_toggle) % 2 == 1: self.page_position_on_footnote_toggle[-1] = self.page_position_on_footnote_toggle[-1]._replace( after_resize_page_number=self.view.document.page_number) if self.pending_goto_page is not None: pos, self.pending_goto_page = self.pending_goto_page, None self.goto_page(pos, loaded_check=False) def update_page_number(self): self.set_page_number(self.view.document.scroll_fraction) return self.pos.value() def close_progress_indicator(self): self.pi.stop() for o in ('tool_bar', 'tool_bar2', 'view', 'horizontal_scrollbar', 'vertical_scrollbar'): getattr(self, o).setEnabled(True) self.unsetCursor() self.view.setFocus(Qt.PopupFocusReason) def open_progress_indicator(self, msg=''): self.pi.start(msg) for o in ('tool_bar', 'tool_bar2', 'view', 'horizontal_scrollbar', 'vertical_scrollbar'): getattr(self, o).setEnabled(False) self.setCursor(Qt.BusyCursor) def load_theme_menu(self): from calibre.gui2.viewer.config import load_themes self.themes_menu.clear() for key in load_themes(): title = key[len('theme_'):] self.themes_menu.addAction(title, partial(self.load_theme, key)) def load_theme(self, theme_id): self.view.load_theme(theme_id) def do_config(self): self.view.config(self) self.load_theme_menu() if self.iterator is not None: self.iterator.copy_bookmarks_to_file = self.view.document.copy_bookmarks_to_file from calibre.gui2 import config if not config['viewer_search_history']: self.search.clear_history() def bookmark(self, *args): num = 1 bm = None while True: bm = _('Bookmark #%d')%num if bm not in self.existing_bookmarks: break num += 1 title, ok = QInputDialog.getText(self, _('Add bookmark'), _('Enter title for bookmark:'), text=bm) title = unicode(title).strip() if ok and title: bm = self.view.bookmark() bm['spine'] = self.current_index bm['title'] = title self.iterator.add_bookmark(bm) self.set_bookmarks(self.iterator.bookmarks) self.bookmarks.set_current_bookmark(bm) def autosave(self): self.save_current_position(no_copy_to_file=True) def bookmarks_edited(self, bookmarks): self.build_bookmarks_menu(bookmarks) self.iterator.set_bookmarks(bookmarks) self.iterator.save_bookmarks() def build_bookmarks_menu(self, bookmarks): self.bookmarks_menu.clear() sc = _(' or ').join(self.view.shortcuts.get_shortcuts('Bookmark')) self.bookmarks_menu.addAction(_("Bookmark this location [%s]") % sc, self.bookmark) self.bookmarks_menu.addAction(_("Show/hide Bookmarks"), self.bookmarks_dock.toggleViewAction().trigger) self.bookmarks_menu.addSeparator() current_page = None self.existing_bookmarks = [] for bm in bookmarks: if bm['title'] == 'calibre_current_page_bookmark': if self.view.document.remember_current_page: current_page = bm else: self.existing_bookmarks.append(bm['title']) self.bookmarks_menu.addAction(bm['title'], partial(self.goto_bookmark, bm)) return current_page def set_bookmarks(self, bookmarks): self.bookmarks.set_bookmarks(bookmarks) return self.build_bookmarks_menu(bookmarks) @property def current_page_bookmark(self): bm = self.view.bookmark() bm['spine'] = self.current_index bm['title'] = 'calibre_current_page_bookmark' return bm def save_current_position(self, no_copy_to_file=False): if not self.view.document.remember_current_page: return if hasattr(self, 'current_index'): try: self.iterator.add_bookmark(self.current_page_bookmark, no_copy_to_file=no_copy_to_file) except: traceback.print_exc() def another_instance_wants_to_talk(self, msg): try: path, open_at = msg except Exception: return self.load_ebook(path, open_at=open_at) self.raise_() def load_ebook(self, pathtoebook, open_at=None, reopen_at=None): if self.iterator is not None: self.save_current_position() self.iterator.__exit__() self.iterator = EbookIterator(pathtoebook, copy_bookmarks_to_file=self.view.document.copy_bookmarks_to_file) self.history.clear() self.open_progress_indicator(_('Loading ebook...')) worker = Worker(target=partial(self.iterator.__enter__, view_kepub=True)) worker.path_to_ebook = pathtoebook worker.start() while worker.isAlive(): worker.join(0.1) QApplication.processEvents() if worker.exception is not None: tb = worker.traceback.strip() if tb and tb.splitlines()[-1].startswith('DRMError:'): from calibre.gui2.dialogs.drm_error import DRMErrorMessage DRMErrorMessage(self).exec_() else: r = getattr(worker.exception, 'reason', worker.exception) error_dialog(self, _('Could not open ebook'), as_unicode(r) or _('Unknown error'), det_msg=tb, show=True) self.close_progress_indicator() else: self.metadata.show_opf(self.iterator.opf, self.iterator.book_format) self.view.current_language = self.iterator.language title = self.iterator.opf.title if not title: title = os.path.splitext(os.path.basename(pathtoebook))[0] if self.iterator.toc: self.toc_model = TOC(self.iterator.spine, self.iterator.toc) self.toc.setModel(self.toc_model) if self.show_toc_on_open: self.action_table_of_contents.setChecked(True) else: self.toc_model = TOC(self.iterator.spine) self.toc.setModel(self.toc_model) self.action_table_of_contents.setChecked(False) if isbytestring(pathtoebook): pathtoebook = force_unicode(pathtoebook, filesystem_encoding) vh = vprefs.get('viewer_open_history', []) try: vh.remove(pathtoebook) except: pass vh.insert(0, pathtoebook) vprefs.set('viewer_open_history', vh[:50]) self.build_recent_menu() self.footnotes_dock.close() self.action_table_of_contents.setDisabled(not self.iterator.toc) self.current_book_has_toc = bool(self.iterator.toc) self.current_title = title self.setWindowTitle(title + ' [%s]'%self.iterator.book_format + ' - ' + self.base_window_title) self.pos.setMaximum(sum(self.iterator.pages)) self.pos.setSuffix(' / %d'%sum(self.iterator.pages)) self.vertical_scrollbar.setMinimum(100) self.vertical_scrollbar.setMaximum(100*sum(self.iterator.pages)) self.vertical_scrollbar.setSingleStep(10) self.vertical_scrollbar.setPageStep(100) self.set_vscrollbar_value(1) self.current_index = -1 QApplication.instance().alert(self, 5000) previous = self.set_bookmarks(self.iterator.bookmarks) if reopen_at is not None: previous = reopen_at if open_at is None and previous is not None: self.goto_bookmark(previous) else: if open_at is None: self.next_document() else: if open_at > self.pos.maximum(): open_at = self.pos.maximum() if open_at < self.pos.minimum(): open_at = self.pos.minimum() if self.resize_in_progress: self.pending_goto_page = open_at else: self.goto_page(open_at, loaded_check=False) def set_vscrollbar_value(self, pagenum): self.vertical_scrollbar.blockSignals(True) self.vertical_scrollbar.setValue(int(pagenum*100)) self.vertical_scrollbar.blockSignals(False) def set_page_number(self, frac): if getattr(self, 'current_page', None) is not None: page = self.current_page.start_page + frac*float(self.current_page.pages-1) self.pos.set_value(page) self.set_vscrollbar_value(page) def scrolled(self, frac, onload=False): self.set_page_number(frac) if not onload: ap = self.view.document.read_anchor_positions() self.update_indexing_state(ap) def next_document(self): if (hasattr(self, 'current_index') and self.current_index < len(self.iterator.spine) - 1): self.load_path(self.iterator.spine[self.current_index+1]) def previous_document(self): if hasattr(self, 'current_index') and self.current_index > 0: self.load_path(self.iterator.spine[self.current_index-1], pos=1.0) def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: if self.metadata.isVisible(): self.metadata.setVisible(False) event.accept() return if self.isFullScreen(): self.action_full_screen.trigger() event.accept() return try: key = self.view.shortcuts.get_match(event) except AttributeError: return MainWindow.keyPressEvent(self, event) try: bac = self.bookmarks_menu.actions()[0] except (AttributeError, TypeError, IndexError, KeyError): bac = None action = { 'Quit':self.action_quit, 'Show metadata':self.action_metadata, 'Copy':self.view.copy_action, 'Font larger': self.action_font_size_larger, 'Font smaller': self.action_font_size_smaller, 'Fullscreen': self.action_full_screen, 'Find next': self.action_find_next, 'Find previous': self.action_find_previous, 'Search online': self.view.search_online_action, 'Lookup word': self.view.dictionary_action, 'Next occurrence': self.view.search_action, 'Bookmark': bac, 'Reload': self.action_reload, 'Table of Contents': self.action_table_of_contents, 'Print': self.action_print, }.get(key, None) if action is not None: event.accept() action.trigger() return if key == 'Focus Search': self.search.setFocus(Qt.OtherFocusReason) return if not self.view.handle_key_press(event): event.ignore() def reload_book(self): if getattr(self.iterator, 'pathtoebook', None): try: reopen_at = self.current_page_bookmark except Exception: reopen_at = None self.history.clear() self.load_ebook(self.iterator.pathtoebook, reopen_at=reopen_at) return def __enter__(self): return self def __exit__(self, *args): if self.iterator is not None: self.save_current_position() self.iterator.__exit__(*args) def read_settings(self): c = config().parse() if c.remember_window_size: wg = vprefs.get('viewer_window_geometry', None) if wg is not None: self.restoreGeometry(wg) self.show_toc_on_open = vprefs.get('viewer_toc_isvisible', False) desktop = QApplication.instance().desktop() av = desktop.availableGeometry(self).height() - 30 if self.height() > av: self.resize(self.width(), av) def show_footnote_view(self): self.footnotes_dock.show()
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): QComboBox.__init__(self, parent) self.normal_background = 'rgb(255, 255, 255, 0%)' self.line_edit = SearchLineEdit(self) self.setLineEdit(self.line_edit) c = self.line_edit.completer() c.setCompletionMode(c.PopupCompletion) c.highlighted[str].connect(self.completer_used) self.line_edit.key_pressed.connect(self.key_pressed, type=Qt.DirectConnection) # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807 self.activated[str].connect(self.history_selected, type=Qt.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.QueuedConnection) self.setInsertPolicy(self.NoInsert) self.setMaxCount(self.MAX_COUNT) self.setSizeAdjustPolicy(self.AdjustToMinimumContentsLengthWithIcon) self.setMinimumContentsLength(25) self._in_a_search = False self.tool_tip_text = self.toolTip() def initialize(self, opt_name, colorize=False, help_text=_('Search')): self.as_you_type = config['search_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 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( 'QLineEdit{color:none;background-color:%s;}' % self.normal_background) def text(self): return self.currentText() def clear_history(self, *args): QComboBox.clear(self) 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() def search_done(self, ok): if isinstance(ok, basestring): self.setToolTip(ok) ok = False if not unicode(self.currentText()).strip(): self.clear(emit_search=False) return self._in_a_search = ok col = 'rgba(0,255,0,20%)' if ok else 'rgb(255,0,0,20%)' if not self.colorize: col = self.normal_background self.line_edit.setStyleSheet('QLineEdit{color:black;background-color:%s;}' % col) # Comes from the lineEdit control def key_pressed(self, event): k = event.key() if k in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down, Qt.Key_Home, Qt.Key_End, Qt.Key_PageUp, Qt.Key_PageDown, Qt.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_Return, Qt.Key_Enter): self.do_search() self.focus_to_library.emit() elif self.as_you_type and unicode(event.text()): self.timer.start(1500) # Comes from the combobox itself def keyPressEvent(self, event): k = event.key() if k in (Qt.Key_Enter, Qt.Key_Return): return self.do_search() if k not in (Qt.Key_Up, Qt.Key_Down): QComboBox.keyPressEvent(self, event) else: self.blockSignals(True) self.normalize_state() 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() def history_selected(self, text): self.changed.emit() self.do_search() def _do_search(self, store_in_history=True): self.hide_completer_popup() text = unicode(self.currentText()).strip() if not text: return self.clear() self.search.emit(text) if store_in_history: idx = self.findText(text, Qt.MatchFixedString|Qt.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(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[str].disconnect() try: self.setFocus(Qt.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.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.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[str].connect(self.history_selected, type=Qt.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(self.lineEdit().text())
class GridView(QListView): update_item = pyqtSignal(object) files_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(self.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(self.Batched) self.setResizeMode(self.Adjust) self.setSelectionMode(self.ExtendedSelection) self.setVerticalScrollMode(self.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.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.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 xrange(geom.top(), (self.spacing() * 2) + geom.top(), 5): for x in xrange(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 xrange(geom.bottom(), geom.bottom() - 2 * self.spacing(), -5): for x in xrange(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 xrange(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'] pal = QPalette() col = QColor(r, g, b) pal.setColor(pal.Base, col) tex = gprefs['cover_grid_texture'] if tex: from calibre.gui2.preferences.texture_chooser import texture_path path = texture_path(tex) if path: 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) pal.setBrush(pal.Base, QBrush(pm)) dark = (r + g + b) / 3.0 < 128 pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black)) self.setPalette(pal) self.delegate.highlight_color = pal.color(pal.Text) 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() if size_changed: 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())) 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 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 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') dpr = self.device_pixel_ratio 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, int(dpr * self.delegate.cover_size.width()), int(dpr * self.delegate.cover_size.height())) if scaled: if self.ignore_render_requests.is_set(): return p = p.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.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): group = list(map(operator.itemgetter(1), g)) sel.merge( QItemSelection(m.index(min(group), 0), m.index(max(group), 0)), sm.Select) sm.select(sel, sm.ClearAndSelect)
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.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.Ok).setText(_('&OK')) self.buttonBox.button(QDialogButtonBox.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.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.StyledPanel) l.setAutoFillBackground(True) l.setText(_('No matches found')) l.setAlignment(Qt.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.QueuedConnection) self.table.setContextMenuPolicy(Qt.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')) 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): filter_text = icu_lower(unicode_type(self.filter_box.text())) auts_to_show = [] for t in self.find_aut_func( use_virtual_library=self.apply_vl_checkbox.isChecked()): if self.string_contains(filter_text, icu_lower(t[1])): auts_to_show.append(t[0]) 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']) orig = self.original_authors[id_] name = name.replace('|', ',') name_item = tableItem(name) name_item.setData(Qt.UserRole, id_) if name != orig['name']: name_item.setIcon(self.edited_icon) sort_item = tableItem(sort) if sort != orig['sort']: sort_item.setIcon(self.edited_icon) link_item = tableItem(link) if link != orig['link']: link_item.setIcon(self.edited_icon) self.table.setItem(row, 0, name_item) self.table.setItem(row, 1, sort_item) self.table.setItem(row, 2, link_item) 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 if id_to_select: select_item = None use_as = tweaks['categories_use_field_for_author_name'] for row in range(0, len(auts_to_show)): name_item = self.table.item( row, 1) if use_as else self.table.item(row, 0) if is_first_letter: if primary_startswith(name_item.text(), id_to_select): select_item = self.table.item(row, 1) break elif id_to_select == self.table.item(row, 0).data(Qt.UserRole): if select_sort: select_item = self.table.item(row, 1) elif select_link: select_item = self.table.item(row, 2) else: select_item = name_item break if select_item: self.table.setCurrentItem(select_item) 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.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 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() 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 search_in_book_list(self): from calibre.gui2.ui import get_gui row = self.context_item.row() get_gui().search.set_search_string(self.table.item(row, 0).text()) 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.Ok).setDefault(False) self.buttonBox.button(QDialogButtonBox.Ok).setAutoDefault(False) self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(False) self.buttonBox.button(QDialogButtonBox.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.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(',') if aus != self.original_authors[id_]['sort']: item_aus.setIcon(self.edited_icon) item_aus.setText(aus) self.authors[id_]['sort'] = aus self.table.setFocus(Qt.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.UserRole)) if aus != self.original_authors[id_]['name']: item_aut.setIcon(self.edited_icon) item_aut.setText(aus) self.authors[id_]['name'] = aus self.table.setFocus(Qt.OtherFocusReason) self.table.cellChanged.connect(self.cell_changed) def cell_changed(self, row, col): id_ = int(self.table.item(row, 0).data(Qt.UserRole)) if col == 0: item = self.table.item(row, 0) item.setIcon(self.edited_icon) 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) self.authors[id_]['name'] = aut c = self.table.item(row, 1) txt = author_to_author_sort(aut) c.setText(txt) self.authors[id_]['sort'] = txt item = c else: item = self.table.item(row, col) item.setIcon(self.edited_icon) if col == 1: self.authors[id_]['sort'] = unicode_type(item.text()) else: self.authors[id_]['link'] = unicode_type(item.text()) self.table.setCurrentItem(item) self.table.scrollToItem(item)
class TagListEditor(QDialog, Ui_TagListEditor): def __init__(self, window, cat_name, tag_to_match, get_book_ids, sorter): QDialog.__init__(self, window) Ui_TagListEditor.__init__(self) self.setupUi(self) self.search_box.setMinimumContentsLength(25) # Put the category name into the title bar t = self.windowTitle() self.category_name = cat_name self.setWindowTitle(t + ' (' + cat_name + ')') # Remove help icon on title bar icon = self.windowIcon() self.setWindowFlags(self.windowFlags()&(~Qt.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['use_primary_find_in_search']: self.string_contains = primary_contains else: self.string_contains = contains self.delete_button.clicked.connect(self.delete_tags) 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.Ok).setText(_('&OK')) self.buttonBox.button(QDialogButtonBox.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) le.returnPressed.connect(self.do_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.StyledPanel) l.setAutoFillBackground(True) l.setText(_('No matches found')) l.setAlignment(Qt.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.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(QTableWidget.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 # Add the data self.search_item_row = -1 self.fill_in_table(None, tag_to_match) def vl_box_changed(self): self.search_item_row = -1 self.fill_in_table(None, None) def do_search(self): self.not_found_label.setVisible(False) find_text = icu_lower(unicode_type(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.all_tags[self.ordered_tags[r]]['cur_name']): self.table.setCurrentItem(self.table.item(r, 0)) 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 search_text_changed(self): self.search_item_row = -1 def clear_search(self): self.search_item_row = -1 self.search_box.setText('') def fill_in_table(self, tags, tag_to_match): data = self.get_book_ids(self.apply_vl_checkbox.isChecked()) self.all_tags = {} filter_text = icu_lower(unicode_type(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 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() item.set_is_deleted(self.all_tags[tag]['is_deleted']) _id = self.all_tags[tag]['key'] item.setData(Qt.UserRole, _id) item.set_initial_text(tag) if _id in self.to_rename: item.setText(self.to_rename[_id]) else: item.setText(tag) item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) self.table.setItem(row, 0, item) if 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.ItemIsSelectable|Qt.ItemIsEditable)) self.table.setItem(row, 1, item) item = QTableWidgetItem() item.setFlags(item.flags() & ~(Qt.ItemIsSelectable|Qt.ItemIsEditable)) if _id in self.to_rename or _id in self.to_delete: item.setData(Qt.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.start_find_pos = select_item.row() else: self.table.setCurrentCell(0, 0) 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.fill_in_table(None, None) def do_filter(self): self.fill_in_table(None, None) 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 items = self.table.selectedItems() self.table.blockSignals(True) for item in items: id_ = int(item.data(Qt.UserRole)) self.to_rename[id_] = unicode_type(edited_item.text()) orig = self.table.item(item.row(), 2) item.setText(edited_item.text()) orig.setData(Qt.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.UserRole))) self.to_rename.pop(int(item.data(Qt.UserRole)), None) self.table.item(row, 2).setData(Qt.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 col_zero_item = self.table.item(item.row(), 0) if col_zero_item.is_deleted: if not question_dialog(self, _('Undelete item?'), '<p>'+_('That item is deleted. Do you want to undelete it?')+'<br>'): return col_zero_item.set_is_deleted(False) self.to_delete.discard(int(col_zero_item.data(Qt.UserRole))) orig = self.table.item(col_zero_item.row(), 2) self.table.blockSignals(True) orig.setData(Qt.DisplayRole, '') self.table.blockSignals(False) else: self.table.editItem(item) 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 = [] to_undel = [] for item in deletes: if item.is_deleted: to_undel.append(item) else: to_del.append(item) if to_del: ct = ', '.join([unicode_type(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 if to_undel: ct = ', '.join([unicode_type(item.text()) for item in to_undel]) if not confirm( '<p>'+_('Are you sure you want to undelete the following items?')+'<br>'+ct, 'tag_list_editor_undelete'): return row = self.table.row(deletes[0]) for item in deletes: if item.is_deleted: item.set_is_deleted(False) self.to_delete.discard(int(item.data(Qt.UserRole))) orig = self.table.item(item.row(), 2) self.table.blockSignals(True) orig.setData(Qt.DisplayRole, '') self.table.blockSignals(False) else: id = int(item.data(Qt.UserRole)) self.to_delete.add(id) item.set_is_deleted(True) orig = self.table.item(item.row(), 2) self.table.blockSignals(True) orig.setData(Qt.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 mainWindow(QMainWindow): def __init__(self): super().__init__() self.D = None self.candidateFrm = QFrame(self) self.candidateGrid = QGridLayout() self.candidateList = [] self.prefix = "[" self.suffix = "]" self.TEXTWIDTH = 18 self.clpb = QApplication.clipboard() self.options = [] self.setFont(QFont('microsoft Yahei', font_size)) self.setWindowTitle(conf['Text', 'windowtitle']) self.file_path = '' #self.maxLineWidthpx = QApplication.desktop().availableGeometry().width() // 2 #self.maxLineWidth = self.maxLineWidthpx // (font_size * 4 //3) # 1px = 0.75point self.maxLineWidth = conf['Intval', 'LineWidth'] # in char self.wheelLock = False self.ticker = 0 self.lockTimer = QTimer(self) self.lockTimer.setSingleShot(False) self.lockTimer.setInterval(800) self.lockTimer.timeout.connect(self.unlockwheel) self.RoomIDinput = QLineEdit(self) numbers = QRegExpValidator(QRegExp("[0-9]{0,13}$")) self.RoomIDinput.setValidator(numbers) self.userWarnLabel = QLabel(self) self.Xffix = QLineEdit(self) self.Xffix.setPlaceholderText("前缀~后缀") self.UsingDictFile = QLabel(self) self.UsingDictFile.setText("meanings:") self.Textarea = Alt_textArea(self) self.Textarea.textChanged.connect(self.highlightText) self.Textarea.PickDict.connect(self.setCandidates) self.Textarea.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) """ font = self.Textarea.document().defaultFont() # or another font if you change it fontMetrics = QtGui.QFontMetrics(font) # a QFontMetrics based on our font textSize = fontMetrics.size(0, "这是一个十八个字长的汉字构成的字符串") self.Textarea.setFixedWidth(textSize.width()) """ self.placeholder = QLabel(self) self.placeholder.setText(placeholderSpaces) self.placeholderL = QLabel(self) self.placeholderL.setText(placeholderSpaces[:12]) self.cookieText = QTextEdit(self) self.cookieText.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.MeaningList = QLabel(self) self.MeaningList.setTextInteractionFlags(Qt.TextSelectableByMouse) #self.LRTOut.setMaximumWidth(self.maxLineWidth) #self.LRTOut.setWordWrap(True) self.LHintFile = QLabel(self) self.LHintFile.setText(conf['Text', 'hintFile']) self.LFileName = QLabel(self) self.markBtn = QPushButton(conf['Text', 'Bmark'], self) self.coolDown = QProgressBar(self) self.browseBtn = QPushButton(conf['Text', 'Bbrowse'], self) self.browseBtn.clicked.connect(self.browse) self.sendBtn = QPushButton("发射!", self) self.sendBtn.clicked.connect(self.sendLine) self.genFileBtn = QPushButton(conf['Text', 'Bgenerate'], self) self.settingLock = Toggle(self, text_false="", text_true="") self.settingLock.clicked.connect(self.settingStatusCheck) self.alwaysOnTop = Toggle(self) self.alwaysOnTop.clicked.connect(self.windowRefresh) self.aboutBtn = QPushButton(conf['Text', 'Babout'], self) self.aboutBtn.clicked.connect(self.about) self.QuitBtn = QPushButton(conf['Text', 'Bquit'], self) self.QuitBtn.clicked.connect(self.close) self.Cwidget = QFrame(self) self.initUI() self.lockTimer.start() self.wflags = self.windowFlags() self.alwaysOnTopFlag = False self.reqHandler = DanmuHandler(0) #invalid def initUI(self): for key in self.__dict__: if isinstance(self.__dict__[key], QWidget): self.__dict__[key].setObjectName(key) #print(key) self.setCentralWidget(self.Cwidget) grid = QGridLayout() grid.setSpacing(5) self.Cwidget.setLayout(grid) self.candidateGrid.setSpacing(5) grid.addWidget(QLabel("锁定房间", self), 0, 0) grid.addWidget(self.settingLock, 0, 0) grid.addWidget(self.placeholderL, 7, 0) grid.addWidget(self.cookieText, 1, 0) grid.addWidget(self.RoomIDinput, 2, 0) grid.addWidget(self.Xffix, 3, 0) grid.addWidget(self.userWarnLabel, 5, 0) grid.addWidget(QLabel("总在最前", self), 6, 0) grid.addWidget(self.alwaysOnTop, 6, 0) grid.addWidget(self.UsingDictFile, 0, 1) grid.addWidget(self.Textarea, 1, 1) grid.addWidget(self.sendBtn, 1, 2) grid.addWidget(self.MeaningList, 2, 1) grid.addWidget(self.candidateFrm, 4, 1) self.candidateFrm.setLayout(self.candidateGrid) #grid.addWidget(self.LHintFile, 4, 0) #grid.addWidget(self.genFileBtn, 4, 1) grid.addWidget(self.LFileName, 0, 1) grid.addWidget(self.browseBtn, 0, 2) grid.addWidget(self.placeholder, 7, 1) grid.addWidget(self.aboutBtn, 6, 2) grid.addWidget(self.QuitBtn, 7, 2) self.setLayout(grid) self.setGeometry(300, 300, 350, 300) #print(conf['customFile','stylish']) #self.setStyleSheet('QPushButton{color:red;}') self.setStyleSheet(conf['customFile', 'stylish']) self.Textarea.setWordWrapMode(QTextOption.NoWrap) self.Textarea.textCursor().setKeepPositionOnInsert(False) def settingStatusCheck(self): settingLock = self.settingLock.isChecked() ### self.cookieText.setDisabled(settingLock) self.RoomIDinput.setDisabled(settingLock) if settingLock: try: self.reqHandler.setCookie(self.cookieText.toPlainText()) except KeyError as e: self.userWarning("Cookie不合法") return if len(self.RoomIDinput.text()) < 1: self.userWarning("直播间未定义") return self.reqHandler.setRoom(int(self.RoomIDinput.text())) print(self.reqHandler.roomID) ############### if not self.reqHandler.touch(): self.userWarning("无法连接到直播间") return xffix = self.Xffix.text() if len(xffix) > 0: self.alt_Xffix(xffix) self.userWarning("") #safe def alt_Xffix(self, s: str): if len(s) >= 19: self.userWarning("前后缀太长") return if "~" not in s: self.userWarning("前后缀不合法,请使用~分割前后缀") return l = s.split("~") self.prefix = l[-2] self.suffix = l[-1] self.TEXTWIDTH = 20 - len(self.prefix) - len(self.suffix) def userWarning(self, warning: str): self.userWarnLabel.setText(warning) def UIrefresh(self): self.ticker = (self.ticker + 1) % 50 ### if self.ticker >= 49: self.unlockwheel() def windowRefresh(self): if self.alwaysOnTop.isChecked() and self.alwaysOnTopFlag == False: self.setWindowFlags(self.wflags | Qt.WindowStaysOnTopHint) self.show() self.alwaysOnTopFlag = True return elif self.alwaysOnTop.isChecked() == False and self.alwaysOnTopFlag: self.setWindowFlags(self.wflags) self.show() self.alwaysOnTopFlag = False return def cpText(self): pass def clearCandidates(self): while self.candidateGrid.count(): child = self.candidateGrid.takeAt(0) if child.widget(): child.widget().deleteLater() def highlightText(self): self.Textarea.blockSignals(True) _cur = self.Textarea.textCursor() _curpos = _cur.position() _s = self.Textarea.toPlainText() _r = utils.toFixedLns(_s, self.TEXTWIDTH) self.Textarea.setText(_r) _newcur = QTextCursor(self.Textarea.document()) _newcur.setPosition(_curpos) self.Textarea.setTextCursor(_newcur) """ print(_curpos) self.Textarea.textCursor().setPosition(_curpos) print(self.Textarea.textCursor().position()) """ self.Textarea.blockSignals(False) #print(self.Textarea.textCursor().position()) @pyqtSlot(str) def pickWord(self, word): m = self.D.Meaning4name(word) _m = m[0] #assume len(m) > 0 self.Textarea.textCursor().insertText(_m) self.MeaningList.setText(word + " : " + " ".join(m)) def makeBtn(self, word): B = QPushButton(self.candidateFrm) B.setText(word) B.setFont(QFont('Courier New', 15)) B.clicked.connect(lambda: self.pickWord(word)) return B def popLine(self): L, S = utils.popLns(self.Textarea.toPlainText(), self.prefix, self.suffix) self.clpb.setText(L) self.Textarea.setPlainText(S) def setCandidates(self, key): if not self.D: print("D not specified") self.LFileName.setText("请选择字典文件") return words = self.D.ListKey(key) # dict {word: [meaning1 , meaning2, ]} self.clearCandidates() for word in words: # word self.candidateGrid.addWidget(self.makeBtn(word)) def sendLine(self): L, S = utils.popLns(self.Textarea.toPlainText(), self.prefix, self.suffix) self.send_cmt(L) self.Textarea.setPlainText(S) def send_cmt(self, cmt: str): self.reqHandler.make_req(cmt) def wheelEvent(self, a0: QWheelEvent) -> None: #print(a0.angleDelta().y()) if self.wheelLock: return else: self.popLine() self.wheelLock = True self.lockTimer.start() def unlockwheel(self): self.wheelLock = False def mousePressEvent(self, a0: QMouseEvent) -> None: pass #print(a0.angleDelta().y()) def about(self): QMessageBox.about( self, 'about', '<p align=\"center\">' + HTMLify(about_text) + "</p>") def browse(self): self.file_path, filetype = QFileDialog.getOpenFileName( self, "Source File") #if filetype self.D = dictRead.Dict(fileName=self.file_path) hint = os.path.basename(self.file_path) + " - " + self.D.getAuthor( ) + " - " + self.D.getTime() self.LFileName.setText(hint) #self.Textarea.PickDict.disconnect() self.Textarea.PickDict.connect(self.D.ListKey)
class MaterialManager(QObject): materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated. def __init__(self, container_registry, parent = None): super().__init__(parent) self._application = Application.getInstance() self._container_registry = container_registry # type: ContainerRegistry self._fallback_materials_map = dict() # material_type -> generic material metadata self._material_group_map = dict() # root_material_id -> MaterialGroup self._diameter_machine_variant_material_map = dict() # approximate diameter str -> dict(machine_definition_id -> MaterialNode) # We're using these two maps to convert between the specific diameter material id and the generic material id # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant # i.e. generic_pla -> generic_pla_175 self._material_diameter_map = defaultdict(dict) # root_material_id -> approximate diameter str -> root_material_id for that diameter self._diameter_material_map = dict() # material id including diameter (generic_pla_175) -> material root id (generic_pla) # This is used in Legacy UM3 send material function and the material management page. self._guid_material_groups_map = defaultdict(list) # GUID -> a list of material_groups # The machine definition ID for the non-machine-specific materials. # This is used as the last fallback option if the given machine-specific material(s) cannot be found. self._default_machine_definition_id = "fdmprinter" self._default_approximate_diameter_for_quality_search = "3" # When a material gets added/imported, there can be more than one InstanceContainers. In those cases, we don't # want to react on every container/metadata changed signal. The timer here is to buffer it a bit so we don't # react too many time. self._update_timer = QTimer(self) self._update_timer.setInterval(300) self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._updateMaps) self._container_registry.containerMetaDataChanged.connect(self._onContainerMetadataChanged) self._container_registry.containerAdded.connect(self._onContainerMetadataChanged) self._container_registry.containerRemoved.connect(self._onContainerMetadataChanged) def initialize(self): # Find all materials and put them in a matrix for quick search. material_metadatas = {metadata["id"]: metadata for metadata in self._container_registry.findContainersMetadata(type = "material") if metadata.get("GUID")} self._material_group_map = dict() # Map #1 # root_material_id -> MaterialGroup for material_id, material_metadata in material_metadatas.items(): # We don't store empty material in the lookup tables if material_id == "empty_material": continue root_material_id = material_metadata.get("base_file") if root_material_id not in self._material_group_map: self._material_group_map[root_material_id] = MaterialGroup(root_material_id, MaterialNode(material_metadatas[root_material_id])) self._material_group_map[root_material_id].is_read_only = self._container_registry.isReadOnly(root_material_id) group = self._material_group_map[root_material_id] # Store this material in the group of the appropriate root material. if material_id != root_material_id: new_node = MaterialNode(material_metadata) group.derived_material_node_list.append(new_node) # Order this map alphabetically so it's easier to navigate in a debugger self._material_group_map = OrderedDict(sorted(self._material_group_map.items(), key = lambda x: x[0])) # Map #1.5 # GUID -> material group list self._guid_material_groups_map = defaultdict(list) for root_material_id, material_group in self._material_group_map.items(): guid = material_group.root_material_node.metadata["GUID"] self._guid_material_groups_map[guid].append(material_group) # Map #2 # Lookup table for material type -> fallback material metadata, only for read-only materials grouped_by_type_dict = dict() material_types_without_fallback = set() for root_material_id, material_node in self._material_group_map.items(): material_type = material_node.root_material_node.metadata["material"] if material_type not in grouped_by_type_dict: grouped_by_type_dict[material_type] = {"generic": None, "others": []} material_types_without_fallback.add(material_type) brand = material_node.root_material_node.metadata["brand"] if brand.lower() == "generic": to_add = True if material_type in grouped_by_type_dict: diameter = material_node.root_material_node.metadata.get("approximate_diameter") if diameter != self._default_approximate_diameter_for_quality_search: to_add = False # don't add if it's not the default diameter if to_add: # Checking this first allow us to differentiate between not read only materials: # - if it's in the list, it means that is a new material without fallback # - if it is not, then it is a custom material with a fallback material (parent) if material_type in material_types_without_fallback: grouped_by_type_dict[material_type] = material_node.root_material_node.metadata material_types_without_fallback.remove(material_type) # Remove the materials that have no fallback materials for material_type in material_types_without_fallback: del grouped_by_type_dict[material_type] self._fallback_materials_map = grouped_by_type_dict # Map #3 # There can be multiple material profiles for the same material with different diameters, such as "generic_pla" # and "generic_pla_175". This is inconvenient when we do material-specific quality lookup because a quality can # be for either "generic_pla" or "generic_pla_175", but not both. This map helps to get the correct material ID # for quality search. self._material_diameter_map = defaultdict(dict) self._diameter_material_map = dict() # Group the material IDs by the same name, material, brand, and color but with different diameters. material_group_dict = dict() keys_to_fetch = ("name", "material", "brand", "color") for root_material_id, machine_node in self._material_group_map.items(): root_material_metadata = machine_node.root_material_node.metadata key_data = [] for key in keys_to_fetch: key_data.append(root_material_metadata.get(key)) key_data = tuple(key_data) # If the key_data doesn't exist, it doesn't matter if the material is read only... if key_data not in material_group_dict: material_group_dict[key_data] = dict() else: # ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it if not machine_node.is_read_only: continue approximate_diameter = root_material_metadata.get("approximate_diameter") material_group_dict[key_data][approximate_diameter] = root_material_metadata["id"] # Map [root_material_id][diameter] -> root_material_id for this diameter for data_dict in material_group_dict.values(): for root_material_id1 in data_dict.values(): if root_material_id1 in self._material_diameter_map: continue diameter_map = data_dict for root_material_id2 in data_dict.values(): self._material_diameter_map[root_material_id2] = diameter_map default_root_material_id = data_dict.get(self._default_approximate_diameter_for_quality_search) if default_root_material_id is None: default_root_material_id = list(data_dict.values())[0] # no default diameter present, just take "the" only one for root_material_id in data_dict.values(): self._diameter_material_map[root_material_id] = default_root_material_id # Map #4 # "machine" -> "variant_name" -> "root material ID" -> specific material InstanceContainer # Construct the "machine" -> "variant" -> "root material ID" -> specific material InstanceContainer self._diameter_machine_variant_material_map = dict() for material_metadata in material_metadatas.values(): # We don't store empty material in the lookup tables if material_metadata["id"] == "empty_material": continue root_material_id = material_metadata["base_file"] definition = material_metadata["definition"] approximate_diameter = material_metadata["approximate_diameter"] if approximate_diameter not in self._diameter_machine_variant_material_map: self._diameter_machine_variant_material_map[approximate_diameter] = {} machine_variant_material_map = self._diameter_machine_variant_material_map[approximate_diameter] if definition not in machine_variant_material_map: machine_variant_material_map[definition] = MaterialNode() machine_node = machine_variant_material_map[definition] variant_name = material_metadata.get("variant_name") if not variant_name: # if there is no variant, this material is for the machine, so put its metadata in the machine node. machine_node.material_map[root_material_id] = MaterialNode(material_metadata) else: # this material is variant-specific, so we save it in a variant-specific node under the # machine-specific node # Check first if the variant exist in the manager existing_variant = self._application.getVariantManager().getVariantNode(definition, variant_name) if existing_variant is not None: if variant_name not in machine_node.children_map: machine_node.children_map[variant_name] = MaterialNode() variant_node = machine_node.children_map[variant_name] if root_material_id in variant_node.material_map: # We shouldn't have duplicated variant-specific materials for the same machine. ConfigurationErrorMessage.getInstance().addFaultyContainers(root_material_id) continue variant_node.material_map[root_material_id] = MaterialNode(material_metadata) else: # Add this container id to the wrong containers list in the registry Logger.log("w", "Not adding {id} to the material manager because the variant does not exist.".format(id = material_metadata["id"])) self._container_registry.addWrongContainerId(material_metadata["id"]) self.materialsUpdated.emit() def _updateMaps(self): Logger.log("i", "Updating material lookup data ...") self.initialize() def _onContainerMetadataChanged(self, container): self._onContainerChanged(container) def _onContainerChanged(self, container): container_type = container.getMetaDataEntry("type") if container_type != "material": return # update the maps self._update_timer.start() def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]: return self._material_group_map.get(root_material_id) def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str: return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id) def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str: return self._diameter_material_map.get(root_material_id) def getMaterialGroupListByGUID(self, guid: str) -> Optional[list]: return self._guid_material_groups_map.get(guid) # # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup. # def getAvailableMaterials(self, machine_definition: "DefinitionContainer", extruder_variant_name: Optional[str], diameter: float) -> dict: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_variant_material_map: Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter) return dict() machine_definition_id = machine_definition.getId() # If there are variant materials, get the variant material machine_variant_material_map = self._diameter_machine_variant_material_map[rounded_diameter] machine_node = machine_variant_material_map.get(machine_definition_id) default_machine_node = machine_variant_material_map.get(self._default_machine_definition_id) variant_node = None if extruder_variant_name is not None and machine_node is not None: variant_node = machine_node.getChildNode(extruder_variant_name) nodes_to_check = [variant_node, machine_node, default_machine_node] # Fallback mechanism of finding materials: # 1. variant-specific material # 2. machine-specific material # 3. generic material (for fdmprinter) machine_exclude_materials = machine_definition.getMetaDataEntry("exclude_materials", []) material_id_metadata_dict = dict() for node in nodes_to_check: if node is not None: # Only exclude the materials that are explicitly specified in the "exclude_materials" field. # Do not exclude other materials that are of the same type. for material_id, node in node.material_map.items(): if material_id in machine_exclude_materials: Logger.log("d", "Exclude material [%s] for machine [%s]", material_id, machine_definition.getId()) continue if material_id not in material_id_metadata_dict: material_id_metadata_dict[material_id] = node return material_id_metadata_dict # # A convenience function to get available materials for the given machine with the extruder position. # def getAvailableMaterialsForMachineExtruder(self, machine: "GlobalStack", extruder_stack: "ExtruderStack") -> Optional[dict]: variant_name = None if extruder_stack.variant.getId() != "empty_variant": variant_name = extruder_stack.variant.getName() diameter = extruder_stack.approximateMaterialDiameter # Fetch the available materials (ContainerNode) for the current active machine and extruder setup. return self.getAvailableMaterials(machine.definition, variant_name, diameter) # # Gets MaterialNode for the given extruder and machine with the given material name. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNode(self, machine_definition_id: str, extruder_variant_name: Optional[str], diameter: float, root_material_id: str) -> Optional["InstanceContainer"]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_variant_material_map: Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]", diameter, rounded_diameter, root_material_id) return None # If there are variant materials, get the variant material machine_variant_material_map = self._diameter_machine_variant_material_map[rounded_diameter] machine_node = machine_variant_material_map.get(machine_definition_id) variant_node = None # Fallback for "fdmprinter" if the machine-specific materials cannot be found if machine_node is None: machine_node = machine_variant_material_map.get(self._default_machine_definition_id) if machine_node is not None and extruder_variant_name is not None: variant_node = machine_node.getChildNode(extruder_variant_name) # Fallback mechanism of finding materials: # 1. variant-specific material # 2. machine-specific material # 3. generic material (for fdmprinter) nodes_to_check = [variant_node, machine_node, machine_variant_material_map.get(self._default_machine_definition_id)] material_node = None for node in nodes_to_check: if node is not None: material_node = node.material_map.get(root_material_id) if material_node: break return material_node # # Gets MaterialNode for the given extruder and machine with the given material type. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNodeByType(self, global_stack: "GlobalStack", extruder_variant_name: str, material_guid: str) -> Optional["MaterialNode"]: node = None machine_definition = global_stack.definition if parseBool(machine_definition.getMetaDataEntry("has_materials", False)): material_diameter = machine_definition.getProperty("material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) # Look at the guid to material dictionary root_material_id = None for material_group in self._guid_material_groups_map[material_guid]: if material_group.is_read_only: root_material_id = material_group.root_material_node.metadata["id"] break if not root_material_id: Logger.log("i", "Cannot find materials with guid [%s] ", material_guid) return None node = self.getMaterialNode(machine_definition.getId(), extruder_variant_name, material_diameter, root_material_id) return node # # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla". # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use # the generic material IDs to search for qualities. # # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will # be "generic_pla". This function is intended to get a generic fallback material for the given material type. # # This function returns the generic root material ID for the given material type, where material types are "PLA", # "ABS", etc. # def getFallbackMaterialIdByMaterialType(self, material_type: str) -> Optional[str]: # For safety if material_type not in self._fallback_materials_map: Logger.log("w", "The material type [%s] does not have a fallback material" % material_type) return None fallback_material = self._fallback_materials_map[material_type] if fallback_material: return self.getRootMaterialIDWithoutDiameter(fallback_material["id"]) else: return None def getDefaultMaterial(self, global_stack: "GlobalStack", extruder_variant_name: Optional[str]) -> Optional["MaterialNode"]: node = None machine_definition = global_stack.definition if parseBool(global_stack.getMetaDataEntry("has_materials", False)): material_diameter = machine_definition.getProperty("material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) approximate_material_diameter = str(round(material_diameter)) root_material_id = machine_definition.getMetaDataEntry("preferred_material") root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_material_diameter) node = self.getMaterialNode(machine_definition.getId(), extruder_variant_name, material_diameter, root_material_id) return node def removeMaterialByRootId(self, root_material_id: str): material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log("i", "Unable to remove the material with id %s, because it doesn't exist.", root_material_id) return nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list for node in nodes_to_remove: self._container_registry.removeContainer(node.metadata["id"]) # # Methods for GUI # # # Sets the new name for the given material. # @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str): root_material_id = material_node.metadata["base_file"] if self._container_registry.isReadOnly(root_material_id): Logger.log("w", "Cannot set name of read-only container %s.", root_material_id) return material_group = self.getMaterialGroup(root_material_id) if material_group: material_group.root_material_node.getContainer().setName(name) # # Removes the given material. # @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode"): root_material_id = material_node.metadata["base_file"] self.removeMaterialByRootId(root_material_id) # # Creates a duplicate of a material, which has the same GUID and base_file metadata. # Returns the root material ID of the duplicated material if successful. # @pyqtSlot("QVariant", result = str) def duplicateMaterial(self, material_node, new_base_id = None, new_metadata = None) -> Optional[str]: root_material_id = material_node.metadata["base_file"] material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log("i", "Unable to duplicate the material with id %s, because it doesn't exist.", root_material_id) return None base_container = material_group.root_material_node.getContainer() if not base_container: return None # Ensure all settings are saved. self._application.saveSettings() # Create a new ID & container to hold the data. new_containers = [] if new_base_id is None: new_base_id = self._container_registry.uniqueName(base_container.getId()) new_base_container = copy.deepcopy(base_container) new_base_container.getMetaData()["id"] = new_base_id new_base_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_base_container.getMetaData()[key] = value new_containers.append(new_base_container) # Clone all of them. for node in material_group.derived_material_node_list: container_to_copy = node.getContainer() if not container_to_copy: continue # Create unique IDs for every clone. new_id = new_base_id if container_to_copy.getMetaDataEntry("definition") != "fdmprinter": new_id += "_" + container_to_copy.getMetaDataEntry("definition") if container_to_copy.getMetaDataEntry("variant_name"): variant_name = container_to_copy.getMetaDataEntry("variant_name") new_id += "_" + variant_name.replace(" ", "_") new_container = copy.deepcopy(container_to_copy) new_container.getMetaData()["id"] = new_id new_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_container.getMetaData()[key] = value new_containers.append(new_container) for container_to_add in new_containers: container_to_add.setDirty(True) self._container_registry.addContainer(container_to_add) return new_base_id # # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID. # @pyqtSlot(result = str) def createMaterial(self) -> str: from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") # Ensure all settings are saved. self._application.saveSettings() machine_manager = self._application.getMachineManager() extruder_stack = machine_manager.activeStack approximate_diameter = str(extruder_stack.approximateMaterialDiameter) root_material_id = "generic_pla" root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_diameter) material_group = self.getMaterialGroup(root_material_id) # Create a new ID & container to hold the data. new_id = self._container_registry.uniqueName("custom_material") new_metadata = {"name": catalog.i18nc("@label", "Custom Material"), "brand": catalog.i18nc("@label", "Custom"), "GUID": str(uuid.uuid4()), } self.duplicateMaterial(material_group.root_material_node, new_base_id = new_id, new_metadata = new_metadata) return new_id
class MaterialManager(QObject): materialsUpdated = pyqtSignal( ) # Emitted whenever the material lookup tables are updated. favoritesUpdated = pyqtSignal( ) # Emitted whenever the favorites are changed def __init__(self, container_registry, parent=None): super().__init__(parent) self._application = Application.getInstance() self._container_registry = container_registry # type: ContainerRegistry # Material_type -> generic material metadata self._fallback_materials_map = dict( ) # type: Dict[str, Dict[str, Any]] # Root_material_id -> MaterialGroup self._material_group_map = dict() # type: Dict[str, MaterialGroup] # Approximate diameter str self._diameter_machine_nozzle_buildplate_material_map = dict( ) # type: Dict[str, Dict[str, MaterialNode]] # We're using these two maps to convert between the specific diameter material id and the generic material id # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant # i.e. generic_pla -> generic_pla_175 # root_material_id -> approximate diameter str -> root_material_id for that diameter self._material_diameter_map = defaultdict( dict) # type: Dict[str, Dict[str, str]] # Material id including diameter (generic_pla_175) -> material root id (generic_pla) self._diameter_material_map = dict() # type: Dict[str, str] # This is used in Legacy UM3 send material function and the material management page. # GUID -> a list of material_groups self._guid_material_groups_map = defaultdict( list) # type: Dict[str, List[MaterialGroup]] # The machine definition ID for the non-machine-specific materials. # This is used as the last fallback option if the given machine-specific material(s) cannot be found. self._default_machine_definition_id = "fdmprinter" self._default_approximate_diameter_for_quality_search = "3" # When a material gets added/imported, there can be more than one InstanceContainers. In those cases, we don't # want to react on every container/metadata changed signal. The timer here is to buffer it a bit so we don't # react too many time. self._update_timer = QTimer(self) self._update_timer.setInterval(300) self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._updateMaps) self._container_registry.containerMetaDataChanged.connect( self._onContainerMetadataChanged) self._container_registry.containerAdded.connect( self._onContainerMetadataChanged) self._container_registry.containerRemoved.connect( self._onContainerMetadataChanged) self._favorites = set() # type: Set[str] def initialize(self) -> None: # Find all materials and put them in a matrix for quick search. material_metadatas = { metadata["id"]: metadata for metadata in self._container_registry.findContainersMetadata( type="material") if metadata.get("GUID") } # type: Dict[str, Dict[str, Any]] self._material_group_map = dict() # type: Dict[str, MaterialGroup] # Map #1 # root_material_id -> MaterialGroup for material_id, material_metadata in material_metadatas.items(): # We don't store empty material in the lookup tables if material_id == "empty_material": continue root_material_id = material_metadata.get("base_file", "") if root_material_id not in self._material_group_map: self._material_group_map[root_material_id] = MaterialGroup( root_material_id, MaterialNode(material_metadatas[root_material_id])) self._material_group_map[ root_material_id].is_read_only = self._container_registry.isReadOnly( root_material_id) group = self._material_group_map[root_material_id] # Store this material in the group of the appropriate root material. if material_id != root_material_id: new_node = MaterialNode(material_metadata) group.derived_material_node_list.append(new_node) # Order this map alphabetically so it's easier to navigate in a debugger self._material_group_map = OrderedDict( sorted(self._material_group_map.items(), key=lambda x: x[0])) # Map #1.5 # GUID -> material group list self._guid_material_groups_map = defaultdict( list) # type: Dict[str, List[MaterialGroup]] for root_material_id, material_group in self._material_group_map.items( ): guid = material_group.root_material_node.getMetaDataEntry( "GUID", "") self._guid_material_groups_map[guid].append(material_group) # Map #2 # Lookup table for material type -> fallback material metadata, only for read-only materials grouped_by_type_dict = dict() # type: Dict[str, Any] material_types_without_fallback = set() for root_material_id, material_node in self._material_group_map.items( ): material_type = material_node.root_material_node.getMetaDataEntry( "material", "") if material_type not in grouped_by_type_dict: grouped_by_type_dict[material_type] = { "generic": None, "others": [] } material_types_without_fallback.add(material_type) brand = material_node.root_material_node.getMetaDataEntry( "brand", "") if brand.lower() == "generic": to_add = True if material_type in grouped_by_type_dict: diameter = material_node.root_material_node.getMetaDataEntry( "approximate_diameter", "") if diameter != self._default_approximate_diameter_for_quality_search: to_add = False # don't add if it's not the default diameter if to_add: # Checking this first allow us to differentiate between not read only materials: # - if it's in the list, it means that is a new material without fallback # - if it is not, then it is a custom material with a fallback material (parent) if material_type in material_types_without_fallback: grouped_by_type_dict[ material_type] = material_node.root_material_node._metadata material_types_without_fallback.remove(material_type) # Remove the materials that have no fallback materials for material_type in material_types_without_fallback: del grouped_by_type_dict[material_type] self._fallback_materials_map = grouped_by_type_dict # Map #3 # There can be multiple material profiles for the same material with different diameters, such as "generic_pla" # and "generic_pla_175". This is inconvenient when we do material-specific quality lookup because a quality can # be for either "generic_pla" or "generic_pla_175", but not both. This map helps to get the correct material ID # for quality search. self._material_diameter_map = defaultdict(dict) self._diameter_material_map = dict() # Group the material IDs by the same name, material, brand, and color but with different diameters. material_group_dict = dict() # type: Dict[Tuple[Any], Dict[str, str]] keys_to_fetch = ("name", "material", "brand", "color") for root_material_id, machine_node in self._material_group_map.items(): root_material_metadata = machine_node.root_material_node._metadata key_data_list = [] # type: List[Any] for key in keys_to_fetch: key_data_list.append( machine_node.root_material_node.getMetaDataEntry(key)) key_data = cast(Tuple[Any], tuple(key_data_list)) # type: Tuple[Any] # If the key_data doesn't exist, it doesn't matter if the material is read only... if key_data not in material_group_dict: material_group_dict[key_data] = dict() else: # ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it if not machine_node.is_read_only: continue approximate_diameter = machine_node.root_material_node.getMetaDataEntry( "approximate_diameter", "") material_group_dict[key_data][ approximate_diameter] = machine_node.root_material_node.getMetaDataEntry( "id", "") # Map [root_material_id][diameter] -> root_material_id for this diameter for data_dict in material_group_dict.values(): for root_material_id1 in data_dict.values(): if root_material_id1 in self._material_diameter_map: continue diameter_map = data_dict for root_material_id2 in data_dict.values(): self._material_diameter_map[ root_material_id2] = diameter_map default_root_material_id = data_dict.get( self._default_approximate_diameter_for_quality_search) if default_root_material_id is None: default_root_material_id = list(data_dict.values())[ 0] # no default diameter present, just take "the" only one for root_material_id in data_dict.values(): self._diameter_material_map[ root_material_id] = default_root_material_id # Map #4 # "machine" -> "nozzle name" -> "buildplate name" -> "root material ID" -> specific material InstanceContainer self._diameter_machine_nozzle_buildplate_material_map = dict( ) # type: Dict[str, Dict[str, MaterialNode]] for material_metadata in material_metadatas.values(): self.__addMaterialMetadataIntoLookupTree(material_metadata) favorites = self._application.getPreferences().getValue( "cura/favorite_materials") for item in favorites.split(";"): self._favorites.add(item) self.materialsUpdated.emit() def __addMaterialMetadataIntoLookupTree( self, material_metadata: Dict[str, Any]) -> None: material_id = material_metadata["id"] # We don't store empty material in the lookup tables if material_id == "empty_material": return root_material_id = material_metadata["base_file"] definition = material_metadata["definition"] approximate_diameter = str(material_metadata["approximate_diameter"]) if approximate_diameter not in self._diameter_machine_nozzle_buildplate_material_map: self._diameter_machine_nozzle_buildplate_material_map[ approximate_diameter] = {} machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[ approximate_diameter] if definition not in machine_nozzle_buildplate_material_map: machine_nozzle_buildplate_material_map[definition] = MaterialNode() # This is a list of information regarding the intermediate nodes: # nozzle -> buildplate nozzle_name = material_metadata.get("variant_name") buildplate_name = material_metadata.get("buildplate_name") intermediate_node_info_list = [ (nozzle_name, VariantType.NOZZLE), (buildplate_name, VariantType.BUILD_PLATE), ] variant_manager = self._application.getVariantManager() machine_node = machine_nozzle_buildplate_material_map[definition] current_node = machine_node current_intermediate_node_info_idx = 0 error_message = None # type: Optional[str] while current_intermediate_node_info_idx < len( intermediate_node_info_list): variant_name, variant_type = intermediate_node_info_list[ current_intermediate_node_info_idx] if variant_name is not None: # The new material has a specific variant, so it needs to be added to that specific branch in the tree. variant = variant_manager.getVariantNode( definition, variant_name, variant_type) if variant is None: error_message = "Material {id} contains a variant {name} that does not exist.".format( id=material_metadata["id"], name=variant_name) break # Update the current node to advance to a more specific branch if variant_name not in current_node.children_map: current_node.children_map[variant_name] = MaterialNode() current_node = current_node.children_map[variant_name] current_intermediate_node_info_idx += 1 if error_message is not None: Logger.log( "e", "%s It will not be added into the material lookup tree.", error_message) self._container_registry.addWrongContainerId( material_metadata["id"]) return # Add the material to the current tree node, which is the deepest (the most specific) branch we can find. # Sanity check: Make sure that there is no duplicated materials. if root_material_id in current_node.material_map: Logger.log( "e", "Duplicated material [%s] with root ID [%s]. It has already been added.", material_id, root_material_id) ConfigurationErrorMessage.getInstance().addFaultyContainers( root_material_id) return current_node.material_map[root_material_id] = MaterialNode( material_metadata) def _updateMaps(self): Logger.log("i", "Updating material lookup data ...") self.initialize() def _onContainerMetadataChanged(self, container): self._onContainerChanged(container) def _onContainerChanged(self, container): container_type = container.getMetaDataEntry("type") if container_type != "material": return # update the maps self._update_timer.start() def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]: return self._material_group_map.get(root_material_id) def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str: return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id) def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str: return self._diameter_material_map.get(root_material_id, "") def getMaterialGroupListByGUID(self, guid: str) -> Optional[List[MaterialGroup]]: return self._guid_material_groups_map.get(guid) # Returns a dict of all material groups organized by root_material_id. def getAllMaterialGroups(self) -> Dict[str, "MaterialGroup"]: return self._material_group_map # # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup. # def getAvailableMaterials(self, machine_definition: "DefinitionContainer", nozzle_name: Optional[str], buildplate_name: Optional[str], diameter: float) -> Dict[str, MaterialNode]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map: Logger.log( "i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter) return dict() machine_definition_id = machine_definition.getId() # If there are nozzle-and-or-buildplate materials, get the nozzle-and-or-buildplate material machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[ rounded_diameter] machine_node = machine_nozzle_buildplate_material_map.get( machine_definition_id) default_machine_node = machine_nozzle_buildplate_material_map.get( self._default_machine_definition_id) nozzle_node = None buildplate_node = None if nozzle_name is not None and machine_node is not None: nozzle_node = machine_node.getChildNode(nozzle_name) # Get buildplate node if possible if nozzle_node is not None and buildplate_name is not None: buildplate_node = nozzle_node.getChildNode(buildplate_name) nodes_to_check = [ buildplate_node, nozzle_node, machine_node, default_machine_node ] # Fallback mechanism of finding materials: # 1. buildplate-specific material # 2. nozzle-specific material # 3. machine-specific material # 4. generic material (for fdmprinter) machine_exclude_materials = machine_definition.getMetaDataEntry( "exclude_materials", []) material_id_metadata_dict = dict() # type: Dict[str, MaterialNode] excluded_materials = set() for current_node in nodes_to_check: if current_node is None: continue # Only exclude the materials that are explicitly specified in the "exclude_materials" field. # Do not exclude other materials that are of the same type. for material_id, node in current_node.material_map.items(): if material_id in machine_exclude_materials: excluded_materials.add(material_id) continue if material_id not in material_id_metadata_dict: material_id_metadata_dict[material_id] = node if excluded_materials: Logger.log( "d", "Exclude materials {excluded_materials} for machine {machine_definition_id}" .format(excluded_materials=", ".join(excluded_materials), machine_definition_id=machine_definition_id)) return material_id_metadata_dict # # A convenience function to get available materials for the given machine with the extruder position. # def getAvailableMaterialsForMachineExtruder( self, machine: "GlobalStack", extruder_stack: "ExtruderStack" ) -> Optional[Dict[str, MaterialNode]]: buildplate_name = machine.getBuildplateName() nozzle_name = None if extruder_stack.variant.getId() != "empty_variant": nozzle_name = extruder_stack.variant.getName() diameter = extruder_stack.getApproximateMaterialDiameter() # Fetch the available materials (ContainerNode) for the current active machine and extruder setup. return self.getAvailableMaterials(machine.definition, nozzle_name, buildplate_name, diameter) # # Gets MaterialNode for the given extruder and machine with the given material name. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNode(self, machine_definition_id: str, nozzle_name: Optional[str], buildplate_name: Optional[str], diameter: float, root_material_id: str) -> Optional["MaterialNode"]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map: Logger.log( "i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]", diameter, rounded_diameter, root_material_id) return None # If there are nozzle materials, get the nozzle-specific material machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[ rounded_diameter] # type: Dict[str, MaterialNode] machine_node = machine_nozzle_buildplate_material_map.get( machine_definition_id) nozzle_node = None buildplate_node = None # Fallback for "fdmprinter" if the machine-specific materials cannot be found if machine_node is None: machine_node = machine_nozzle_buildplate_material_map.get( self._default_machine_definition_id) if machine_node is not None and nozzle_name is not None: nozzle_node = machine_node.getChildNode(nozzle_name) if nozzle_node is not None and buildplate_name is not None: buildplate_node = nozzle_node.getChildNode(buildplate_name) # Fallback mechanism of finding materials: # 1. buildplate-specific material # 2. nozzle-specific material # 3. machine-specific material # 4. generic material (for fdmprinter) nodes_to_check = [ buildplate_node, nozzle_node, machine_node, machine_nozzle_buildplate_material_map.get( self._default_machine_definition_id) ] material_node = None for node in nodes_to_check: if node is not None: material_node = node.material_map.get(root_material_id) if material_node: break return material_node # # Gets MaterialNode for the given extruder and machine with the given material type. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, nozzle_name: str, buildplate_name: Optional[str], material_guid: str) -> Optional["MaterialNode"]: node = None machine_definition = global_stack.definition extruder_definition = global_stack.extruders[position].definition if parseBool( machine_definition.getMetaDataEntry("has_materials", False)): material_diameter = extruder_definition.getProperty( "material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) # Look at the guid to material dictionary root_material_id = None for material_group in self._guid_material_groups_map[ material_guid]: root_material_id = cast( str, material_group.root_material_node.getMetaDataEntry( "id", "")) break if not root_material_id: Logger.log("i", "Cannot find materials with guid [%s] ", material_guid) return None node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name, material_diameter, root_material_id) return node # There are 2 ways to get fallback materials; # - A fallback by type (@sa getFallbackMaterialIdByMaterialType), which adds the generic version of this material # - A fallback by GUID; If a material has been duplicated, it should also check if the original materials do have # a GUID. This should only be done if the material itself does not have a quality just yet. def getFallBackMaterialIdsByMaterial( self, material: "InstanceContainer") -> List[str]: results = [] # type: List[str] material_groups = self.getMaterialGroupListByGUID( material.getMetaDataEntry("GUID")) for material_group in material_groups: # type: ignore if material_group.name != material.getId(): # If the material in the group is read only, put it at the front of the list (since that is the most # likely one to get a result) if material_group.is_read_only: results.insert(0, material_group.name) else: results.append(material_group.name) fallback = self.getFallbackMaterialIdByMaterialType( material.getMetaDataEntry("material")) if fallback is not None: results.append(fallback) return results # # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla". # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use # the generic material IDs to search for qualities. # # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will # be "generic_pla". This function is intended to get a generic fallback material for the given material type. # # This function returns the generic root material ID for the given material type, where material types are "PLA", # "ABS", etc. # def getFallbackMaterialIdByMaterialType( self, material_type: str) -> Optional[str]: # For safety if material_type not in self._fallback_materials_map: Logger.log( "w", "The material type [%s] does not have a fallback material" % material_type) return None fallback_material = self._fallback_materials_map[material_type] if fallback_material: return self.getRootMaterialIDWithoutDiameter( fallback_material["id"]) else: return None ## Get default material for given global stack, extruder position and extruder nozzle name # you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder) def getDefaultMaterial( self, global_stack: "GlobalStack", position: str, nozzle_name: Optional[str], extruder_definition: Optional["DefinitionContainer"] = None ) -> Optional["MaterialNode"]: node = None buildplate_name = global_stack.getBuildplateName() machine_definition = global_stack.definition # The extruder-compatible material diameter in the extruder definition may not be the correct value because # the user can change it in the definition_changes container. if extruder_definition is None: extruder_stack_or_definition = global_stack.extruders[position] is_extruder_stack = True else: extruder_stack_or_definition = extruder_definition is_extruder_stack = False if extruder_stack_or_definition and parseBool( global_stack.getMetaDataEntry("has_materials", False)): if is_extruder_stack: material_diameter = extruder_stack_or_definition.getCompatibleMaterialDiameter( ) else: material_diameter = extruder_stack_or_definition.getProperty( "material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) approximate_material_diameter = str(round(material_diameter)) root_material_id = machine_definition.getMetaDataEntry( "preferred_material") root_material_id = self.getRootMaterialIDForDiameter( root_material_id, approximate_material_diameter) node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name, material_diameter, root_material_id) return node def removeMaterialByRootId(self, root_material_id: str): material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log( "i", "Unable to remove the material with id %s, because it doesn't exist.", root_material_id) return nodes_to_remove = [material_group.root_material_node ] + material_group.derived_material_node_list # Sort all nodes with respect to the container ID lengths in the ascending order so the base material container # will be the first one to be removed. We need to do this to ensure that all containers get loaded & deleted. nodes_to_remove = sorted( nodes_to_remove, key=lambda x: len(x.getMetaDataEntry("id", ""))) # Try to load all containers first. If there is any faulty ones, they will be put into the faulty container # list, so removeContainer() can ignore those ones. for node in nodes_to_remove: container_id = node.getMetaDataEntry("id", "") results = self._container_registry.findContainers(id=container_id) if not results: self._container_registry.addWrongContainerId(container_id) for node in nodes_to_remove: self._container_registry.removeContainer( node.getMetaDataEntry("id", "")) # # Methods for GUI # # # Sets the new name for the given material. # @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: root_material_id = material_node.getMetaDataEntry("base_file") if root_material_id is None: return if self._container_registry.isReadOnly(root_material_id): Logger.log("w", "Cannot set name of read-only container %s.", root_material_id) return material_group = self.getMaterialGroup(root_material_id) if material_group: container = material_group.root_material_node.getContainer() if container: container.setName(name) # # Removes the given material. # @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode") -> None: root_material_id = material_node.getMetaDataEntry("base_file") if root_material_id is not None: self.removeMaterialByRootId(root_material_id) # # Creates a duplicate of a material, which has the same GUID and base_file metadata. # Returns the root material ID of the duplicated material if successful. # @pyqtSlot("QVariant", result=str) def duplicateMaterial( self, material_node: MaterialNode, new_base_id: Optional[str] = None, new_metadata: Dict[str, Any] = None) -> Optional[str]: root_material_id = cast( str, material_node.getMetaDataEntry("base_file", "")) material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log( "i", "Unable to duplicate the material with id %s, because it doesn't exist.", root_material_id) return None base_container = material_group.root_material_node.getContainer() if not base_container: return None # Ensure all settings are saved. self._application.saveSettings() # Create a new ID & container to hold the data. new_containers = [] if new_base_id is None: new_base_id = self._container_registry.uniqueName( base_container.getId()) new_base_container = copy.deepcopy(base_container) new_base_container.getMetaData()["id"] = new_base_id new_base_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_base_container.getMetaData()[key] = value new_containers.append(new_base_container) # Clone all of them. for node in material_group.derived_material_node_list: container_to_copy = node.getContainer() if not container_to_copy: continue # Create unique IDs for every clone. new_id = new_base_id if container_to_copy.getMetaDataEntry( "definition") != "fdmprinter": new_id += "_" + container_to_copy.getMetaDataEntry( "definition") if container_to_copy.getMetaDataEntry("variant_name"): nozzle_name = container_to_copy.getMetaDataEntry( "variant_name") new_id += "_" + nozzle_name.replace(" ", "_") new_container = copy.deepcopy(container_to_copy) new_container.getMetaData()["id"] = new_id new_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_container.getMetaData()[key] = value new_containers.append(new_container) for container_to_add in new_containers: container_to_add.setDirty(True) self._container_registry.addContainer(container_to_add) # if the duplicated material was favorite then the new material should also be added to favorite. if root_material_id in self.getFavorites(): self.addFavorite(new_base_id) return new_base_id # # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID. # Returns the ID of the newly created material. @pyqtSlot(result=str) def createMaterial(self) -> str: from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") # Ensure all settings are saved. self._application.saveSettings() machine_manager = self._application.getMachineManager() extruder_stack = machine_manager.activeStack machine_definition = self._application.getGlobalContainerStack( ).definition root_material_id = machine_definition.getMetaDataEntry( "preferred_material", default="generic_pla") approximate_diameter = str(extruder_stack.approximateMaterialDiameter) root_material_id = self.getRootMaterialIDForDiameter( root_material_id, approximate_diameter) material_group = self.getMaterialGroup(root_material_id) if not material_group: # This should never happen Logger.log("w", "Cannot get the material group of %s.", root_material_id) return "" # Create a new ID & container to hold the data. new_id = self._container_registry.uniqueName("custom_material") new_metadata = { "name": catalog.i18nc("@label", "Custom Material"), "brand": catalog.i18nc("@label", "Custom"), "GUID": str(uuid.uuid4()), } self.duplicateMaterial(material_group.root_material_node, new_base_id=new_id, new_metadata=new_metadata) return new_id @pyqtSlot(str) def addFavorite(self, root_material_id: str) -> None: self._favorites.add(root_material_id) self.materialsUpdated.emit() # Ensure all settings are saved. self._application.getPreferences().setValue( "cura/favorite_materials", ";".join(list(self._favorites))) self._application.saveSettings() @pyqtSlot(str) def removeFavorite(self, root_material_id: str) -> None: try: self._favorites.remove(root_material_id) except KeyError: Logger.log( "w", "Could not delete material %s from favorites as it was already deleted", root_material_id) return self.materialsUpdated.emit() # Ensure all settings are saved. self._application.getPreferences().setValue( "cura/favorite_materials", ";".join(list(self._favorites))) self._application.saveSettings() @pyqtSlot() def getFavorites(self): return self._favorites
class SiteIcon(ToolButton): def __init__(self, parent): ''' @param: parent LocationBar ''' super().__init__(parent) self._window = None # BrowserWindow self._locationBar = parent # LocationBar self._view = None # WebView self._updateTimer = None # QTimer self._drawStartPosition = QPoint() self._icon = QIcon() self.setObjectName('locationbar-siteicon') self.setToolButtonStyle(Qt.ToolButtonIconOnly) self.setCursor(Qt.ArrowCursor) self.setToolTip(_('Show information about this page')) self.setFocusPolicy(Qt.NoFocus) self._updateTimer = QTimer(self) self._updateTimer.setInterval(100) self._updateTimer.setSingleShot(True) self._updateTimer.timeout.connect(self._updateIcon) def setBrowserWindow(self, window): ''' @param: window BrowserWindow ''' self._window = window def setWebView(self, view): ''' @param: view WebView ''' self._view = view def setIcon(self, icon): ''' @param: icon QIcon ''' wasNull = self._icon.isNull() self._icon = icon if wasNull: self._updateIcon() else: self._updateTimer.start() # private Q_SLOTS: def _updateIcon(self): super().setIcon(self._icon) def _popupClosed(self): self.setDown(False) # private: def contextMenuEvent(self, event): ''' @param: event QContextMenuEvent ''' # Prevent propagating to LocationBar event.accept() def mousePressEvent(self, event): ''' @param: event QMouseEvent ''' if event.buttons() == Qt.LeftButton: self._drawStartPosition = event.pos() # Prevent propagating to LocationBar event.accept() super().mousePressEvent(event) def mouseReleaseEvent(self, event): ''' @param: event QMouseEvent ''' # Mouse release event is storing Down state # So we pause updates to prevent flicker activated = False if event.button() == Qt.LeftButton and self.rect().contains( event.pos()): # Popup may not be always shown. eg. on app: pages activated = self._showPopup() if activated: self.setUpdatesEnabled(False) super().mouseReleaseEvent(event) if activated: self.setDown(False) self.setUpdatesEnabled(True) # override def mouseMoveEvent(self, event): ''' @param: event QMouseEvent ''' if not self._locationBar or event.buttons() != Qt.LeftButton: super().mouseMoveEvent(event) return manhattanLength = (event.pos() - self._drawStartPosition).manhattanLength() if manhattanLength <= QApplication.startDragDistance(): super().mouseMoveEvent(event) return url = self._locationBar.webView().url() title = self._locationBar.webView().title() if url.isEmpty() or not title: super().mouseMoveEvent(event) return drag = QDrag(self) mime = QMimeData() mime.setUrls([url]) mime.setText(title) mime.setImageData(self.icon.pixmap(16).toImage()) drag.setMimeData(mime) drag.setPixmap( gVar.appTools.createPixmapForSite(self.icon, title, url.toString())) drag.exec_() # Restore Down State self.setDown(False) def _showPopup(self): if not self._view or not self._window: return False url = self._view.url() if not SiteInfo.canShowSiteInfo(url): return False self.setDown(True) # SiteInfoWidget info = SiteInfoWidget(self._window) info.showAt(self.parentWidget()) info.destroyed.connect(self._popupClosed) return True
class TagBrowserWidget(QWidget): # {{{ def __init__(self, parent): QWidget.__init__(self, parent) 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) 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().returnPressed.connect(self.do_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.addWidget(parent.tags_view) # Now the floating 'not found' box l = QLabel(self.tags_view) self.not_found_label = l l.setFrameStyle(QFrame.StyledPanel) l.setAutoFillBackground(True) l.setText('<p><b>'+_('No More Matches.</b><p> Click Find again to go to first match')) l.setAlignment(Qt.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.QueuedConnection) # The Alter Tag Browser button l = self.alter_tb 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')) ac.triggered.connect(lambda : self.tags_view.collapseAll()) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser alter', _('Alter Tag browser'), default_keys=(), action=ac, group=_('Tag browser')) ac.triggered.connect(l.showMenu) 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) # self.leak_test_timer = QTimer(self) # self.leak_test_timer.timeout.connect(self.test_for_leak) # self.leak_test_timer.start(5000) 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 set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) 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() def find(self): model = self.tags_view.model() model.clear_boxed() txt = unicode(self.item_search.currentText()).strip() if txt.startswith('*'): 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) key = None colon = txt.rfind(':') if len(txt) > 2 else 0 if colon > 0: key = self._parent.library_view.model().db.\ field_metadata.search_term_to_field_key(txt[:colon]) txt = txt[colon+1:] self.current_find_position = \ model.find_item_node(key, txt, self.current_find_position) 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)
class TagBrowserWidget(QFrame): # {{{ def __init__(self, parent): QFrame.__init__(self, parent) self.setFrameStyle(QFrame.NoFrame if gprefs['tag_browser_old_look'] else QFrame.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.StyledPanel) l.setAutoFillBackground(True) l.setText('<p><b>'+_('No more matches.</b><p> Click Find again to go to first match')) l.setAlignment(Qt.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.QueuedConnection) # The Alter Tag Browser button l = self.alter_tb 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()) 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) 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) # self.leak_test_timer = QTimer(self) # self.leak_test_timer.timeout.connect(self.test_for_leak) # self.leak_test_timer.start(5000) 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 set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) 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 unicode_type(self.item_search.currentText()).strip() def reset_find(self): model = self.tags_view.model() 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_Enter, Qt.Key_Return) and self.find_text: self.find() ev.accept() return return QFrame.keyPressEvent(self, ev)
class BookmarksToolbar(QWidget): def __init__(self, window, parent=None): ''' @param: window BrowserWindow @param: parent QWidget ''' super().__init__(parent) self._window = window self._bookmarks = gVar.app.bookmarks() # Bookmarks self._clickedBookmark = None # BookmarkItem self._layout = None # QHBoxLayout self._updateTimer = None # QTimer self._actShowOnlyIcons = None # QAction self._actShowOnlyText = None # QAction self._dropRow = -1 self._dropPos = QPoint() self.setObjectName('bookmarksbar') self.setAcceptDrops(True) self.setContextMenuPolicy(Qt.CustomContextMenu) self._layout = QHBoxLayout(self) margin = self.style().pixelMetric(QStyle.PM_ToolBarItemMargin, None, self) + \ self.style().pixelMetric(QStyle.PM_ToolBarFrameWidth, None, self) self._layout.setContentsMargins(margin, margin, margin, margin) self._layout.setSpacing(self.style().pixelMetric( QStyle.PM_ToolBarItemSpacing, None, self)) self.setLayout(self._layout) self._updateTimer = QTimer(self) self._updateTimer.setInterval(300) self._updateTimer.setSingleShot(True) self._updateTimer.timeout.connect(self._refresh) self._bookmarks.bookmarkAdded.connect(self._bookmarksChanged) self._bookmarks.bookmarkRemoved.connect(self._bookmarksChanged) self._bookmarks.bookmarkChanged.connect(self._bookmarksChanged) self._bookmarks.showOnlyIconsInToolbarChanged.connect( self._showOnlyIconsChanged) self._bookmarks.showOnlyTextInToolbarChanged.connect( self._showOnlyTextChanged) self.customContextMenuRequested.connect(self._contextMenuRequested) self._refresh() # private Q_SLOTS: def _contextMenuRequested(self, pos): ''' @param: pos QPoint ''' # BookmarksToolbarButton button = self._buttonAt(pos) if button: self._clickedBookmark = button.bookmark() else: self._clickedBookmark = None menu = QMenu() actNewTab = menu.addAction(IconProvider.newTabIcon(), _('Open in new tab')) actNewWindow = menu.addAction(IconProvider.newWindowIcon(), _('Open in new window')) actNewPrivateWindow = menu.addAction( IconProvider.privateBrowsingIcon(), _('Open in new private window')) menu.addSeparator() actEdit = menu.addAction(_('Edit')) actDelete = menu.addAction(QIcon.fromTheme('edit-delete'), _('Delete')) menu.addSeparator() self._actShowOnlyIcons = menu.addAction(_('Show Only Icons')) self._actShowOnlyIcons.setCheckable(True) self._actShowOnlyIcons.setChecked( self._bookmarks.showOnlyIconsInToolbar()) self._actShowOnlyIcons.toggled.connect(self._showOnlyIconsChanged) self._actShowOnlyText = menu.addAction(_('Show Only Text')) self._actShowOnlyText.setCheckable(True) self._actShowOnlyText.setChecked( self._bookmarks.showOnlyTextInToolbar()) self._actShowOnlyText.toggled.connect(self._showOnlyTextChanged) actNewTab.triggered.connect(self._openBookmarkInNewTab) actNewWindow.triggered.connect(self._openBookmarkInNewWindow) actNewPrivateWindow.triggered.connect( self._openBookmarkInNewPrivateWindow) actEdit.triggered.connect(self._editBookmark) actDelete.triggered.connect(self._deleteBookmark) canBeModify = self._clickedBookmark and self._bookmarks.canBeModified( self._clickedBookmark) actEdit.setEnabled(canBeModify) actDelete.setEnabled(canBeModify) canOpen = self._clickedBookmark and self._clickedBookmark.isUrl() actNewTab.setEnabled(canOpen) actNewWindow.setEnabled(canOpen) actNewPrivateWindow.setEnabled(canOpen) menu.exec_(self.mapToGlobal(pos)) if button: # Clear mouse over state after closing menu button.update() self._clickedBookmark = None self._actShowOnlyIcons = None self._actShowOnlyText = None def _refresh(self): self._clear() folder = gVar.app.bookmarks().toolbarFolder() for child in folder.children(): self._addItem(child) self._layout.addStretch() def _bookmarksChanged(self): self._updateTimer.start() def _showOnlyIconsChanged(self, state): if state and self._actShowOnlyText: self._actShowOnlyText.setChecked(False) for idx in range(self._layout.count()): # BookmarksToolbarButton btn = self._layout.itemAt(idx).widget() if isinstance(btn, BookmarksToolbarButton): btn.setShowOnlyIcon(state) def _showOnlyTextChanged(self, state): if state and self._actShowOnlyIcons: self._actShowOnlyIcons.setChecked(False) for idx in range(self._layout.count()): # BookmarksToolbarButton btn = self._layout.itemAt(idx).widget() if isinstance(btn, BookmarksToolbarButton): btn.setShowOnlyText(state) def _openBookmarkInNewTab(self): if self._clickedBookmark: BookmarksTools.openBookmarkInNewTab(self._window, self._clickedBookmark) def _openBookmarkInNewWindow(self): if self._clickedBookmark: BookmarksTools.openBookmarkInNewWindow(self._clickedBookmark) def _openBookmarkInNewPrivateWindow(self): if self._clickedBookmark: BookmarksTools.openBookmarkInNewPrivateWindow( self._clickedBookmark) def _editBookmark(self): if self._clickedBookmark: BookmarksTools.editBookmarkDialog(self, self._clickedBookmark) self._bookmarks.changeBookmark(self._clickedBookmark) def _deleteBookmark(self): if self._clickedBookmark: self._bookmarks.removeBookmark(self._clickedBookmark) # private: def _clear(self): for idx in range(self._layout.count()): item = self._layout.takeAt(0) widget = item.widget() if widget: widget.setParent(None) widget.deleteLater() assert (self._layout.isEmpty()) def _addItem(self, item): ''' @param: BookmarkItem ''' assert (item) button = BookmarksToolbarButton(item, self) button.setMainWindow(self._window) button.setShowOnlyIcon(self._bookmarks.showOnlyIconsInToolbar()) button.setShowOnlyText(self._bookmarks.showOnlyTextInToolbar()) self._layout.addWidget(button) def _buttonAt(self, pos): ''' @param: pos QPoint @return: BookmarksToolbarButton ''' button = QApplication.widgetAt(self.mapToGlobal(pos)) if not isinstance(button, BookmarksToolbarButton): button = None return button # override def minimumSizeHint(self): size = super().minimumSizeHint() size.setHeight(max(20, size.height())) return size # override def dropEvent(self, event): ''' @param: event QDropEvent ''' row = self._dropRow self._clearDropIndicator() mime = event.mimeData() if not mime.hasUrls() and not mime.hasFormat( BookmarksButtonMimeData.mimeType()): super().dropEvent(event) return # BookmarkItem parent = self._bookmarks.toolbarFolder() bookmark = None if mime.hasFormat(BookmarksButtonMimeData.mimeType()): bookmarkMime = mime assert (isinstance(bookmarkMime, BookmarksButtonMimeData)) bookmark = bookmarkMime.item() initialIndex = bookmark.parent().children().index(bookmark) current = self._buttonAt(self._dropPos) if initialIndex < self._layout.indexOf(current): row -= 1 else: url = mime.urls()[0] if mime.hasText(): title = mime.text() else: title = url.toEncoded(QUrl.RemoveScheme) bookmark = BookmarkItem(BookmarkItem.Url) bookmark.setTitle(title) bookmark.setUrl(url) if row >= 0: self._bookmarks.insertBookmark(parent, row, bookmark) else: self._bookmarks.addBookmark(parent, bookmark) # override def dragEnterEvent(self, event): ''' @param: event QDragEnterEvent ''' mime = event.mimeData() if (mime.hasUrls() and mime.hasText()) or mime.hasFormat( BookmarksButtonMimeData.mimeType()): event.acceptProposedAction() return super().dragEnterEvent(event) # override def dragMoveEvent(self, event): ''' @param: event QDragMoveEvent ''' eventX = event.pos().x() # BoomarksToolbarButton button = self._buttonAt(event.pos()) self._dropPos = event.pos() self._dropRow = self._layout.indexOf(button) if button: if eventX - button.x() >= button.x() + button.width() - eventX: self._dropRow + 1 else: self._dropRow = -1 self.update() # override def dragLeaveEvent(self, event): ''' @param: event QDragLeaveEvent ''' self._clearDropIndicator() # override def paintEvent(self, event): ''' @param: event QPaintEvent ''' super().paintEvent(event) # Draw drop indicator if self._dropRow != -1: # BookmarksToolbarButton button = self._buttonAt(self._dropPos) if button: if button.bookmark().isFolder(): return tmpRect = QRect(button.x(), 0, button.width(), self.height()) rect = QRect() if self._dropRow == self._layout.indexOf(button): rect = QRect(max(0, tmpRect.left() - 2), tmpRect.top(), 3, tmpRect.height()) else: rect = QRect(tmpRect.right() + 0, tmpRect.top(), 3, tmpRect.height()) gVar.appTools.paintDropIndicator(self, rect) def _clearDropIndicator(self): self._dropRow = -1 self.update()
class Splitter(QSplitter): state_changed = pyqtSignal(object) def __init__(self, name, label, icon, initial_show=True, initial_side_size=120, connect_button=True, orientation=Qt.Horizontal, side_index=0, parent=None, shortcut=None, hide_handle_on_single_panel=True): QSplitter.__init__(self, parent) 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.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), unicode_type(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.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]) @dynamic_property def side_index_size(self): def fget(self): if self.count() < 2: return 0 return self.sizes()[self.side_index] def fset(self, val): if self.count() < 2: return if val == 0 and not self.is_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) total = sum(self.sizes()) sizes = list(self.sizes()) for i in range(len(sizes)): sizes[i] = val if i == self.side_index else total-val self.setSizes(sizes) self.initialize() return property(fget=fget, fset=fset) 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 TagBrowserWidget(QWidget): # {{{ def __init__(self, parent): QWidget.__init__(self, parent) self.parent = parent self._layout = QVBoxLayout() self.setLayout(self._layout) self._layout.setContentsMargins(0,0,0,0) # Set up the find box & button search_layout = QHBoxLayout() self._layout.addLayout(search_layout) self.item_search = HistoryLineEdit(parent) self.item_search.setMinimumContentsLength(5) self.item_search.setSizeAdjustPolicy(self.item_search.AdjustToMinimumContentsLengthWithIcon) try: self.item_search.lineEdit().setPlaceholderText( _('Find item in tag browser')) except: pass # Using Qt < 4.7 self.item_search.setToolTip(_( 'Search for items. This is a "contains" search; items containing the\n' 'text anywhere in the name will be found. You can limit the search\n' 'to particular categories using syntax similar to search. For example,\n' 'tags:foo will find foo in any tag, but not in authors etc. Entering\n' '*foo will filter all categories at once, showing only those items\n' 'containing the text "foo"')) search_layout.addWidget(self.item_search) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser find box', _('Find item'), default_keys=(), action=ac, group=_('Tag Browser')) ac.triggered.connect(self.set_focus_to_find_box) self.search_button = QToolButton() self.search_button.setText(_('Find')) self.search_button.setToolTip(_('Find the first/next matching item')) search_layout.addWidget(self.search_button) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser find button', _('Find button'), default_keys=(), action=ac, group=_('Tag Browser')) ac.triggered.connect(self.search_button.click) self.expand_button = QToolButton() self.expand_button.setText('-') self.expand_button.setToolTip(_('Collapse all categories')) search_layout.addWidget(self.expand_button) search_layout.setStretch(0, 10) search_layout.setStretch(1, 1) search_layout.setStretch(2, 1) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser collapse all', _('Collapse all'), default_keys=(), action=ac, group=_('Tag Browser')) ac.triggered.connect(self.expand_button.clicked) self.current_find_position = None self.search_button.clicked.connect(self.find) self.item_search.initialize('tag_browser_search') self.item_search.lineEdit().returnPressed.connect(self.do_find) self.item_search.lineEdit().textEdited.connect(self.find_text_changed) self.item_search.activated[str].connect(self.do_find) self.item_search.completer().setCaseSensitivity(Qt.CaseSensitive) parent.tags_view = TagsView(parent) self.tags_view = parent.tags_view self.expand_button.clicked.connect(self.tags_view.collapseAll) self._layout.addWidget(parent.tags_view) # Now the floating 'not found' box l = QLabel(self.tags_view) self.not_found_label = l l.setFrameStyle(QFrame.StyledPanel) l.setAutoFillBackground(True) l.setText('<p><b>'+_('No More Matches.</b><p> Click Find again to go to first match')) l.setAlignment(Qt.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.QueuedConnection) parent.alter_tb = l = QPushButton(parent) l.setText(_('Alter Tag Browser')) l.setIcon(QIcon(I('tags.png'))) l.m = QMenu() l.setMenu(l.m) self._layout.addWidget(l) ac = QAction(parent) parent.addAction(ac) parent.keyboard.register_shortcut('tag browser alter', _('Alter tag browser'), default_keys=(), action=ac, group=_('Tag Browser')) ac.triggered.connect(l.showMenu) 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((_('Sort by name'), _('Sort by number of books'), _('Sort by 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) # self.leak_test_timer = QTimer(self) # self.leak_test_timer.timeout.connect(self.test_for_leak) # self.leak_test_timer.start(5000) def toggle_item(self): self.tags_view.toggle_current_index() def set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) def find_text_changed(self, str): self.current_find_position = None def set_focus_to_find_box(self): self.item_search.setFocus() self.item_search.lineEdit().selectAll() def do_find(self, str=None): self.current_find_position = None self.find() def find(self): model = self.tags_view.model() model.clear_boxed() txt = unicode(self.item_search.currentText()).strip() if txt.startswith('*'): 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) key = None colon = txt.rfind(':') if len(txt) > 2 else 0 if colon > 0: key = self.parent.library_view.model().db.\ field_metadata.search_term_to_field_key(txt[:colon]) txt = txt[colon+1:] self.current_find_position = \ model.find_item_node(key, txt, self.current_find_position) 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)
class EbookViewer(MainWindow): STATE_VERSION = 2 FLOW_MODE_TT = _("Switch to paged mode - where the text is broken up " "into pages like a paper book") PAGED_MODE_TT = _("Switch to flow mode - where the text is not broken up " "into pages") def __init__(self, pathtoebook=None, debug_javascript=False, open_at=None, start_in_fullscreen=False): MainWindow.__init__(self, debug_javascript) self.view.magnification_changed.connect(self.magnification_changed) self.show_toc_on_open = False self.current_book_has_toc = False self.iterator = None self.current_page = None self.pending_search = None self.pending_search_dir = None self.pending_anchor = None self.pending_reference = None self.pending_bookmark = None self.pending_restore = False self.cursor_hidden = False self.existing_bookmarks = [] self.selected_text = None self.was_maximized = False self.read_settings() self.pos.value_changed.connect(self.update_pos_label) self.pos.setMinimumWidth(150) self.setFocusPolicy(Qt.StrongFocus) self.view.set_manager(self) self.pi = ProgressIndicator(self) self.action_quit = QAction(_("&Quit"), self) self.addAction(self.action_quit) self.view_resized_timer = QTimer(self) self.view_resized_timer.timeout.connect(self.viewport_resize_finished) self.view_resized_timer.setSingleShot(True) self.resize_in_progress = False self.action_reload = QAction(_("&Reload book"), self) self.action_reload.triggered.connect(self.reload_book) self.action_quit.triggered.connect(self.quit) self.action_reference_mode.triggered[bool].connect(self.view.reference_mode) self.action_metadata.triggered[bool].connect(self.metadata.setVisible) self.action_table_of_contents.toggled[bool].connect(self.set_toc_visible) self.action_copy.triggered[bool].connect(self.copy) self.action_font_size_larger.triggered.connect(self.font_size_larger) self.action_font_size_smaller.triggered.connect(self.font_size_smaller) self.action_open_ebook.triggered[bool].connect(self.open_ebook) self.action_next_page.triggered.connect(self.view.next_page) self.action_previous_page.triggered.connect(self.view.previous_page) self.action_find_next.triggered.connect(self.find_next) self.action_find_previous.triggered.connect(self.find_previous) self.action_full_screen.triggered[bool].connect(self.toggle_fullscreen) self.action_back.triggered[bool].connect(self.back) self.action_forward.triggered[bool].connect(self.forward) self.action_preferences.triggered.connect(self.do_config) self.pos.editingFinished.connect(self.goto_page_num) self.vertical_scrollbar.valueChanged[int].connect(lambda x: self.goto_page(x / 100.0)) self.search.search.connect(self.find) self.search.focus_to_library.connect(lambda: self.view.setFocus(Qt.OtherFocusReason)) self.toc.pressed[QModelIndex].connect(self.toc_clicked) self.toc.searched.connect(partial(self.toc_clicked, force=True)) self.reference.goto.connect(self.goto) self.bookmarks.edited.connect(self.bookmarks_edited) self.bookmarks.activated.connect(self.goto_bookmark) self.bookmarks.create_requested.connect(self.bookmark) self.set_bookmarks([]) self.load_theme_menu() if pathtoebook is not None: f = functools.partial(self.load_ebook, pathtoebook, open_at=open_at) QTimer.singleShot(50, f) self.window_mode_changed = None self.toggle_toolbar_action = QAction(_("Show/hide controls"), self) self.toggle_toolbar_action.setCheckable(True) self.toggle_toolbar_action.triggered.connect(self.toggle_toolbars) self.toolbar_hidden = None self.addAction(self.toggle_toolbar_action) self.full_screen_label_anim = QPropertyAnimation(self.full_screen_label, "size") self.clock_timer = QTimer(self) self.clock_timer.timeout.connect(self.update_clock) self.action_print.triggered.connect(self.print_book) self.print_menu.actions()[0].triggered.connect(self.print_preview) self.clear_recent_history_action = QAction(_("Clear list of recently opened books"), self) self.clear_recent_history_action.triggered.connect(self.clear_recent_history) self.build_recent_menu() self.open_history_menu.triggered.connect(self.open_recent) for x in ("tool_bar", "tool_bar2"): x = getattr(self, x) for action in x.actions(): # So that the keyboard shortcuts for these actions will # continue to function even when the toolbars are hidden self.addAction(action) for plugin in self.view.document.all_viewer_plugins: plugin.customize_ui(self) self.view.document.settings_changed.connect(self.settings_changed) self.restore_state() self.settings_changed() self.action_toggle_paged_mode.toggled[bool].connect(self.toggle_paged_mode) if start_in_fullscreen or self.view.document.start_in_fullscreen: self.action_full_screen.trigger() self.hide_cursor_timer = t = QTimer(self) t.setSingleShot(True), t.setInterval(3000) t.timeout.connect(self.hide_cursor) t.start() def eventFilter(self, obj, ev): if ev.type() == ev.MouseMove: if self.cursor_hidden: self.cursor_hidden = False QApplication.instance().restoreOverrideCursor() self.hide_cursor_timer.start() return False def hide_cursor(self): self.cursor_hidden = True QApplication.instance().setOverrideCursor(Qt.BlankCursor) def toggle_paged_mode(self, checked, at_start=False): in_paged_mode = not self.action_toggle_paged_mode.isChecked() self.view.document.in_paged_mode = in_paged_mode self.action_toggle_paged_mode.setToolTip( self.FLOW_MODE_TT if self.action_toggle_paged_mode.isChecked() else self.PAGED_MODE_TT ) if at_start: return self.reload() def settings_changed(self): for x in ("", "2"): x = getattr(self, "tool_bar" + x) x.setVisible(self.view.document.show_controls) def reload(self): if hasattr(self, "current_index") and self.current_index > -1: self.view.document.page_position.save(overwrite=False) self.pending_restore = True self.load_path(self.view.last_loaded_path) def set_toc_visible(self, yes): self.toc_dock.setVisible(yes) if not yes: self.show_toc_on_open = False def clear_recent_history(self, *args): vprefs.set("viewer_open_history", []) self.build_recent_menu() def build_recent_menu(self): m = self.open_history_menu m.clear() recent = vprefs.get("viewer_open_history", []) if recent: m.addAction(self.clear_recent_history_action) m.addSeparator() count = 0 for path in recent: if count > 9: break if os.path.exists(path): m.addAction(RecentAction(path, m)) count += 1 def shutdown(self): if self.isFullScreen() and not self.view.document.start_in_fullscreen: self.action_full_screen.trigger() return False self.save_state() return True def quit(self): if self.shutdown(): QApplication.instance().quit() def closeEvent(self, e): if self.shutdown(): return MainWindow.closeEvent(self, e) else: e.ignore() def toggle_toolbars(self): for x in ("tool_bar", "tool_bar2"): x = getattr(self, x) x.setVisible(not x.isVisible()) def save_state(self): state = bytearray(self.saveState(self.STATE_VERSION)) vprefs["main_window_state"] = state if not self.isFullScreen(): vprefs.set("viewer_window_geometry", bytearray(self.saveGeometry())) if self.current_book_has_toc: vprefs.set("viewer_toc_isvisible", self.show_toc_on_open or bool(self.toc_dock.isVisible())) vprefs["multiplier"] = self.view.multiplier vprefs["in_paged_mode"] = not self.action_toggle_paged_mode.isChecked() def restore_state(self): state = vprefs.get("main_window_state", None) if state is not None: try: state = QByteArray(state) self.restoreState(state, self.STATE_VERSION) except: pass mult = vprefs.get("multiplier", None) if mult: self.view.multiplier = mult # On windows Qt lets the user hide toolbars via a right click in a very # specific location, ensure they are visible. self.tool_bar.setVisible(True) self.tool_bar2.setVisible(True) self.toc_dock.close() # This will be opened on book open, if the book has a toc and it was previously opened self.action_toggle_paged_mode.setChecked(not vprefs.get("in_paged_mode", True)) self.toggle_paged_mode(self.action_toggle_paged_mode.isChecked(), at_start=True) def lookup(self, word): from urllib import quote word = quote(word.encode("utf-8")) try: url = lookup_website(canonicalize_lang(self.view.current_language) or "en").format(word=word) except Exception: traceback.print_exc() url = default_lookup_website(canonicalize_lang(self.view.current_language) or "en").format(word=word) open_url(url) def get_remember_current_page_opt(self): from calibre.gui2.viewer.documentview import config c = config().parse() return c.remember_current_page def print_book(self): p = Printing(self.iterator, self) p.start_print() def print_preview(self): p = Printing(self.iterator, self) p.start_preview() def toggle_fullscreen(self): if self.isFullScreen(): self.showNormal() else: self.showFullScreen() def showFullScreen(self): self.view.document.page_position.save() self.window_mode_changed = "fullscreen" self.tool_bar.setVisible(False) self.tool_bar2.setVisible(False) self.was_maximized = self.isMaximized() if not self.view.document.fullscreen_scrollbar: self.vertical_scrollbar.setVisible(False) super(EbookViewer, self).showFullScreen() def show_full_screen_label(self): f = self.full_screen_label height = f.final_height width = int(0.7 * self.view.width()) f.resize(width, height) if self.view.document.show_fullscreen_help: f.setVisible(True) a = self.full_screen_label_anim a.setDuration(500) a.setStartValue(QSize(width, 0)) a.setEndValue(QSize(width, height)) a.start() QTimer.singleShot(3500, self.full_screen_label.hide) self.view.document.switch_to_fullscreen_mode() if self.view.document.fullscreen_clock: self.show_clock() if self.view.document.fullscreen_pos: self.show_pos_label() self.relayout_fullscreen_labels() def show_clock(self): self.clock_label.setVisible(True) self.clock_label.setText(QTime(22, 33, 33).toString(Qt.SystemLocaleShortDate)) self.clock_timer.start(1000) self.clock_label.setStyleSheet(self.info_label_style % ("rgba(0, 0, 0, 0)", self.view.document.colors()[1])) self.clock_label.resize(self.clock_label.sizeHint()) self.update_clock() def show_pos_label(self): self.pos_label.setVisible(True) self.pos_label.setStyleSheet(self.info_label_style % ("rgba(0, 0, 0, 0)", self.view.document.colors()[1])) self.update_pos_label() def relayout_fullscreen_labels(self): vswidth = self.vertical_scrollbar.width() if self.vertical_scrollbar.isVisible() else 0 p = self.pos_label p.move(15, p.parent().height() - p.height() - 10) c = self.clock_label c.move(c.parent().width() - vswidth - 15 - c.width(), c.parent().height() - c.height() - 10) f = self.full_screen_label f.move((f.parent().width() - f.width()) // 2, (f.parent().height() - f.final_height) // 2) def update_clock(self): self.clock_label.setText(QTime.currentTime().toString(Qt.SystemLocaleShortDate)) def update_pos_label(self, *args): if self.pos_label.isVisible(): try: value, maximum = args except: value, maximum = self.pos.value(), self.pos.maximum() text = "%g/%g" % (value, maximum) self.pos_label.setText(text) self.pos_label.resize(self.pos_label.sizeHint()) def showNormal(self): self.view.document.page_position.save() self.clock_label.setVisible(False) self.pos_label.setVisible(False) self.clock_timer.stop() self.vertical_scrollbar.setVisible(True) self.window_mode_changed = "normal" self.settings_changed() self.full_screen_label.setVisible(False) if self.was_maximized: super(EbookViewer, self).showMaximized() else: super(EbookViewer, self).showNormal() def handle_window_mode_toggle(self): if self.window_mode_changed: fs = self.window_mode_changed == "fullscreen" self.window_mode_changed = None if fs: self.show_full_screen_label() else: self.view.document.switch_to_window_mode() self.view.document.page_position.restore() self.scrolled(self.view.scroll_fraction) def goto(self, ref): if ref: tokens = ref.split(".") if len(tokens) > 1: spine_index = int(tokens[0]) - 1 if spine_index == self.current_index: self.view.goto(ref) else: self.pending_reference = ref self.load_path(self.iterator.spine[spine_index]) def goto_bookmark(self, bm): spine_index = bm["spine"] if spine_index > -1 and self.current_index == spine_index: if self.resize_in_progress: self.view.document.page_position.set_pos(bm["pos"]) else: self.view.goto_bookmark(bm) # Going to a bookmark does not call scrolled() so we update the # page position explicitly. Use a timer to ensure it is # accurate. QTimer.singleShot(100, self.update_page_number) else: self.pending_bookmark = bm if spine_index < 0 or spine_index >= len(self.iterator.spine): spine_index = 0 self.pending_bookmark = None self.load_path(self.iterator.spine[spine_index]) def toc_clicked(self, index, force=False): if force or QApplication.mouseButtons() & Qt.LeftButton: item = self.toc_model.itemFromIndex(index) if item.abspath is not None: if not os.path.exists(item.abspath): return error_dialog( self, _("No such location"), _("The location pointed to by this item" " does not exist."), det_msg=item.abspath, show=True, ) url = QUrl.fromLocalFile(item.abspath) if item.fragment: url.setFragment(item.fragment) self.link_clicked(url) self.view.setFocus(Qt.OtherFocusReason) def selection_changed(self, selected_text): self.selected_text = selected_text.strip() self.action_copy.setEnabled(bool(self.selected_text)) def copy(self, x): if self.selected_text: QApplication.clipboard().setText(self.selected_text) def back(self, x): pos = self.history.back(self.pos.value()) if pos is not None: self.goto_page(pos) def goto_page_num(self): num = self.pos.value() self.goto_page(num) def forward(self, x): pos = self.history.forward(self.pos.value()) if pos is not None: self.goto_page(pos) def goto_start(self): self.goto_page(1) def goto_end(self): self.goto_page(self.pos.maximum()) def goto_page(self, new_page, loaded_check=True): if self.current_page is not None or not loaded_check: for page in self.iterator.spine: if new_page >= page.start_page and new_page <= page.max_page: try: frac = float(new_page - page.start_page) / (page.pages - 1) except ZeroDivisionError: frac = 0 if page == self.current_page: self.view.scroll_to(frac) else: self.load_path(page, pos=frac) def open_ebook(self, checked): files = choose_files( self, "ebook viewer open dialog", _("Choose ebook"), [(_("Ebooks"), available_input_formats())], all_files=False, select_only_single_file=True, ) if files: self.load_ebook(files[0]) def open_recent(self, action): self.load_ebook(action.path) def font_size_larger(self): self.view.magnify_fonts() def font_size_smaller(self): self.view.shrink_fonts() def magnification_changed(self, val): tt = "%(action)s [%(sc)s]\n" + _("Current magnification: %(mag).1f") sc = _(" or ").join(self.view.shortcuts.get_shortcuts("Font larger")) self.action_font_size_larger.setToolTip( tt % dict(action=unicode(self.action_font_size_larger.text()), mag=val, sc=sc) ) sc = _(" or ").join(self.view.shortcuts.get_shortcuts("Font smaller")) self.action_font_size_smaller.setToolTip( tt % dict(action=unicode(self.action_font_size_smaller.text()), mag=val, sc=sc) ) self.action_font_size_larger.setEnabled(self.view.multiplier < 3) self.action_font_size_smaller.setEnabled(self.view.multiplier > 0.2) def find(self, text, repeat=False, backwards=False): if not text: self.view.search("") return self.search.search_done(False) if self.view.search(text, backwards=backwards): self.scrolled(self.view.scroll_fraction) return self.search.search_done(True) index = self.iterator.search(text, self.current_index, backwards=backwards) if index is None: if self.current_index > 0: index = self.iterator.search(text, 0) if index is None: info_dialog(self, _("No matches found"), _("No matches found for: %s") % text).exec_() return self.search.search_done(True) return self.search.search_done(True) self.pending_search = text self.pending_search_dir = "backwards" if backwards else "forwards" self.load_path(self.iterator.spine[index]) def find_next(self): self.find(unicode(self.search.text()), repeat=True) def find_previous(self): self.find(unicode(self.search.text()), repeat=True, backwards=True) def do_search(self, text, backwards): self.pending_search = None self.pending_search_dir = None if self.view.search(text, backwards=backwards): self.scrolled(self.view.scroll_fraction) def internal_link_clicked(self, prev_pos): self.history.add(prev_pos) def link_clicked(self, url): path = os.path.abspath(unicode(url.toLocalFile())) frag = None if path in self.iterator.spine: self.update_page_number() # Ensure page number is accurate as it is used for history self.history.add(self.pos.value()) path = self.iterator.spine[self.iterator.spine.index(path)] if url.hasFragment(): frag = unicode(url.fragment()) if path != self.current_page: self.pending_anchor = frag self.load_path(path) else: oldpos = self.view.document.ypos if frag: self.view.scroll_to(frag) else: # Scroll to top self.view.scroll_to(0) if self.view.document.ypos == oldpos: # If we are coming from goto_next_section() call this will # cause another goto next section call with the next toc # entry, since this one did not cause any scrolling at all. QTimer.singleShot(10, self.update_indexing_state) else: open_url(url) def load_started(self): self.open_progress_indicator(_("Loading flow...")) def load_finished(self, ok): self.close_progress_indicator() path = self.view.path() try: index = self.iterator.spine.index(path) except (ValueError, AttributeError): return -1 self.current_page = self.iterator.spine[index] self.current_index = index self.set_page_number(self.view.scroll_fraction) QTimer.singleShot(100, self.update_indexing_state) if self.pending_search is not None: self.do_search(self.pending_search, self.pending_search_dir == "backwards") self.pending_search = None self.pending_search_dir = None if self.pending_anchor is not None: self.view.scroll_to(self.pending_anchor) self.pending_anchor = None if self.pending_reference is not None: self.view.goto(self.pending_reference) self.pending_reference = None if self.pending_bookmark is not None: self.goto_bookmark(self.pending_bookmark) self.pending_bookmark = None if self.pending_restore: self.view.document.page_position.restore() return self.current_index def goto_next_section(self): if hasattr(self, "current_index"): entry = self.toc_model.next_entry( self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, ) if entry is not None: self.pending_goto_next_section = (self.toc_model.currently_viewed_entry, entry, False) self.toc_clicked(entry.index(), force=True) def goto_previous_section(self): if hasattr(self, "current_index"): entry = self.toc_model.next_entry( self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, backwards=True, ) if entry is not None: self.pending_goto_next_section = (self.toc_model.currently_viewed_entry, entry, True) self.toc_clicked(entry.index(), force=True) def update_indexing_state(self, anchor_positions=None): pgns = getattr(self, "pending_goto_next_section", None) if hasattr(self, "current_index"): if anchor_positions is None: anchor_positions = self.view.document.read_anchor_positions() items = self.toc_model.update_indexing_state( self.current_index, self.view.viewport_rect, anchor_positions, self.view.document.in_paged_mode ) if items: self.toc.scrollTo(items[-1].index()) if pgns is not None: self.pending_goto_next_section = None # Check that we actually progressed if pgns[0] is self.toc_model.currently_viewed_entry: entry = self.toc_model.next_entry( self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, backwards=pgns[2], current_entry=pgns[1], ) if entry is not None: self.pending_goto_next_section = (self.toc_model.currently_viewed_entry, entry, pgns[2]) self.toc_clicked(entry.index(), force=True) def load_path(self, path, pos=0.0): self.open_progress_indicator(_("Laying out %s") % self.current_title) self.view.load_path(path, pos=pos) def viewport_resize_started(self, event): if not self.resize_in_progress: # First resize, so save the current page position self.resize_in_progress = True if not self.window_mode_changed: # The special handling for window mode changed will already # have saved page position, so only save it if this is not a # mode change self.view.document.page_position.save() if self.resize_in_progress: self.view_resized_timer.start(75) def viewport_resize_finished(self): # There hasn't been a resize event for some time # restore the current page position. self.resize_in_progress = False if self.window_mode_changed: # This resize is part of a window mode change, special case it self.handle_window_mode_toggle() else: self.view.document.page_position.restore() if self.isFullScreen(): self.relayout_fullscreen_labels() self.view.document.after_resize() # For some reason scroll_fraction returns incorrect results in paged # mode for some time after a resize is finished. No way of knowing # exactly how long, so we update it in a second, in the hopes that it # will be enough *most* of the time. QTimer.singleShot(1000, self.update_page_number) def update_page_number(self): self.set_page_number(self.view.document.scroll_fraction) return self.pos.value() def close_progress_indicator(self): self.pi.stop() for o in ("tool_bar", "tool_bar2", "view", "horizontal_scrollbar", "vertical_scrollbar"): getattr(self, o).setEnabled(True) self.unsetCursor() self.view.setFocus(Qt.PopupFocusReason) def open_progress_indicator(self, msg=""): self.pi.start(msg) for o in ("tool_bar", "tool_bar2", "view", "horizontal_scrollbar", "vertical_scrollbar"): getattr(self, o).setEnabled(False) self.setCursor(Qt.BusyCursor) def load_theme_menu(self): from calibre.gui2.viewer.config import load_themes self.themes_menu.clear() for key in load_themes(): title = key[len("theme_") :] self.themes_menu.addAction(title, partial(self.load_theme, key)) def load_theme(self, theme_id): self.view.load_theme(theme_id) def do_config(self): self.view.config(self) self.load_theme_menu() from calibre.gui2 import config if not config["viewer_search_history"]: self.search.clear_history() def bookmark(self, *args): num = 1 bm = None while True: bm = _("Bookmark #%d") % num if bm not in self.existing_bookmarks: break num += 1 title, ok = QInputDialog.getText(self, _("Add bookmark"), _("Enter title for bookmark:"), text=bm) title = unicode(title).strip() if ok and title: bm = self.view.bookmark() bm["spine"] = self.current_index bm["title"] = title self.iterator.add_bookmark(bm) self.set_bookmarks(self.iterator.bookmarks) self.bookmarks.set_current_bookmark(bm) def bookmarks_edited(self, bookmarks): self.build_bookmarks_menu(bookmarks) self.iterator.set_bookmarks(bookmarks) self.iterator.save_bookmarks() def build_bookmarks_menu(self, bookmarks): self.bookmarks_menu.clear() sc = _(" or ").join(self.view.shortcuts.get_shortcuts("Bookmark")) self.bookmarks_menu.addAction(_("Bookmark this location [%s]") % sc, self.bookmark) self.bookmarks_menu.addAction(_("Show/hide Bookmarks"), self.bookmarks_dock.toggleViewAction().trigger) self.bookmarks_menu.addSeparator() current_page = None self.existing_bookmarks = [] for bm in bookmarks: if bm["title"] == "calibre_current_page_bookmark": if self.get_remember_current_page_opt(): current_page = bm else: self.existing_bookmarks.append(bm["title"]) self.bookmarks_menu.addAction(bm["title"], partial(self.goto_bookmark, bm)) return current_page def set_bookmarks(self, bookmarks): self.bookmarks.set_bookmarks(bookmarks) return self.build_bookmarks_menu(bookmarks) @property def current_page_bookmark(self): bm = self.view.bookmark() bm["spine"] = self.current_index bm["title"] = "calibre_current_page_bookmark" return bm def save_current_position(self): if not self.get_remember_current_page_opt(): return if hasattr(self, "current_index"): try: self.iterator.add_bookmark(self.current_page_bookmark) except: traceback.print_exc() def load_ebook(self, pathtoebook, open_at=None, reopen_at=None): if self.iterator is not None: self.save_current_position() self.iterator.__exit__() self.iterator = EbookIterator(pathtoebook) self.history.clear() self.open_progress_indicator(_("Loading ebook...")) worker = Worker(target=partial(self.iterator.__enter__, view_kepub=True)) worker.start() while worker.isAlive(): worker.join(0.1) QApplication.processEvents() if worker.exception is not None: if isinstance(worker.exception, DRMError): from calibre.gui2.dialogs.drm_error import DRMErrorMessage DRMErrorMessage(self).exec_() else: r = getattr(worker.exception, "reason", worker.exception) error_dialog( self, _("Could not open ebook"), as_unicode(r) or _("Unknown error"), det_msg=worker.traceback, show=True, ) self.close_progress_indicator() else: self.metadata.show_opf(self.iterator.opf, self.iterator.book_format) self.view.current_language = self.iterator.language title = self.iterator.opf.title if not title: title = os.path.splitext(os.path.basename(pathtoebook))[0] if self.iterator.toc: self.toc_model = TOC(self.iterator.spine, self.iterator.toc) self.toc.setModel(self.toc_model) if self.show_toc_on_open: self.action_table_of_contents.setChecked(True) else: self.toc_model = TOC(self.iterator.spine) self.toc.setModel(self.toc_model) self.action_table_of_contents.setChecked(False) if isbytestring(pathtoebook): pathtoebook = force_unicode(pathtoebook, filesystem_encoding) vh = vprefs.get("viewer_open_history", []) try: vh.remove(pathtoebook) except: pass vh.insert(0, pathtoebook) vprefs.set("viewer_open_history", vh[:50]) self.build_recent_menu() self.action_table_of_contents.setDisabled(not self.iterator.toc) self.current_book_has_toc = bool(self.iterator.toc) self.current_title = title self.setWindowTitle(title + " [%s]" % self.iterator.book_format + " - " + self.base_window_title) self.pos.setMaximum(sum(self.iterator.pages)) self.pos.setSuffix(" / %d" % sum(self.iterator.pages)) self.vertical_scrollbar.setMinimum(100) self.vertical_scrollbar.setMaximum(100 * sum(self.iterator.pages)) self.vertical_scrollbar.setSingleStep(10) self.vertical_scrollbar.setPageStep(100) self.set_vscrollbar_value(1) self.current_index = -1 QApplication.instance().alert(self, 5000) previous = self.set_bookmarks(self.iterator.bookmarks) if reopen_at is not None: previous = reopen_at if open_at is None and previous is not None: self.goto_bookmark(previous) else: if open_at is None: self.next_document() else: if open_at > self.pos.maximum(): open_at = self.pos.maximum() if open_at < self.pos.minimum(): open_at = self.pos.minimum() self.goto_page(open_at, loaded_check=False) def set_vscrollbar_value(self, pagenum): self.vertical_scrollbar.blockSignals(True) self.vertical_scrollbar.setValue(int(pagenum * 100)) self.vertical_scrollbar.blockSignals(False) def set_page_number(self, frac): if getattr(self, "current_page", None) is not None: page = self.current_page.start_page + frac * float(self.current_page.pages - 1) self.pos.set_value(page) self.set_vscrollbar_value(page) def scrolled(self, frac, onload=False): self.set_page_number(frac) if not onload: ap = self.view.document.read_anchor_positions() self.update_indexing_state(ap) def next_document(self): if hasattr(self, "current_index") and self.current_index < len(self.iterator.spine) - 1: self.load_path(self.iterator.spine[self.current_index + 1]) def previous_document(self): if hasattr(self, "current_index") and self.current_index > 0: self.load_path(self.iterator.spine[self.current_index - 1], pos=1.0) def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: if self.metadata.isVisible(): self.metadata.setVisible(False) event.accept() return if self.isFullScreen(): self.action_full_screen.trigger() event.accept() return try: key = self.view.shortcuts.get_match(event) except AttributeError: return MainWindow.keyPressEvent(self, event) try: bac = self.bookmarks_menu.actions()[0] except (AttributeError, TypeError, IndexError, KeyError): bac = None action = { "Quit": self.action_quit, "Show metadata": self.action_metadata, "Copy": self.view.copy_action, "Font larger": self.action_font_size_larger, "Font smaller": self.action_font_size_smaller, "Fullscreen": self.action_full_screen, "Find next": self.action_find_next, "Find previous": self.action_find_previous, "Search online": self.view.search_online_action, "Lookup word": self.view.dictionary_action, "Next occurrence": self.view.search_action, "Bookmark": bac, "Reload": self.action_reload, "Table of Contents": self.action_table_of_contents, }.get(key, None) if action is not None: event.accept() action.trigger() return if key == "Focus Search": self.search.setFocus(Qt.OtherFocusReason) return if not self.view.handle_key_press(event): event.ignore() def reload_book(self): if getattr(self.iterator, "pathtoebook", None): try: reopen_at = self.current_page_bookmark except Exception: reopen_at = None self.load_ebook(self.iterator.pathtoebook, reopen_at=reopen_at) return def __enter__(self): return self def __exit__(self, *args): if self.iterator is not None: self.save_current_position() self.iterator.__exit__(*args) def read_settings(self): c = config().parse() if c.remember_window_size: wg = vprefs.get("viewer_window_geometry", None) if wg is not None: self.restoreGeometry(wg) self.show_toc_on_open = vprefs.get("viewer_toc_isvisible", False) desktop = QApplication.instance().desktop() av = desktop.availableGeometry(self).height() - 30 if self.height() > av: self.resize(self.width(), av)
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 i, bar in enumerate((self.scrollbar, self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar())): self.bars.append(bar) bar.valueChanged[int].connect(partial(self.scrolled, i)) self.view.left.resized.connect(self.resized) for i, v in enumerate((self.view.left, self.view.right, self.view.handle(1))): v.wheel_event.connect(self.scrollbar.wheelEvent) if i < 2: v.next_change.connect(self.next_change) v.line_activated.connect(self.line_activated) v.scrolled.connect(partial(self.scrolled, i + 1)) 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.blockCount() > ls.pageStep() or self.view.right.blockCount() > 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_Up, Qt.Key_Down, Qt.Key_J, Qt.Key_K): amount = self.scrollbar.singleStep() if key in (Qt.Key_Up, Qt.Key_K): d = -1 elif key in (Qt.Key_PageUp, Qt.Key_PageDown): amount = self.scrollbar.pageStep() if key in (Qt.Key_PageUp,): d = -1 elif key in (Qt.Key_Home, Qt.Key_End): self.scrollbar.setValue(0 if key == Qt.Key_Home else self.scrollbar.maximum()) return True elif key in (Qt.Key_N, Qt.Key_P): self.next_change(1 if key == Qt.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 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.AlignTop | Qt.AlignLeft) s.addWidget(la) self.box = box = Box(self) box.hyperlink_activated.connect(self.goto_declaration, type=Qt.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 # We must let the event loop run otherwise the webview will return # stale data in read_data() 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.setPointSize(tprefs['editor_font_size']) self.setFont(f) theme = get_theme(tprefs['editor_theme']) pal = self.palette() pal.setColor(pal.Window, theme_color(theme, 'Normal', 'bg')) pal.setColor(pal.WindowText, theme_color(theme, 'Normal', 'fg')) pal.setColor(pal.AlternateBase, theme_color(theme, 'HighlightRegion', 'bg')) pal.setColor(pal.Link, theme_color(theme, 'Link', 'fg')) pal.setColor(pal.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: data = self.read_data(sourceline, 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 if self.now_showing == ( None, None, None) or self.now_showing[0] != self.current_name: self.clear() return # Try to refresh the data for the currently shown tag instead # of clearing editor_name, sourceline, tags = self.now_showing data = self.read_data(sourceline, tags) if data is None or len(data['computed_css']) < 1: 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 read_data(self, sourceline, tags): mf = self.preview.view.page().mainFrame() tags = [x.lower() for x in tags] result = str( mf.evaluateJavaScript( 'window.calibre_preview_integration.live_css(%s, %s)' % (json.dumps(sourceline), json.dumps(tags))) or '') try: result = json.loads(result) except ValueError: result = None if result is not None: maximum_specificities = {} for node in result['nodes']: is_ancestor = node['is_ancestor'] for rule in node['css']: self.process_rule(rule, is_ancestor, 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 return result def process_rule(self, rule, is_ancestor, 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))) ancestor_specificity = 0 if is_ancestor else 1 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, (0, 0, 0, 0, 0, 0)): maximum_specificities[p.name] = p.specificity rule['properties'] = properties href = rule['href'] if hasattr(href, 'startswith') and href.startswith( '%s://%s' % (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 EbookViewer(MainWindow): STATE_VERSION = 2 FLOW_MODE_TT = _('Switch to paged mode - where the text is broken up ' 'into pages like a paper book') PAGED_MODE_TT = _('Switch to flow mode - where the text is not broken up ' 'into pages') def __init__(self, pathtoebook=None, debug_javascript=False, open_at=None, start_in_fullscreen=False): MainWindow.__init__(self, debug_javascript) self.view.magnification_changed.connect(self.magnification_changed) self.show_toc_on_open = False self.current_book_has_toc = False self.iterator = None self.current_page = None self.pending_search = None self.pending_search_dir= None self.pending_anchor = None self.pending_reference = None self.pending_bookmark = None self.pending_restore = False self.cursor_hidden = False self.existing_bookmarks= [] self.selected_text = None self.was_maximized = False self.page_position_on_footnote_toggle = [] self.read_settings() self.pos.value_changed.connect(self.update_pos_label) self.pos.setMinimumWidth(150) self.setFocusPolicy(Qt.StrongFocus) self.view.set_manager(self) self.pi = ProgressIndicator(self) self.action_quit = QAction(_('&Quit'), self) self.addAction(self.action_quit) self.view_resized_timer = QTimer(self) self.view_resized_timer.timeout.connect(self.viewport_resize_finished) self.view_resized_timer.setSingleShot(True) self.resize_in_progress = False self.action_reload = QAction(_('&Reload book'), self) self.action_reload.triggered.connect(self.reload_book) self.action_quit.triggered.connect(self.quit) self.action_reference_mode.triggered[bool].connect(self.view.reference_mode) self.action_metadata.triggered[bool].connect(self.metadata.setVisible) self.action_table_of_contents.toggled[bool].connect(self.set_toc_visible) self.action_copy.triggered[bool].connect(self.copy) self.action_font_size_larger.triggered.connect(self.font_size_larger) self.action_font_size_smaller.triggered.connect(self.font_size_smaller) self.action_open_ebook.triggered[bool].connect(self.open_ebook) self.action_next_page.triggered.connect(self.view.next_page) self.action_previous_page.triggered.connect(self.view.previous_page) self.action_find_next.triggered.connect(self.find_next) self.action_find_previous.triggered.connect(self.find_previous) self.action_full_screen.triggered[bool].connect(self.toggle_fullscreen) self.action_back.triggered[bool].connect(self.back) self.action_forward.triggered[bool].connect(self.forward) self.action_preferences.triggered.connect(self.do_config) self.pos.editingFinished.connect(self.goto_page_num) self.vertical_scrollbar.valueChanged[int].connect(lambda x:self.goto_page(x/100.)) self.search.search.connect(self.find) self.search.focus_to_library.connect(lambda: self.view.setFocus(Qt.OtherFocusReason)) self.toc.pressed[QModelIndex].connect(self.toc_clicked) self.toc.searched.connect(partial(self.toc_clicked, force=True)) self.reference.goto.connect(self.goto) self.bookmarks.edited.connect(self.bookmarks_edited) self.bookmarks.activated.connect(self.goto_bookmark) self.bookmarks.create_requested.connect(self.bookmark) self.set_bookmarks([]) self.load_theme_menu() if pathtoebook is not None: f = functools.partial(self.load_ebook, pathtoebook, open_at=open_at) QTimer.singleShot(50, f) self.window_mode_changed = None self.toggle_toolbar_action = QAction(_('Show/hide controls'), self) self.toggle_toolbar_action.setCheckable(True) self.toggle_toolbar_action.triggered.connect(self.toggle_toolbars) self.toolbar_hidden = None self.addAction(self.toggle_toolbar_action) self.full_screen_label_anim = QPropertyAnimation( self.full_screen_label, 'size') self.clock_timer = QTimer(self) self.clock_timer.timeout.connect(self.update_clock) self.action_print.triggered.connect(self.print_book) self.print_menu.actions()[0].triggered.connect(self.print_preview) self.clear_recent_history_action = QAction( _('Clear list of recently opened books'), self) self.clear_recent_history_action.triggered.connect(self.clear_recent_history) self.build_recent_menu() self.open_history_menu.triggered.connect(self.open_recent) for x in ('tool_bar', 'tool_bar2'): x = getattr(self, x) for action in x.actions(): # So that the keyboard shortcuts for these actions will # continue to function even when the toolbars are hidden self.addAction(action) for plugin in self.view.document.all_viewer_plugins: plugin.customize_ui(self) self.view.document.settings_changed.connect(self.settings_changed) self.restore_state() self.settings_changed() self.action_toggle_paged_mode.toggled[bool].connect(self.toggle_paged_mode) if (start_in_fullscreen or self.view.document.start_in_fullscreen): self.action_full_screen.trigger() self.hide_cursor_timer = t = QTimer(self) t.setSingleShot(True), t.setInterval(3000) t.timeout.connect(self.hide_cursor) t.start() def eventFilter(self, obj, ev): if ev.type() == ev.MouseMove: if self.cursor_hidden: self.cursor_hidden = False QApplication.instance().restoreOverrideCursor() self.hide_cursor_timer.start() return False def hide_cursor(self): self.cursor_hidden = True QApplication.instance().setOverrideCursor(Qt.BlankCursor) def toggle_paged_mode(self, checked, at_start=False): in_paged_mode = not self.action_toggle_paged_mode.isChecked() self.view.document.in_paged_mode = in_paged_mode self.action_toggle_paged_mode.setToolTip(self.FLOW_MODE_TT if self.action_toggle_paged_mode.isChecked() else self.PAGED_MODE_TT) if at_start: return self.reload() def settings_changed(self): for x in ('', '2'): x = getattr(self, 'tool_bar'+x) x.setVisible(self.view.document.show_controls) def reload(self): if hasattr(self, 'current_index') and self.current_index > -1: self.view.document.page_position.save(overwrite=False) self.pending_restore = True self.load_path(self.view.last_loaded_path) def set_toc_visible(self, yes): self.toc_dock.setVisible(yes) if not yes: self.show_toc_on_open = False def clear_recent_history(self, *args): vprefs.set('viewer_open_history', []) self.build_recent_menu() def build_recent_menu(self): m = self.open_history_menu m.clear() recent = vprefs.get('viewer_open_history', []) if recent: m.addAction(self.clear_recent_history_action) m.addSeparator() count = 0 for path in recent: if count > 9: break if os.path.exists(path): m.addAction(RecentAction(path, m)) count += 1 def shutdown(self): if self.isFullScreen() and not self.view.document.start_in_fullscreen: self.action_full_screen.trigger() return False self.save_state() return True def quit(self): if self.shutdown(): QApplication.instance().quit() def closeEvent(self, e): if self.shutdown(): return MainWindow.closeEvent(self, e) else: e.ignore() def toggle_toolbars(self): for x in ('tool_bar', 'tool_bar2'): x = getattr(self, x) x.setVisible(not x.isVisible()) def save_state(self): state = bytearray(self.saveState(self.STATE_VERSION)) vprefs['main_window_state'] = state if not self.isFullScreen(): vprefs.set('viewer_window_geometry', bytearray(self.saveGeometry())) if self.current_book_has_toc: vprefs.set('viewer_toc_isvisible', self.show_toc_on_open or bool(self.toc_dock.isVisible())) vprefs['multiplier'] = self.view.multiplier vprefs['in_paged_mode'] = not self.action_toggle_paged_mode.isChecked() def restore_state(self): state = vprefs.get('main_window_state', None) if state is not None: try: state = QByteArray(state) self.restoreState(state, self.STATE_VERSION) except: pass self.initialize_dock_state() mult = vprefs.get('multiplier', None) if mult: self.view.multiplier = mult # On windows Qt lets the user hide toolbars via a right click in a very # specific location, ensure they are visible. self.tool_bar.setVisible(True) self.tool_bar2.setVisible(True) self.toc_dock.close() # This will be opened on book open, if the book has a toc and it was previously opened self.action_toggle_paged_mode.setChecked(not vprefs.get('in_paged_mode', True)) self.toggle_paged_mode(self.action_toggle_paged_mode.isChecked(), at_start=True) def lookup(self, word): from urllib import quote word = quote(word.encode('utf-8')) try: url = lookup_website(canonicalize_lang(self.view.current_language) or 'en').format(word=word) except Exception: traceback.print_exc() url = default_lookup_website(canonicalize_lang(self.view.current_language) or 'en').format(word=word) open_url(url) def print_book(self): p = Printing(self.iterator, self) p.start_print() def print_preview(self): p = Printing(self.iterator, self) p.start_preview() def toggle_fullscreen(self): if self.isFullScreen(): self.showNormal() else: self.showFullScreen() def showFullScreen(self): self.view.document.page_position.save() self.window_mode_changed = 'fullscreen' self.tool_bar.setVisible(False) self.tool_bar2.setVisible(False) self.was_maximized = self.isMaximized() if not self.view.document.fullscreen_scrollbar: self.vertical_scrollbar.setVisible(False) super(EbookViewer, self).showFullScreen() def show_full_screen_label(self): f = self.full_screen_label height = f.final_height width = int(0.7*self.view.width()) f.resize(width, height) if self.view.document.show_fullscreen_help: f.setVisible(True) a = self.full_screen_label_anim a.setDuration(500) a.setStartValue(QSize(width, 0)) a.setEndValue(QSize(width, height)) a.start() QTimer.singleShot(3500, self.full_screen_label.hide) self.view.document.switch_to_fullscreen_mode() if self.view.document.fullscreen_clock: self.show_clock() if self.view.document.fullscreen_pos: self.show_pos_label() self.relayout_fullscreen_labels() def show_clock(self): self.clock_label.setVisible(True) self.clock_label.setText(QTime(22, 33, 33).toString(Qt.SystemLocaleShortDate)) self.clock_timer.start(1000) self.clock_label.setStyleSheet(self.info_label_style%( 'rgba(0, 0, 0, 0)', self.view.document.colors()[1])) self.clock_label.resize(self.clock_label.sizeHint()) self.update_clock() def show_pos_label(self): self.pos_label.setVisible(True) self.pos_label.setStyleSheet(self.info_label_style%( 'rgba(0, 0, 0, 0)', self.view.document.colors()[1])) self.update_pos_label() def relayout_fullscreen_labels(self): vswidth = (self.vertical_scrollbar.width() if self.vertical_scrollbar.isVisible() else 0) p = self.pos_label p.move(15, p.parent().height() - p.height()-10) c = self.clock_label c.move(c.parent().width() - vswidth - 15 - c.width(), c.parent().height() - c.height() - 10) f = self.full_screen_label f.move((f.parent().width() - f.width())//2, (f.parent().height() - f.final_height)//2) def update_clock(self): self.clock_label.setText(QTime.currentTime().toString(Qt.SystemLocaleShortDate)) def update_pos_label(self, *args): if self.pos_label.isVisible(): try: value, maximum = args except: value, maximum = self.pos.value(), self.pos.maximum() text = '%g/%g'%(value, maximum) self.pos_label.setText(text) self.pos_label.resize(self.pos_label.sizeHint()) def showNormal(self): self.view.document.page_position.save() self.clock_label.setVisible(False) self.pos_label.setVisible(False) self.clock_timer.stop() self.vertical_scrollbar.setVisible(True) self.window_mode_changed = 'normal' self.settings_changed() self.full_screen_label.setVisible(False) if self.was_maximized: super(EbookViewer, self).showMaximized() else: super(EbookViewer, self).showNormal() def handle_window_mode_toggle(self): if self.window_mode_changed: fs = self.window_mode_changed == 'fullscreen' self.window_mode_changed = None if fs: self.show_full_screen_label() else: self.view.document.switch_to_window_mode() self.view.document.page_position.restore() self.scrolled(self.view.scroll_fraction) def goto(self, ref): if ref: tokens = ref.split('.') if len(tokens) > 1: spine_index = int(tokens[0]) -1 if spine_index == self.current_index: self.view.goto(ref) else: self.pending_reference = ref self.load_path(self.iterator.spine[spine_index]) def goto_bookmark(self, bm): spine_index = bm['spine'] if spine_index > -1 and self.current_index == spine_index: if self.resize_in_progress: self.view.document.page_position.set_pos(bm['pos']) else: self.view.goto_bookmark(bm) # Going to a bookmark does not call scrolled() so we update the # page position explicitly. Use a timer to ensure it is # accurate. QTimer.singleShot(100, self.update_page_number) else: self.pending_bookmark = bm if spine_index < 0 or spine_index >= len(self.iterator.spine): spine_index = 0 self.pending_bookmark = None self.load_path(self.iterator.spine[spine_index]) def toc_clicked(self, index, force=False): if force or QApplication.mouseButtons() & Qt.LeftButton: item = self.toc_model.itemFromIndex(index) if item.abspath is not None: if not os.path.exists(item.abspath): return error_dialog(self, _('No such location'), _('The location pointed to by this item' ' does not exist.'), det_msg=item.abspath, show=True) url = QUrl.fromLocalFile(item.abspath) if item.fragment: url.setFragment(item.fragment) self.link_clicked(url) self.view.setFocus(Qt.OtherFocusReason) def selection_changed(self, selected_text): self.selected_text = selected_text.strip() self.action_copy.setEnabled(bool(self.selected_text)) def copy(self, x): if self.selected_text: QApplication.clipboard().setText(self.selected_text) def back(self, x): pos = self.history.back(self.pos.value()) if pos is not None: self.goto_page(pos) def goto_page_num(self): num = self.pos.value() self.goto_page(num) def forward(self, x): pos = self.history.forward(self.pos.value()) if pos is not None: self.goto_page(pos) def goto_start(self): self.goto_page(1) def goto_end(self): self.goto_page(self.pos.maximum()) def goto_page(self, new_page, loaded_check=True): if self.current_page is not None or not loaded_check: for page in self.iterator.spine: if new_page >= page.start_page and new_page <= page.max_page: try: frac = float(new_page-page.start_page)/(page.pages-1) except ZeroDivisionError: frac = 0 if page == self.current_page: self.view.scroll_to(frac) else: self.load_path(page, pos=frac) def open_ebook(self, checked): files = choose_files(self, 'ebook viewer open dialog', _('Choose ebook'), [(_('Ebooks'), available_input_formats())], all_files=False, select_only_single_file=True) if files: self.load_ebook(files[0]) def open_recent(self, action): self.load_ebook(action.path) def font_size_larger(self): self.view.magnify_fonts() def font_size_smaller(self): self.view.shrink_fonts() def magnification_changed(self, val): tt = '%(action)s [%(sc)s]\n'+_('Current magnification: %(mag).1f') sc = _(' or ').join(self.view.shortcuts.get_shortcuts('Font larger')) self.action_font_size_larger.setToolTip( tt %dict(action=unicode(self.action_font_size_larger.text()), mag=val, sc=sc)) sc = _(' or ').join(self.view.shortcuts.get_shortcuts('Font smaller')) self.action_font_size_smaller.setToolTip( tt %dict(action=unicode(self.action_font_size_smaller.text()), mag=val, sc=sc)) self.action_font_size_larger.setEnabled(self.view.multiplier < 3) self.action_font_size_smaller.setEnabled(self.view.multiplier > 0.2) def find(self, text, repeat=False, backwards=False): if not text: self.view.search('') return self.search.search_done(False) if self.view.search(text, backwards=backwards): self.scrolled(self.view.scroll_fraction) return self.search.search_done(True) index = self.iterator.search(text, self.current_index, backwards=backwards) if index is None: if self.current_index > 0: index = self.iterator.search(text, 0) if index is None: info_dialog(self, _('No matches found'), _('No matches found for: %s')%text).exec_() return self.search.search_done(True) return self.search.search_done(True) self.pending_search = text self.pending_search_dir = 'backwards' if backwards else 'forwards' self.load_path(self.iterator.spine[index]) def find_next(self): self.find(unicode(self.search.text()), repeat=True) def find_previous(self): self.find(unicode(self.search.text()), repeat=True, backwards=True) def do_search(self, text, backwards): self.pending_search = None self.pending_search_dir = None if self.view.search(text, backwards=backwards): self.scrolled(self.view.scroll_fraction) def internal_link_clicked(self, prev_pos): self.history.add(prev_pos) def link_clicked(self, url): path = os.path.abspath(unicode(url.toLocalFile())) frag = None if path in self.iterator.spine: self.update_page_number() # Ensure page number is accurate as it is used for history self.history.add(self.pos.value()) path = self.iterator.spine[self.iterator.spine.index(path)] if url.hasFragment(): frag = unicode(url.fragment()) if path != self.current_page: self.pending_anchor = frag self.load_path(path) else: oldpos = self.view.document.ypos if frag: self.view.scroll_to(frag) else: # Scroll to top self.view.scroll_to(0) if self.view.document.ypos == oldpos: # If we are coming from goto_next_section() call this will # cause another goto next section call with the next toc # entry, since this one did not cause any scrolling at all. QTimer.singleShot(10, self.update_indexing_state) else: open_url(url) def load_started(self): self.open_progress_indicator(_('Loading flow...')) def load_finished(self, ok): self.close_progress_indicator() path = self.view.path() try: index = self.iterator.spine.index(path) except (ValueError, AttributeError): return -1 self.current_page = self.iterator.spine[index] self.current_index = index self.set_page_number(self.view.scroll_fraction) QTimer.singleShot(100, self.update_indexing_state) if self.pending_search is not None: self.do_search(self.pending_search, self.pending_search_dir=='backwards') self.pending_search = None self.pending_search_dir = None if self.pending_anchor is not None: self.view.scroll_to(self.pending_anchor) self.pending_anchor = None if self.pending_reference is not None: self.view.goto(self.pending_reference) self.pending_reference = None if self.pending_bookmark is not None: self.goto_bookmark(self.pending_bookmark) self.pending_bookmark = None if self.pending_restore: self.view.document.page_position.restore() return self.current_index def goto_next_section(self): if hasattr(self, 'current_index'): entry = self.toc_model.next_entry(self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode) if entry is not None: self.pending_goto_next_section = ( self.toc_model.currently_viewed_entry, entry, False) self.toc_clicked(entry.index(), force=True) def goto_previous_section(self): if hasattr(self, 'current_index'): entry = self.toc_model.next_entry(self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, backwards=True) if entry is not None: self.pending_goto_next_section = ( self.toc_model.currently_viewed_entry, entry, True) self.toc_clicked(entry.index(), force=True) def update_indexing_state(self, anchor_positions=None): pgns = getattr(self, 'pending_goto_next_section', None) if hasattr(self, 'current_index'): if anchor_positions is None: anchor_positions = self.view.document.read_anchor_positions() items = self.toc_model.update_indexing_state(self.current_index, self.view.viewport_rect, anchor_positions, self.view.document.in_paged_mode) if items: self.toc.scrollTo(items[-1].index()) if pgns is not None: self.pending_goto_next_section = None # Check that we actually progressed if pgns[0] is self.toc_model.currently_viewed_entry: entry = self.toc_model.next_entry(self.current_index, self.view.document.read_anchor_positions(), self.view.viewport_rect, self.view.document.in_paged_mode, backwards=pgns[2], current_entry=pgns[1]) if entry is not None: self.pending_goto_next_section = ( self.toc_model.currently_viewed_entry, entry, pgns[2]) self.toc_clicked(entry.index(), force=True) def load_path(self, path, pos=0.0): self.open_progress_indicator(_('Laying out %s')%self.current_title) self.view.load_path(path, pos=pos) def footnote_visibility_changed(self, is_visible): if self.view.document.in_paged_mode: pp = namedtuple('PagePosition', 'time is_visible page_dimensions multiplier last_loaded_path page_number after_resize_page_number') self.page_position_on_footnote_toggle.append(pp( time.time(), is_visible, self.view.document.page_dimensions, self.view.multiplier, self.view.last_loaded_path, self.view.document.page_number, None)) def pre_footnote_toggle_position(self): num = len(self.page_position_on_footnote_toggle) if self.view.document.in_paged_mode and num > 1 and num % 2 == 0: two, one = self.page_position_on_footnote_toggle.pop(), self.page_position_on_footnote_toggle.pop() if ( time.time() - two.time < 1 and not two.is_visible and one.is_visible and one.last_loaded_path == two.last_loaded_path and two.last_loaded_path == self.view.last_loaded_path and one.page_dimensions == self.view.document.page_dimensions and one.multiplier == self.view.multiplier and one.after_resize_page_number == self.view.document.page_number ): return one.page_number def viewport_resize_started(self, event): if not self.resize_in_progress: # First resize, so save the current page position self.resize_in_progress = True if not self.window_mode_changed: # The special handling for window mode changed will already # have saved page position, so only save it if this is not a # mode change self.view.document.page_position.save() if self.resize_in_progress: self.view_resized_timer.start(75) def viewport_resize_finished(self): # There hasn't been a resize event for some time # restore the current page position. self.resize_in_progress = False wmc = self.window_mode_changed if self.window_mode_changed: # This resize is part of a window mode change, special case it self.handle_window_mode_toggle() else: if self.isFullScreen(): self.relayout_fullscreen_labels() self.view.document.after_resize() if not wmc: pre_footnote_pos = self.pre_footnote_toggle_position() if pre_footnote_pos is not None: self.view.document.page_number = pre_footnote_pos else: self.view.document.page_position.restore() self.update_page_number() if len(self.page_position_on_footnote_toggle) % 2 == 1: self.page_position_on_footnote_toggle[-1] = self.page_position_on_footnote_toggle[-1]._replace( after_resize_page_number=self.view.document.page_number) def update_page_number(self): self.set_page_number(self.view.document.scroll_fraction) return self.pos.value() def close_progress_indicator(self): self.pi.stop() for o in ('tool_bar', 'tool_bar2', 'view', 'horizontal_scrollbar', 'vertical_scrollbar'): getattr(self, o).setEnabled(True) self.unsetCursor() self.view.setFocus(Qt.PopupFocusReason) def open_progress_indicator(self, msg=''): self.pi.start(msg) for o in ('tool_bar', 'tool_bar2', 'view', 'horizontal_scrollbar', 'vertical_scrollbar'): getattr(self, o).setEnabled(False) self.setCursor(Qt.BusyCursor) def load_theme_menu(self): from calibre.gui2.viewer.config import load_themes self.themes_menu.clear() for key in load_themes(): title = key[len('theme_'):] self.themes_menu.addAction(title, partial(self.load_theme, key)) def load_theme(self, theme_id): self.view.load_theme(theme_id) def do_config(self): self.view.config(self) self.load_theme_menu() if self.iterator is not None: self.iterator.copy_bookmarks_to_file = self.view.document.copy_bookmarks_to_file from calibre.gui2 import config if not config['viewer_search_history']: self.search.clear_history() def bookmark(self, *args): num = 1 bm = None while True: bm = _('Bookmark #%d')%num if bm not in self.existing_bookmarks: break num += 1 title, ok = QInputDialog.getText(self, _('Add bookmark'), _('Enter title for bookmark:'), text=bm) title = unicode(title).strip() if ok and title: bm = self.view.bookmark() bm['spine'] = self.current_index bm['title'] = title self.iterator.add_bookmark(bm) self.set_bookmarks(self.iterator.bookmarks) self.bookmarks.set_current_bookmark(bm) def bookmarks_edited(self, bookmarks): self.build_bookmarks_menu(bookmarks) self.iterator.set_bookmarks(bookmarks) self.iterator.save_bookmarks() def build_bookmarks_menu(self, bookmarks): self.bookmarks_menu.clear() sc = _(' or ').join(self.view.shortcuts.get_shortcuts('Bookmark')) self.bookmarks_menu.addAction(_("Bookmark this location [%s]") % sc, self.bookmark) self.bookmarks_menu.addAction(_("Show/hide Bookmarks"), self.bookmarks_dock.toggleViewAction().trigger) self.bookmarks_menu.addSeparator() current_page = None self.existing_bookmarks = [] for bm in bookmarks: if bm['title'] == 'calibre_current_page_bookmark': if self.view.document.remember_current_page: current_page = bm else: self.existing_bookmarks.append(bm['title']) self.bookmarks_menu.addAction(bm['title'], partial(self.goto_bookmark, bm)) return current_page def set_bookmarks(self, bookmarks): self.bookmarks.set_bookmarks(bookmarks) return self.build_bookmarks_menu(bookmarks) @property def current_page_bookmark(self): bm = self.view.bookmark() bm['spine'] = self.current_index bm['title'] = 'calibre_current_page_bookmark' return bm def save_current_position(self): if not self.view.document.remember_current_page: return if hasattr(self, 'current_index'): try: self.iterator.add_bookmark(self.current_page_bookmark) except: traceback.print_exc() def load_ebook(self, pathtoebook, open_at=None, reopen_at=None): if self.iterator is not None: self.save_current_position() self.iterator.__exit__() self.iterator = EbookIterator(pathtoebook, copy_bookmarks_to_file=self.view.document.copy_bookmarks_to_file) self.history.clear() self.open_progress_indicator(_('Loading ebook...')) worker = Worker(target=partial(self.iterator.__enter__, view_kepub=True)) worker.start() while worker.isAlive(): worker.join(0.1) QApplication.processEvents() if worker.exception is not None: if isinstance(worker.exception, DRMError): from calibre.gui2.dialogs.drm_error import DRMErrorMessage DRMErrorMessage(self).exec_() else: r = getattr(worker.exception, 'reason', worker.exception) error_dialog(self, _('Could not open ebook'), as_unicode(r) or _('Unknown error'), det_msg=worker.traceback, show=True) self.close_progress_indicator() else: self.metadata.show_opf(self.iterator.opf, self.iterator.book_format) self.view.current_language = self.iterator.language title = self.iterator.opf.title if not title: title = os.path.splitext(os.path.basename(pathtoebook))[0] if self.iterator.toc: self.toc_model = TOC(self.iterator.spine, self.iterator.toc) self.toc.setModel(self.toc_model) if self.show_toc_on_open: self.action_table_of_contents.setChecked(True) else: self.toc_model = TOC(self.iterator.spine) self.toc.setModel(self.toc_model) self.action_table_of_contents.setChecked(False) if isbytestring(pathtoebook): pathtoebook = force_unicode(pathtoebook, filesystem_encoding) vh = vprefs.get('viewer_open_history', []) try: vh.remove(pathtoebook) except: pass vh.insert(0, pathtoebook) vprefs.set('viewer_open_history', vh[:50]) self.build_recent_menu() self.footnotes_dock.close() self.action_table_of_contents.setDisabled(not self.iterator.toc) self.current_book_has_toc = bool(self.iterator.toc) self.current_title = title self.setWindowTitle(title + ' [%s]'%self.iterator.book_format + ' - ' + self.base_window_title) self.pos.setMaximum(sum(self.iterator.pages)) self.pos.setSuffix(' / %d'%sum(self.iterator.pages)) self.vertical_scrollbar.setMinimum(100) self.vertical_scrollbar.setMaximum(100*sum(self.iterator.pages)) self.vertical_scrollbar.setSingleStep(10) self.vertical_scrollbar.setPageStep(100) self.set_vscrollbar_value(1) self.current_index = -1 QApplication.instance().alert(self, 5000) previous = self.set_bookmarks(self.iterator.bookmarks) if reopen_at is not None: previous = reopen_at if open_at is None and previous is not None: self.goto_bookmark(previous) else: if open_at is None: self.next_document() else: if open_at > self.pos.maximum(): open_at = self.pos.maximum() if open_at < self.pos.minimum(): open_at = self.pos.minimum() self.goto_page(open_at, loaded_check=False) def set_vscrollbar_value(self, pagenum): self.vertical_scrollbar.blockSignals(True) self.vertical_scrollbar.setValue(int(pagenum*100)) self.vertical_scrollbar.blockSignals(False) def set_page_number(self, frac): if getattr(self, 'current_page', None) is not None: page = self.current_page.start_page + frac*float(self.current_page.pages-1) self.pos.set_value(page) self.set_vscrollbar_value(page) def scrolled(self, frac, onload=False): self.set_page_number(frac) if not onload: ap = self.view.document.read_anchor_positions() self.update_indexing_state(ap) def next_document(self): if (hasattr(self, 'current_index') and self.current_index < len(self.iterator.spine) - 1): self.load_path(self.iterator.spine[self.current_index+1]) def previous_document(self): if hasattr(self, 'current_index') and self.current_index > 0: self.load_path(self.iterator.spine[self.current_index-1], pos=1.0) def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: if self.metadata.isVisible(): self.metadata.setVisible(False) event.accept() return if self.isFullScreen(): self.action_full_screen.trigger() event.accept() return try: key = self.view.shortcuts.get_match(event) except AttributeError: return MainWindow.keyPressEvent(self, event) try: bac = self.bookmarks_menu.actions()[0] except (AttributeError, TypeError, IndexError, KeyError): bac = None action = { 'Quit':self.action_quit, 'Show metadata':self.action_metadata, 'Copy':self.view.copy_action, 'Font larger': self.action_font_size_larger, 'Font smaller': self.action_font_size_smaller, 'Fullscreen': self.action_full_screen, 'Find next': self.action_find_next, 'Find previous': self.action_find_previous, 'Search online': self.view.search_online_action, 'Lookup word': self.view.dictionary_action, 'Next occurrence': self.view.search_action, 'Bookmark': bac, 'Reload': self.action_reload, 'Table of Contents': self.action_table_of_contents, }.get(key, None) if action is not None: event.accept() action.trigger() return if key == 'Focus Search': self.search.setFocus(Qt.OtherFocusReason) return if not self.view.handle_key_press(event): event.ignore() def reload_book(self): if getattr(self.iterator, 'pathtoebook', None): try: reopen_at = self.current_page_bookmark except Exception: reopen_at = None self.history.clear() self.load_ebook(self.iterator.pathtoebook, reopen_at=reopen_at) return def __enter__(self): return self def __exit__(self, *args): if self.iterator is not None: self.save_current_position() self.iterator.__exit__(*args) def read_settings(self): c = config().parse() if c.remember_window_size: wg = vprefs.get('viewer_window_geometry', None) if wg is not None: self.restoreGeometry(wg) self.show_toc_on_open = vprefs.get('viewer_toc_isvisible', False) desktop = QApplication.instance().desktop() av = desktop.availableGeometry(self).height() - 30 if self.height() > av: self.resize(self.width(), av) def show_footnote_view(self): self.footnotes_dock.show()
class CoordinatorDockWidget(QtWidgets.QDockWidget, FORM_CLASS): SectionInput = 1 SectionOutput = 2 SectionBoth = 3 SideRight = 1 SideLeft = 2 SideBoth = 3 closingPlugin = pyqtSignal() inputChanged = pyqtSignal() mapConnectionChanged = pyqtSignal(int, bool) def __init__(self, parent=None): """Constructor.""" super(CoordinatorDockWidget, self).__init__(parent) # Set up the user interface from Designer. # After setupUI you can access any designer object by doing # self.<objectname>, and you can use autoconnect slots - see # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html # #widgets-and-dialogs-with-auto-connect self.decDegreeValidator = QDoubleValidator(0.0, 180.0, 8, parent=self) self.decDegreeValidator.setNotation(QDoubleValidator.StandardNotation) self.projectedValidator = QDoubleValidator(-999999999.0, 999999999.0, 4, parent=self) self._eastingLeftNorthingRight = True self._eastingInverted = False self._northingInverted = False self.sectionIsGeographic = { CoordinatorDockWidget.SectionInput: True, CoordinatorDockWidget.SectionOutput: True } self.setupUi(self) # setup some lists of the input for easier access later self._widgetlistInputDms = [ self.inLeft, self.inRight, self.inLeftSec, self.inRightSec, self.inLeftMin, self.inRightMin ] self._widgetlistInput = self._widgetlistInputDms + [ self.inLeftDec, self.inRightDec ] self.resetInterface() self.setupInternal() # need to init that after setupUi because of message icon label size messageIconWidgetSize = self.messageIcon.size() messageIconSize = QSize(messageIconWidgetSize.width() - 2, messageIconWidgetSize.height() - 2) self.warningPixmap = QIcon( ':/plugins/coordinator/icons/warning.svg').pixmap(messageIconSize) self.messageHideTimer = QTimer(parent=self) self.messageHideTimer.setSingleShot(True) self.messageHideTimer.timeout.connect(self.hideMessages) def setupInternal(self): self.leftDecimal.hide() self.rightDecimal.hide() self.leftDmsDisplay = DmsHandler(self.inLeft, self.inLeftMin, self.inLeftSec, 180) self.leftDmsDisplay.inputDidChange.connect( self.__inputFieldsChangedInternal) self.rightDmsDisplay = DmsHandler(self.inRight, self.inRightMin, self.inRightSec, 90) self.rightDmsDisplay.inputDidChange.connect( self.__inputFieldsChangedInternal) for dirButton in [self.leftDirButton, self.rightDirButton]: # see https://stackoverflow.com/questions/4578861/connecting-slots-and-signals-in-pyqt4-in-a-loop dirButton.clicked.connect( partial(self.toggleCardinalDirectionButton, dirButton)) for inputField in self._widgetlistInputDms: inputField.installEventFilter(self) for inputField in [self.inLeftDec, self.inRightDec]: inputField.textEdited.connect(self.__inputFieldsChangedInternal) inputField.setValidator(self.decDegreeValidator) inputField.installEventFilter(self) self.leftDecIncrementor = ValueIncrementor(self.inLeftDec, 180, -180) self.rightDecIncrementor = ValueIncrementor(self.inLeftDec, 90, -90) self.outputCrsConn.clicked.connect( partial(self.toggledMapConnection, CoordinatorDockWidget.SectionOutput)) self.copyLeft.clicked.connect( partial(self.copyResultToClipBoard, CoordinatorDockWidget.SideLeft)) self.copyRight.clicked.connect( partial(self.copyResultToClipBoard, CoordinatorDockWidget.SideRight)) self.copyResultComplete.clicked.connect( partial(self.copyResultToClipBoard, CoordinatorDockWidget.SideBoth)) self.clearInput.clicked.connect( partial(self.resetInterface, self.SectionBoth)) self.showHelp.clicked.connect(self.showHelpButtonClicked) def _setToolsEnabled(self, enabled, section=None): if (section == None): section = self.SectionBoth if (section & self.SectionInput): self.moveCanvas.setEnabled(enabled) if (section & self.SectionOutput): for widget in [ self.copyResultComplete, self.copyLeft, self.copyRight ]: widget.setEnabled(enabled) def resetInterface(self, section=None): if section == None: section = self.SectionBoth self.clearSection(section) self._setToolsEnabled(False, section) def eventFilter(self, obj, event): if (type(event) == QKeyEvent and event.type() == QEvent.KeyRelease): key = event.key() if (self.sectionIsGeographic[self.SectionInput] and event.text() == "-"): # maybe we should switch the hemisphere? if (obj in [ self.inLeft, self.inLeftDec, self.inLeftMin, self.inLeftSec ]): self.toggleCardinalDirectionButton(self.leftDirButton) else: self.toggleCardinalDirectionButton(self.rightDirButton) elif (key in [Qt.Key_Return, Qt.Key_Enter]): if (self.addFeatureButton.isEnabled()): #coordinatorLog("Clicking add Feature Button") self.addFeatureButton.click() #coordinatorLog("Yakub Key Event (Filter): %s (%s)" % (event.key(), event.text()) ) return super(CoordinatorDockWidget, self).eventFilter(obj, event) def __styleInputStyleSelectorForGeographicCrs(self, isGeographic): for widget in [self.geoLabel, self.inputAsDMS, self.inputAsDec]: widget.setEnabled(isGeographic) def setSectionIsGeographic(self, section, isGeographic): if (section & self.SectionInput): # switch validators for the decimal field, as we may support different ranges # switch to DMS view accordingly if isGeographic: self.inLeftDec.setValidator(self.decDegreeValidator) self.inRightDec.setValidator(self.decDegreeValidator) # switch to DMS view if it is selected self.setInputToDMS(self.inputAsDMS.isChecked()) else: self.inLeftDec.setValidator(self.projectedValidator) self.inRightDec.setValidator(self.projectedValidator) self.setInputToDMS(False) for widget in [ self.labelDecDegreeLeft, self.labelDecDegreeRight, self.leftDirButton, self.rightDirButton ]: widget.show() if isGeographic else widget.hide() self.__styleInputStyleSelectorForGeographicCrs(isGeographic) if (section & self.SectionOutput): for widget in [self.resultAsDMS, self.resultAsDec]: widget.setEnabled(isGeographic) self.sectionIsGeographic[section] = isGeographic def setSectionCrs(self, section, crs): #QgsMessageLog.logMessage("set SectionCrs, is Geographic: %s" % crs.isGeographic(), "Coordinator") self.setSectionIsGeographic(section, crs.isGeographic()) if (section == self.SectionInput): self.selectCrsButton.setText(crs.authid()) elif (section == self.SectionOutput): self.outputCrs.setText(crs.authid()) def setInputToDMS(self, isDms): if isDms: self.leftDMS.show() self.rightDMS.show() self.leftDecimal.hide() self.rightDecimal.hide() else: self.leftDMS.hide() self.rightDMS.hide() self.leftDecimal.show() self.rightDecimal.show() def setInputPoint(self, point): #QgsMessageLog.logMessage("%s/%s" % (point.x(), point.y())) if self.sectionIsGeographic[self.SectionInput]: precision = 9 # remove leading minus when setting decimal fields. The correct state for # the hemisphere buttons is applied in setDmsInputFromDecimal() later. xDec = abs(point.x()) yDec = abs(point.y()) else: precision = 3 xDec = point.x() yDec = point.y() self.inLeftDec.setText(QLocale().toString( xDec if self._eastingLeftNorthingRight else yDec, "f", precision)) self.inRightDec.setText(QLocale().toString( yDec if self._eastingLeftNorthingRight else xDec, "f", precision)) self.setDmsInputFromDecimal(point.x(), point.y()) self.__inputFieldsChangedInternal() def setDmsInputFromDecimal(self, x, y, setEastingNorthingInversion=True): xDMS = coordinatorDecimalToDms(x) yDMS = coordinatorDecimalToDms(y) #QgsMessageLog.logMessage(str(xDMS)) if (setEastingNorthingInversion): self.setEastingInverted(xDMS[0]) self.setNorthingInverted(yDMS[0]) leftDMS = xDMS if self._eastingLeftNorthingRight else yDMS rightDMS = yDMS if self._eastingLeftNorthingRight else xDMS self.inLeft.setText(QLocale().toString(leftDMS[1])) self.inLeftMin.setText(QLocale().toString(leftDMS[2])) self.inLeftSec.setText(QLocale().toString(leftDMS[3], "f", 2)) self.inRight.setText(QLocale().toString(rightDMS[1])) self.inRightMin.setText(QLocale().toString(rightDMS[2])) self.inRightSec.setText(QLocale().toString(rightDMS[3], "f", 2)) def setResultPoint(self, point): if self.sectionIsGeographic[CoordinatorDockWidget.SectionOutput]: if (self.resultAsDec.isChecked()): f = QgsCoordinateFormatter.FormatDecimalDegrees precision = 9 else: f = QgsCoordinateFormatter.FormatDegreesMinutesSeconds precision = 3 transformedX = QgsCoordinateFormatter.formatX( point.x(), f, precision) transformedY = QgsCoordinateFormatter.formatY( point.y(), f, precision) # if we use FormatDecimalDegrees QgsCoordinateFormatter produces # a string just with a QString::number(). Therefore the string is NOT # localized. But QgsCoordinateFormatter provides coordinate wrapping already # so we just hack the correct decimal sign into the string and should be # fine... juuust fine! decPoint = QLocale().decimalPoint() #coordinatorLog("Decimal Point: %s" % decPoint) if (decPoint != "."): #coordinatorLog("replace!!") transformedX = transformedX.replace(".", decPoint) transformedY = transformedY.replace(".", decPoint) else: #self.log(" -> using decimal output") transformedX = QLocale().toString(point.x(), 'f', 2) transformedY = QLocale().toString(point.y(), 'f', 2) self.setResult(transformedX, transformedY) def setResult(self, x, y): if (self._eastingLeftNorthingRight): self.resultLeft.setText(x) self.resultRight.setText(y) else: self.resultLeft.setText(y) self.resultRight.setText(x) def setWarningMessage(self, message): if (message and len(message) > 0): self.messageIcon.setPixmap(self.warningPixmap) self.messageIcon.show() self.messageText.setText(message) else: self.hideMessages() def showInfoMessage(self, message, hideAfterMilliseconds=None): self.messageIcon.clear() self.messageText.setText(message) if (hideAfterMilliseconds): self.messageHideTimer.setInterval(hideAfterMilliseconds) self.messageHideTimer.start() def hideMessages(self): self.messageIcon.clear() self.messageIcon.hide() self.messageText.clear() def clearFieldsInSection(self, section): if (section & self.SectionInput): for inputField in self._widgetlistInput: inputField.setText("") if (section & self.SectionOutput): for inputField in [ self.resultRight, self.resultLeft, ]: inputField.setText("") def clearSection(self, section): self.clearFieldsInSection(section) # the following code will also reset # the buttons designating the hemisphere: #if (section & self.SectionInput ) : # self.setEastingInverted(False) # self.setNorthingInverted(False) self.addFeatureButton.setEnabled(False) self.inputChanged.emit() def calculateDecimalDegreesFromDMS(self): #QgsMessageLog.logMessage("calc DMS", "Coordinator") valueLeft = coordinatorDmsStringsToDecimal(self.inLeft.text(), self.inLeftMin.text(), self.inLeftSec.text()) valueRight = coordinatorDmsStringsToDecimal(self.inRight.text(), self.inRightMin.text(), self.inRightSec.text()) return (valueLeft, valueRight) def hasInput(self): if self.leftDMS.isVisible(): hasInput = False for field in self._widgetlistInputDms: if len(field.text()) > 0: hasInput = True break else: hasInput = (len(self.inLeftDec.text()) > 0 or len(self.inRightDec.text()) > 0) return hasInput def __rawInputCoordinates(self): if (self.leftDMS.isVisible()): return (self.calculateDecimalDegreesFromDMS()) else: valueLeft = 0.0 valueRight = 0.0 if (len(self.inLeftDec.text())): valueLeft = QLocale().toFloat(self.inLeftDec.text())[0] if (len(self.inRightDec.text())): valueRight = QLocale().toFloat(self.inRightDec.text())[0] return (valueLeft, valueRight) def inputCoordinates(self): (valueLeft, valueRight) = self.__rawInputCoordinates() if (self._eastingLeftNorthingRight): inX = valueLeft inY = valueRight else: inX = valueRight inY = valueLeft if (self.sectionIsGeographic[self.SectionInput] and self._eastingInverted): inX *= -1 if (self.sectionIsGeographic[self.SectionInput] and self._northingInverted): inY *= -1 return (inX, inY) def setNorthingInverted(self, northingInverted): self._northingInverted = northingInverted label = self.tr("S", "lblSouth") if northingInverted else self.tr( "N", "lblNorth") if (self._eastingLeftNorthingRight): self.rightDirButton.setText(label) else: self.leftDirButton.setText(label) def setEastingInverted(self, eastingInverted): self._eastingInverted = eastingInverted label = self.tr("W", "lblWest") if eastingInverted else self.tr( "E", "lblEast") if (self._eastingLeftNorthingRight): self.leftDirButton.setText(label) else: self.rightDirButton.setText(label) def toggleCardinalDirectionButton(self, button): if (self._eastingLeftNorthingRight): toggleEasting = (button == self.leftDirButton) else: toggleEasting = (button == self.rightDirButton) if (toggleEasting): self.setEastingInverted(not self._eastingInverted) else: self.setNorthingInverted(not self._northingInverted) self.inputChanged.emit() def toggledMapConnection(self, section, isConnected): if section == CoordinatorDockWidget.SectionOutput: self.outputCrs.setEnabled(not isConnected) self.mapConnectionChanged.emit(section, isConnected) def __inputFieldsChangedInternal(self): #coordinatorLog("fields changed internal triggered") self._setToolsEnabled(self.hasInput()) if (self.hasInput()): if (self.leftDMS.isVisible()): (leftValue, rightValue) = self.calculateDecimalDegreesFromDMS() self.inLeftDec.setText(QLocale().toString(leftValue, "f", 9)) self.inRightDec.setText(QLocale().toString(rightValue, "f", 9)) else: # calculate DMS Values leftValue = QLocale().toFloat(self.inLeftDec.text())[0] rightValue = QLocale().toFloat(self.inRightDec.text())[0] x = leftValue if self._eastingLeftNorthingRight else rightValue y = rightValue if self._eastingLeftNorthingRight else leftValue self.setDmsInputFromDecimal(x, y, False) else: if (self.leftDMS.isVisible()): self.inLeftDec.setText(None) self.inRightDec.setText(None) else: for f in self._widgetlistInputDms: f.setText(None) self.inputChanged.emit() def copyResultToClipBoard(self, which): cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) if which == CoordinatorDockWidget.SideLeft: content = self.resultLeft.text() elif which == CoordinatorDockWidget.SideRight: content = self.resultRight.text() else: content = "%s\t%s" % (self.resultLeft.text(), self.resultRight.text()) cb.setText(content, mode=cb.Clipboard) def showHelpButtonClicked(self): if not hasattr(self, "helpview"): helpBasepath = "%s/help" % os.path.normpath( os.path.dirname(__file__)) helpUrl = QUrl( pathlib.Path("%s/index.html" % helpBasepath).as_uri()) cssUrl = QUrl(pathlib.Path("%s/help.css" % helpBasepath).as_uri()) self.helpview = QWebView() self.helpview.settings().setUserStyleSheetUrl(cssUrl) self.helpview.load(helpUrl) self.helpview.show() def closeEvent(self, event): self.closingPlugin.emit() event.accept()
class ScudCloud(QtWidgets.QMainWindow): forceClose = False messages = 0 speller = Speller() title = 'ScudCloud' def __init__(self, debug=False, minimized=None, urgent_hint=None, settings_path='', cache_path=''): super(ScudCloud, self).__init__(None) self.debug = debug self.minimized = minimized self.urgent_hint = urgent_hint self.setWindowTitle(self.title) self.settings_path = settings_path self.cache_path = cache_path self.notifier = Notifier(Resources.APP_NAME, Resources.get_path('scudcloud.png')) self.settings = QSettings(self.settings_path + '/scudcloud_qt5.cfg', QSettings.IniFormat) self.notifier.enabled = self.settings.value('Notifications', defaultValue=True, type=bool) self.identifier = self.settings.value("Domain") if Unity is not None: self.launcher = Unity.LauncherEntry.get_for_desktop_id( "scudcloud.desktop") else: self.launcher = DummyLauncher(self) self.webSettings() self.snippetsSettings() self.leftPane = LeftPane(self) self.stackedWidget = QtWidgets.QStackedWidget() centralWidget = QtWidgets.QWidget(self) layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.leftPane) layout.addWidget(self.stackedWidget) centralWidget.setLayout(layout) self.setCentralWidget(centralWidget) self.startURL = Resources.SIGNIN_URL if self.identifier is not None: if isinstance(self.identifier, str): self.domains = self.identifier.split(",") else: self.domains = self.identifier self.startURL = self.normalize(self.domains[0]) else: self.domains = [] self.addWrapper(self.startURL) self.addMenu() self.tray = Systray(self) self.systray(self.minimized) self.installEventFilter(self) self.statusBar().showMessage('Loading Slack...') self.tickler = QTimer(self) self.tickler.setInterval(1800000) # Watch for ScreenLock events if DBusQtMainLoop is not None: DBusQtMainLoop(set_as_default=True) sessionBus = dbus.SessionBus() # Ubuntu 12.04 and other distros sessionBus.add_match_string( "type='signal',interface='org.gnome.ScreenSaver'") # Ubuntu 14.04 sessionBus.add_match_string( "type='signal',interface='com.ubuntu.Upstart0_6'") # Ubuntu 16.04 and KDE sessionBus.add_match_string( "type='signal',interface='org.freedesktop.ScreenSaver'") # Cinnamon sessionBus.add_match_string( "type='signal',interface='org.cinnamon.ScreenSaver'") sessionBus.add_message_filter(self.screenListener) self.tickler.timeout.connect(self.sendTickle) # If dbus is not present, tickler timer will act like a blocker to not send tickle too often else: self.tickler.setSingleShot(True) self.tickler.start() def screenListener(self, bus, message): event = message.get_member() # "ActiveChanged" for Ubuntu 12.04 and other distros. "EventEmitted" for Ubuntu 14.04 and above if event == "ActiveChanged" or event == "EventEmitted": arg = message.get_args_list()[0] # True for Ubuntu 12.04 and other distros. "desktop-lock" for Ubuntu 14.04 and above if (arg == True or arg == "desktop-lock") and self.tickler.isActive(): self.tickler.stop() elif (arg == False or arg == "desktop-unlock") and not self.tickler.isActive(): self.sendTickle() self.tickler.start() def sendTickle(self): for i in range(0, self.stackedWidget.count()): self.stackedWidget.widget(i).sendTickle() def addWrapper(self, url): webView = Wrapper(self) webView.load(QtCore.QUrl(url)) webView.show() webView.setZoomFactor(self.zoom) self.stackedWidget.addWidget(webView) self.stackedWidget.setCurrentWidget(webView) self.clearMemory() def webSettings(self): self.cookiesjar = PersistentCookieJar(self) self.zoom = self.readZoom() # We don't want Flash (it causes a lot of trouble in some distros) QWebSettings.globalSettings().setAttribute(QWebSettings.PluginsEnabled, False) # We don't need Java QWebSettings.globalSettings().setAttribute(QWebSettings.JavaEnabled, False) # Enabling Local Storage (now required by Slack) QWebSettings.globalSettings().setAttribute( QWebSettings.LocalStorageEnabled, True) # We need browsing history (required to not limit LocalStorage) QWebSettings.globalSettings().setAttribute( QWebSettings.PrivateBrowsingEnabled, False) # Enabling Cache self.diskCache = QNetworkDiskCache(self) self.diskCache.setCacheDirectory(self.cache_path) # Required for copy and paste clipboard integration QWebSettings.globalSettings().setAttribute( QWebSettings.JavascriptCanAccessClipboard, True) # Enabling Inspeclet only when --debug=True (requires more CPU usage) QWebSettings.globalSettings().setAttribute( QWebSettings.DeveloperExtrasEnabled, self.debug) # Sharing the same networkAccessManager self.networkAccessManager = QNetworkAccessManager(self) self.networkAccessManager.setCookieJar(self.cookiesjar) self.networkAccessManager.setCache(self.diskCache) def snippetsSettings(self): self.disable_snippets = self.settings.value("Snippets") if self.disable_snippets is not None: self.disable_snippets = self.disable_snippets == "False" else: self.disable_snippets = False if self.disable_snippets: disable_snippets_css = '' with open(Resources.get_path('disable_snippets.css'), 'r') as f: disable_snippets_css = f.read() with open(os.path.join(self.cache_path, 'resources.css'), 'a') as f: f.write(disable_snippets_css) def toggleFullScreen(self): if self.isFullScreen(): self.showMaximized() else: self.showFullScreen() def toggleMenuBar(self): menu = self.menuBar() state = menu.isHidden() menu.setVisible(state) if state: self.settings.setValue("Menu", "True") else: self.settings.setValue("Menu", "False") def restore(self): geometry = self.settings.value("geometry") if geometry is not None: self.restoreGeometry(geometry) windowState = self.settings.value("windowState") if windowState is not None: self.restoreState(windowState) else: self.setWindowState(QtCore.Qt.WindowMaximized) def systray(self, show=None): if show is None: show = self.settings.value("Systray") == "True" if show: self.tray.show() self.menus["file"]["close"].setEnabled(True) self.settings.setValue("Systray", "True") else: self.tray.setVisible(False) self.menus["file"]["close"].setEnabled(False) self.settings.setValue("Systray", "False") def readZoom(self): default = 1 if self.settings.value("Zoom") is not None: default = float(self.settings.value("Zoom")) return default def setZoom(self, factor=1): if factor > 0: for i in range(0, self.stackedWidget.count()): widget = self.stackedWidget.widget(i) widget.setZoomFactor(factor) self.settings.setValue("Zoom", factor) def zoomIn(self): self.setZoom(self.current().zoomFactor() + 0.1) def zoomOut(self): self.setZoom(self.current().zoomFactor() - 0.1) def zoomReset(self): self.setZoom() def addTeam(self): self.switchTo(Resources.SIGNIN_URL) def addMenu(self): # We'll register the webpage shorcuts with the window too (Fixes #338) undo = self.current().pageAction(QWebPage.Undo) redo = self.current().pageAction(QWebPage.Redo) cut = self.current().pageAction(QWebPage.Cut) copy = self.current().pageAction(QWebPage.Copy) paste = self.current().pageAction(QWebPage.Paste) back = self.current().pageAction(QWebPage.Back) forward = self.current().pageAction(QWebPage.Forward) reload = self.current().pageAction(QWebPage.Reload) self.menus = { "file": { "preferences": self.createAction("Preferences", lambda: self.current().preferences()), "systray": self.createAction("Close to Tray", self.systray, None, True), "addTeam": self.createAction("Sign in to Another Team", lambda: self.addTeam()), "signout": self.createAction("Signout", lambda: self.current().logout()), "close": self.createAction("Close", self.close, QKeySequence.Close), "exit": self.createAction("Quit", self.exit, QKeySequence.Quit) }, "edit": { "undo": self.createAction( undo.text(), lambda: self.current().page().triggerAction(QWebPage.Undo), undo.shortcut()), "redo": self.createAction( redo.text(), lambda: self.current().page().triggerAction(QWebPage.Redo), redo.shortcut()), "cut": self.createAction( cut.text(), lambda: self.current().page().triggerAction(QWebPage.Cut), cut.shortcut()), "copy": self.createAction( copy.text(), lambda: self.current().page().triggerAction(QWebPage.Copy), copy.shortcut()), "paste": self.createAction( paste.text(), lambda: self.current().page().triggerAction( QWebPage.Paste), paste.shortcut()), "back": self.createAction( back.text(), lambda: self.current().page().triggerAction(QWebPage.Back), back.shortcut()), "forward": self.createAction( forward.text(), lambda: self.current().page(). triggerAction(QWebPage.Forward), forward.shortcut()), "reload": self.createAction( reload.text(), lambda: self.current().page().triggerAction( QWebPage.Reload), reload.shortcut()), }, "view": { "zoomin": self.createAction("Zoom In", self.zoomIn, QKeySequence.ZoomIn), "zoomout": self.createAction("Zoom Out", self.zoomOut, QKeySequence.ZoomOut), "reset": self.createAction("Reset", self.zoomReset, QtCore.Qt.CTRL + QtCore.Qt.Key_0), "fullscreen": self.createAction("Toggle Full Screen", self.toggleFullScreen, QtCore.Qt.Key_F11), "hidemenu": self.createAction("Toggle Menubar", self.toggleMenuBar, QtCore.Qt.Key_F12) }, "help": { "help": self.createAction("Help and Feedback", lambda: self.current().help(), QKeySequence.HelpContents), "center": self.createAction("Slack Help Center", lambda: self.current().helpCenter()), "about": self.createAction("About", lambda: self.current().about()) } } menu = self.menuBar() fileMenu = menu.addMenu("&File") fileMenu.addAction(self.menus["file"]["preferences"]) fileMenu.addAction(self.menus["file"]["systray"]) fileMenu.addSeparator() fileMenu.addAction(self.menus["file"]["addTeam"]) fileMenu.addAction(self.menus["file"]["signout"]) fileMenu.addSeparator() fileMenu.addAction(self.menus["file"]["close"]) fileMenu.addAction(self.menus["file"]["exit"]) editMenu = menu.addMenu("&Edit") editMenu.addAction(self.menus["edit"]["undo"]) editMenu.addAction(self.menus["edit"]["redo"]) editMenu.addSeparator() editMenu.addAction(self.menus["edit"]["cut"]) editMenu.addAction(self.menus["edit"]["copy"]) editMenu.addAction(self.menus["edit"]["paste"]) editMenu.addSeparator() editMenu.addAction(self.menus["edit"]["back"]) editMenu.addAction(self.menus["edit"]["forward"]) editMenu.addAction(self.menus["edit"]["reload"]) viewMenu = menu.addMenu("&View") viewMenu.addAction(self.menus["view"]["zoomin"]) viewMenu.addAction(self.menus["view"]["zoomout"]) viewMenu.addAction(self.menus["view"]["reset"]) viewMenu.addSeparator() viewMenu.addAction(self.menus["view"]["fullscreen"]) viewMenu.addAction(self.menus["view"]["hidemenu"]) helpMenu = menu.addMenu("&Help") helpMenu.addAction(self.menus["help"]["help"]) helpMenu.addAction(self.menus["help"]["center"]) helpMenu.addSeparator() helpMenu.addAction(self.menus["help"]["about"]) self.enableMenus(False) showSystray = self.settings.value("Systray") == "True" self.menus["file"]["systray"].setChecked(showSystray) self.menus["file"]["close"].setEnabled(showSystray) # Restore menu visibility visible = self.settings.value("Menu") if visible is not None and visible == "False": menu.setVisible(False) def enableMenus(self, enabled): self.menus["file"]["preferences"].setEnabled(enabled == True) self.menus["file"]["addTeam"].setEnabled(enabled == True) self.menus["file"]["signout"].setEnabled(enabled == True) self.menus["help"]["help"].setEnabled(enabled == True) def createAction(self, text, slot, shortcut=None, checkable=False): action = QtWidgets.QAction(text, self) action.triggered.connect(slot) if shortcut is not None: action.setShortcut(shortcut) self.addAction(action) if checkable: action.setCheckable(True) return action def normalize(self, url): if url.endswith(".slack.com"): url += "/" elif not url.endswith(".slack.com/"): url = "https://" + url + ".slack.com/" return url def current(self): return self.stackedWidget.currentWidget() def teams(self, teams): if len(self.domains) == 0: self.domains.append(teams[0]['team_url']) team_list = [t['team_url'] for t in teams] for t in teams: for i in range(0, len(self.domains)): self.domains[i] = self.normalize(self.domains[i]) # When team_icon is missing, the team already exists (Fixes #381, #391) if 'team_icon' in t: if self.domains[i] in team_list: add = next(item for item in teams if item['team_url'] == self.domains[i]) if 'team_icon' in add: self.leftPane.addTeam(add['id'], add['team_name'], add['team_url'], add['team_icon']['image_44'], add == teams[0]) # Adding new teams and saving loading positions if t['team_url'] not in self.domains: self.leftPane.addTeam( t['id'], t['team_name'], t['team_url'], t['team_icon']['image_44'], t == teams[0]) self.domains.append(t['team_url']) self.settings.setValue("Domain", self.domains) if len(teams) > 1: self.leftPane.show() def switchTo(self, url): exists = False for i in range(0, self.stackedWidget.count()): if self.stackedWidget.widget(i).url().toString().startswith(url): self.stackedWidget.setCurrentIndex(i) self.quicklist(self.current().listChannels()) self.current().setFocus() self.leftPane.click(i) self.clearMemory() exists = True break if not exists: self.addWrapper(url) def eventFilter(self, obj, event): if event.type( ) == QtCore.QEvent.ActivationChange and self.isActiveWindow(): self.focusInEvent(event) if event.type() == QtCore.QEvent.KeyPress: # Ctrl + <n> modifiers = QtWidgets.QApplication.keyboardModifiers() if modifiers == QtCore.Qt.ControlModifier: if event.key() == QtCore.Qt.Key_1: self.leftPane.click(0) elif event.key() == QtCore.Qt.Key_2: self.leftPane.click(1) elif event.key() == QtCore.Qt.Key_3: self.leftPane.click(2) elif event.key() == QtCore.Qt.Key_4: self.leftPane.click(3) elif event.key() == QtCore.Qt.Key_5: self.leftPane.click(4) elif event.key() == QtCore.Qt.Key_6: self.leftPane.click(5) elif event.key() == QtCore.Qt.Key_7: self.leftPane.click(6) elif event.key() == QtCore.Qt.Key_8: self.leftPane.click(7) elif event.key() == QtCore.Qt.Key_9: self.leftPane.click(8) # Ctrl + Tab elif event.key() == QtCore.Qt.Key_Tab: self.leftPane.clickNext(1) # Ctrl + BackTab if (modifiers & QtCore.Qt.ControlModifier) and ( modifiers & QtCore.Qt.ShiftModifier): if event.key() == QtCore.Qt.Key_Backtab: self.leftPane.clickNext(-1) # Ctrl + Shift + <key> if (modifiers & QtCore.Qt.ShiftModifier) and ( modifiers & QtCore.Qt.ShiftModifier): if event.key() == QtCore.Qt.Key_V: self.current().createSnippet() return QtWidgets.QMainWindow.eventFilter(self, obj, event) def focusInEvent(self, event): self.launcher.set_property("urgent", False) self.tray.stopAlert() # Let's tickle all teams on window focus, but only if tickle was not fired in last 30 minutes if DBusQtMainLoop is None and not self.tickler.isActive(): self.sendTickle() self.tickler.start() def titleChanged(self): self.setWindowTitle(self.current().title()) def setForceClose(self): self.forceClose = True def closeEvent(self, event): if not self.forceClose and self.settings.value("Systray") == "True": self.hide() event.ignore() else: self.cookiesjar.save() self.settings.setValue("Domain", self.domains) self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("windowState", self.saveState()) self.settings.setValue("Domain", self.domains) self.forceClose = False def show(self): self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.activateWindow() self.setVisible(True) def exit(self): # Make sure tray is not visible (Fixes #513) self.tray.setVisible(False) self.setForceClose() self.close() def quicklist(self, channels): if Dbusmenu is not None: if channels is not None: ql = Dbusmenu.Menuitem.new() self.launcher.set_property("quicklist", ql) for c in channels: if type(c) is dict and hasattr( c, '__getitem__') and c['is_member']: item = Dbusmenu.Menuitem.new() item.property_set(Dbusmenu.MENUITEM_PROP_LABEL, "#" + c['name']) item.property_set("id", c['name']) item.property_set_bool(Dbusmenu.MENUITEM_PROP_VISIBLE, True) item.connect(Dbusmenu.MENUITEM_SIGNAL_ITEM_ACTIVATED, self.current().openChannel) ql.child_append(item) self.launcher.set_property("quicklist", ql) def notify(self, title, message, icon): if self.debug: print("Notification: title [{}] message [{}] icon [{}]".format( title, message, icon)) self.notifier.notify(title, message, icon) self.alert() def alert(self): if not self.isActiveWindow(): self.launcher.set_property("urgent", True) self.tray.alert() if self.urgent_hint is True: QApplication.alert(self) def count(self): total = 0 unreads = 0 for i in range(0, self.stackedWidget.count()): widget = self.stackedWidget.widget(i) highlights = widget.highlights unreads += widget.unreads total += highlights if total > self.messages: self.alert() if 0 == total: self.launcher.set_property("count_visible", False) self.tray.setCounter(0) if unreads > 0: self.setWindowTitle("*{}".format(self.title)) else: self.setWindowTitle(self.title) else: self.tray.setCounter(total) self.launcher.set_property("count", total) self.launcher.set_property("count_visible", True) self.setWindowTitle("[{}]{}".format(str(total), self.title)) self.messages = total def clearMemory(self): QWebSettings.globalSettings().clearMemoryCaches() QWebSettings.globalSettings().clearIconDatabase()
class LiveCSS(QWidget): goto_declaration = pyqtSignal(object) def __init__(self, preview, parent=None): QWidget.__init__(self, parent) self.preview = preview 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.AlignTop | Qt.AlignLeft) s.addWidget(la) self.box = box = Box(self) box.hyperlink_activated.connect(self.goto_declaration, type=Qt.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 # We must let the event loop run otherwise the webview will return # stale data in read_data() 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.setPointSize(tprefs["editor_font_size"]) self.setFont(f) theme = get_theme(tprefs["editor_theme"]) pal = self.palette() pal.setColor(pal.Window, theme_color(theme, "Normal", "bg")) pal.setColor(pal.WindowText, theme_color(theme, "Normal", "fg")) pal.setColor(pal.AlternateBase, theme_color(theme, "HighlightRegion", "bg")) pal.setColor(pal.Link, theme_color(theme, "Link", "fg")) pal.setColor(pal.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: data = self.read_data(sourceline, 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 if self.now_showing == (None, None, None) or self.now_showing[0] != self.current_name: self.clear() return # Try to refresh the data for the currently shown tag instead # of clearing editor_name, sourceline, tags = self.now_showing data = self.read_data(sourceline, tags) if data is None or len(data["computed_css"]) < 1: 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 read_data(self, sourceline, tags): mf = self.preview.view.page().mainFrame() tags = [x.lower() for x in tags] result = unicode( mf.evaluateJavaScript( "window.calibre_preview_integration.live_css(%s, %s)" % (json.dumps(sourceline), json.dumps(tags)) ) or "" ) try: result = json.loads(result) except ValueError: result = None if result is not None: maximum_specificities = {} for node in result["nodes"]: is_ancestor = node["is_ancestor"] for rule in node["css"]: self.process_rule(rule, is_ancestor, 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 return result def process_rule(self, rule, is_ancestor, 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): specificity = [0, 0, 0, 0] else: # style attribute specificity = [1, 0, 0, 0] specificity.extend((sheet_index, tuple(rule_address))) ancestor_specificity = 0 if is_ancestor else 1 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, (0, 0, 0, 0, 0, 0)): maximum_specificities[p.name] = p.specificity rule["properties"] = properties href = rule["href"] if hasattr(href, "startswith") and href.startswith("file://"): href = href[len("file://") :] if iswindows and href.startswith("/"): href = href[1:] if href: rule["href"] = current_container().abspath_to_name(href, root=self.preview.current_root) @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 TagBrowserWidget(QWidget): # {{{ def __init__(self, parent): QWidget.__init__(self, parent) self.parent = parent self._layout = QVBoxLayout() self.setLayout(self._layout) self._layout.setContentsMargins(0, 0, 0, 0) # Set up the find box & button search_layout = QHBoxLayout() self._layout.addLayout(search_layout) self.item_search = HistoryLineEdit(parent) self.item_search.setMinimumContentsLength(5) self.item_search.setSizeAdjustPolicy( self.item_search.AdjustToMinimumContentsLengthWithIcon) try: self.item_search.lineEdit().setPlaceholderText( _('Find item in tag browser')) except: pass # Using Qt < 4.7 self.item_search.setToolTip( _('Search for items. This is a "contains" search; items containing the\n' 'text anywhere in the name will be found. You can limit the search\n' 'to particular categories using syntax similar to search. For example,\n' 'tags:foo will find foo in any tag, but not in authors etc. Entering\n' '*foo will filter all categories at once, showing only those items\n' 'containing the text "foo"')) search_layout.addWidget(self.item_search) # Not sure if the shortcut should be translatable ... sc = QShortcut(QKeySequence(_('ALT+f')), parent) sc.activated.connect(self.set_focus_to_find_box) self.search_button = QToolButton() self.search_button.setText(_('F&ind')) self.search_button.setToolTip(_('Find the first/next matching item')) search_layout.addWidget(self.search_button) self.expand_button = QToolButton() self.expand_button.setText('-') self.expand_button.setToolTip(_('Collapse all categories')) search_layout.addWidget(self.expand_button) search_layout.setStretch(0, 10) search_layout.setStretch(1, 1) search_layout.setStretch(2, 1) self.current_find_position = None self.search_button.clicked.connect(self.find) self.item_search.initialize('tag_browser_search') self.item_search.lineEdit().returnPressed.connect(self.do_find) self.item_search.lineEdit().textEdited.connect(self.find_text_changed) self.item_search.activated[str].connect(self.do_find) self.item_search.completer().setCaseSensitivity(Qt.CaseSensitive) parent.tags_view = TagsView(parent) self.tags_view = parent.tags_view self.expand_button.clicked.connect(self.tags_view.collapseAll) self._layout.addWidget(parent.tags_view) # Now the floating 'not found' box l = QLabel(self.tags_view) self.not_found_label = l l.setFrameStyle(QFrame.StyledPanel) l.setAutoFillBackground(True) l.setText( '<p><b>' + _('No More Matches.</b><p> Click Find again to go to first match')) l.setAlignment(Qt.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.QueuedConnection) parent.alter_tb = l = QPushButton(parent) l.setText(_('Alter Tag Browser')) l.setIcon(QIcon(I('tags.png'))) l.m = QMenu() l.setMenu(l.m) self._layout.addWidget(l) 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((_('Sort by name'), _('Sort by number of books'), _('Sort by 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) # self.leak_test_timer = QTimer(self) # self.leak_test_timer.timeout.connect(self.test_for_leak) # self.leak_test_timer.start(5000) def set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) def find_text_changed(self, str): self.current_find_position = None def set_focus_to_find_box(self): self.item_search.setFocus() self.item_search.lineEdit().selectAll() def do_find(self, str=None): self.current_find_position = None self.find() def find(self): model = self.tags_view.model() model.clear_boxed() txt = unicode(self.item_search.currentText()).strip() if txt.startswith('*'): 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) key = None colon = txt.rfind(':') if len(txt) > 2 else 0 if colon > 0: key = self.parent.library_view.model().db.\ field_metadata.search_term_to_field_key(txt[:colon]) txt = txt[colon + 1:] self.current_find_position = \ model.find_item_node(key, txt, self.current_find_position) 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)
class LiveCSS(QWidget): goto_declaration = pyqtSignal(object) def __init__(self, preview, parent=None): QWidget.__init__(self, parent) self.preview = preview 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.AlignTop | Qt.AlignLeft) s.addWidget(la) self.box = box = Box(self) box.hyperlink_activated.connect(self.goto_declaration, type=Qt.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 # We must let the event loop run otherwise the webview will return # stale data in read_data() 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.setPointSize(tprefs['editor_font_size']) self.setFont(f) theme = get_theme(tprefs['editor_theme']) pal = self.palette() pal.setColor(pal.Window, theme_color(theme, 'Normal', 'bg')) pal.setColor(pal.WindowText, theme_color(theme, 'Normal', 'fg')) pal.setColor(pal.AlternateBase, theme_color(theme, 'HighlightRegion', 'bg')) pal.setColor(pal.Link, theme_color(theme, 'Link', 'fg')) pal.setColor(pal.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: data = self.read_data(sourceline, 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 if self.now_showing == (None, None, None) or self.now_showing[0] != self.current_name: self.clear() return # Try to refresh the data for the currently shown tag instead # of clearing editor_name, sourceline, tags = self.now_showing data = self.read_data(sourceline, tags) if data is None or len(data['computed_css']) < 1: 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 read_data(self, sourceline, tags): mf = self.preview.view.page().mainFrame() tags = [x.lower() for x in tags] result = unicode_type(mf.evaluateJavaScript( 'window.calibre_preview_integration.live_css(%s, %s)' % ( json.dumps(sourceline), json.dumps(tags))) or '') try: result = json.loads(result) except ValueError: result = None if result is not None: maximum_specificities = {} for node in result['nodes']: is_ancestor = node['is_ancestor'] for rule in node['css']: self.process_rule(rule, is_ancestor, 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 return result def process_rule(self, rule, is_ancestor, 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))) ancestor_specificity = 0 if is_ancestor else 1 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, (0,0,0,0,0,0)): maximum_specificities[p.name] = p.specificity rule['properties'] = properties href = rule['href'] if hasattr(href, 'startswith') and href.startswith('%s://%s' % (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 GridView(QListView): update_item = pyqtSignal(object) files_dropped = pyqtSignal(object) def __init__(self, parent): QListView.__init__(self, parent) setup_gestures(self) setup_dnd_interface(self) self.setUniformItemSizes(True) self.setWrapping(True) self.setFlow(self.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(self.Batched) self.setResizeMode(self.Adjust) self.setSelectionMode(self.ExtendedSelection) self.setVerticalScrollMode(self.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.padding_left = 0 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.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.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): ret = gesture_viewport_event(self, ev) 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 xrange(geom.top(), (self.spacing()*2) + geom.top(), 5): for x in xrange(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 xrange(geom.bottom(), geom.bottom() - 2 * self.spacing(), -5): for x in xrange(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 xrange(self.first_visible_row or 0, self.last_visible_row or (m.count() - 1)): self.update(m.index(r, 0)) def double_clicked(self, index): d = self.delegate if d.animating is None and not config['disable_animations']: d.animating = index d.animation.start() 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'] pal = QPalette() col = QColor(r, g, b) pal.setColor(pal.Base, col) tex = gprefs['cover_grid_texture'] if tex: from calibre.gui2.preferences.texture_chooser import texture_path path = texture_path(tex) if path: 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) pal.setBrush(pal.Base, QBrush(pm)) dark = (r + g + b)/3.0 < 128 pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black)) self.setPalette(pal) self.delegate.highlight_color = pal.color(pal.Text) 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() if size_changed: 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())) 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 resizeEvent(self, ev): 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 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') dpr = self.device_pixel_ratio 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, int(dpr * self.delegate.cover_size.width()), int(dpr * self.delegate.cover_size.height())) if scaled: if self.ignore_render_requests.is_set(): return p = p.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.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): group = list(map(operator.itemgetter(1), g)) sel.merge(QItemSelection(m.index(min(group), 0), m.index(max(group), 0)), sm.Select) sm.select(sel, sm.ClearAndSelect)
class MaterialManager(QObject): materialsUpdated = pyqtSignal( ) # Emitted whenever the material lookup tables are updated. def __init__(self, container_registry, parent=None): super().__init__(parent) self._application = Application.getInstance() self._container_registry = container_registry # type: ContainerRegistry self._fallback_materials_map = dict( ) # material_type -> generic material metadata self._material_group_map = dict() # root_material_id -> MaterialGroup self._diameter_machine_variant_material_map = dict( ) # approximate diameter str -> dict(machine_definition_id -> MaterialNode) # We're using these two maps to convert between the specific diameter material id and the generic material id # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant # i.e. generic_pla -> generic_pla_175 self._material_diameter_map = defaultdict( dict ) # root_material_id -> approximate diameter str -> root_material_id for that diameter self._diameter_material_map = dict( ) # material id including diameter (generic_pla_175) -> material root id (generic_pla) # This is used in Legacy UM3 send material function and the material management page. self._guid_material_groups_map = defaultdict( list) # GUID -> a list of material_groups # The machine definition ID for the non-machine-specific materials. # This is used as the last fallback option if the given machine-specific material(s) cannot be found. self._default_machine_definition_id = "fdmprinter" self._default_approximate_diameter_for_quality_search = "3" # When a material gets added/imported, there can be more than one InstanceContainers. In those cases, we don't # want to react on every container/metadata changed signal. The timer here is to buffer it a bit so we don't # react too many time. self._update_timer = QTimer(self) self._update_timer.setInterval(300) self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._updateMaps) self._container_registry.containerMetaDataChanged.connect( self._onContainerMetadataChanged) self._container_registry.containerAdded.connect( self._onContainerMetadataChanged) self._container_registry.containerRemoved.connect( self._onContainerMetadataChanged) def initialize(self): # Find all materials and put them in a matrix for quick search. material_metadatas = { metadata["id"]: metadata for metadata in self._container_registry.findContainersMetadata( type="material") if metadata.get("GUID") } self._material_group_map = dict() # Map #1 # root_material_id -> MaterialGroup for material_id, material_metadata in material_metadatas.items(): # We don't store empty material in the lookup tables if material_id == "empty_material": continue root_material_id = material_metadata.get("base_file") if root_material_id not in self._material_group_map: self._material_group_map[root_material_id] = MaterialGroup( root_material_id, MaterialNode(material_metadatas[root_material_id])) self._material_group_map[ root_material_id].is_read_only = self._container_registry.isReadOnly( root_material_id) group = self._material_group_map[root_material_id] # Store this material in the group of the appropriate root material. if material_id != root_material_id: new_node = MaterialNode(material_metadata) group.derived_material_node_list.append(new_node) # Order this map alphabetically so it's easier to navigate in a debugger self._material_group_map = OrderedDict( sorted(self._material_group_map.items(), key=lambda x: x[0])) # Map #1.5 # GUID -> material group list self._guid_material_groups_map = defaultdict(list) for root_material_id, material_group in self._material_group_map.items( ): guid = material_group.root_material_node.metadata["GUID"] self._guid_material_groups_map[guid].append(material_group) # Map #2 # Lookup table for material type -> fallback material metadata, only for read-only materials grouped_by_type_dict = dict() material_types_without_fallback = set() for root_material_id, material_node in self._material_group_map.items( ): material_type = material_node.root_material_node.metadata[ "material"] if material_type not in grouped_by_type_dict: grouped_by_type_dict[material_type] = { "generic": None, "others": [] } material_types_without_fallback.add(material_type) brand = material_node.root_material_node.metadata["brand"] if brand.lower() == "generic": to_add = True if material_type in grouped_by_type_dict: diameter = material_node.root_material_node.metadata.get( "approximate_diameter") if diameter != self._default_approximate_diameter_for_quality_search: to_add = False # don't add if it's not the default diameter if to_add: # Checking this first allow us to differentiate between not read only materials: # - if it's in the list, it means that is a new material without fallback # - if it is not, then it is a custom material with a fallback material (parent) if material_type in material_types_without_fallback: grouped_by_type_dict[ material_type] = material_node.root_material_node.metadata material_types_without_fallback.remove(material_type) # Remove the materials that have no fallback materials for material_type in material_types_without_fallback: del grouped_by_type_dict[material_type] self._fallback_materials_map = grouped_by_type_dict # Map #3 # There can be multiple material profiles for the same material with different diameters, such as "generic_pla" # and "generic_pla_175". This is inconvenient when we do material-specific quality lookup because a quality can # be for either "generic_pla" or "generic_pla_175", but not both. This map helps to get the correct material ID # for quality search. self._material_diameter_map = defaultdict(dict) self._diameter_material_map = dict() # Group the material IDs by the same name, material, brand, and color but with different diameters. material_group_dict = dict() keys_to_fetch = ("name", "material", "brand", "color") for root_material_id, machine_node in self._material_group_map.items(): root_material_metadata = machine_node.root_material_node.metadata key_data = [] for key in keys_to_fetch: key_data.append(root_material_metadata.get(key)) key_data = tuple(key_data) # If the key_data doesn't exist, it doesn't matter if the material is read only... if key_data not in material_group_dict: material_group_dict[key_data] = dict() else: # ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it if not machine_node.is_read_only: continue approximate_diameter = root_material_metadata.get( "approximate_diameter") material_group_dict[key_data][ approximate_diameter] = root_material_metadata["id"] # Map [root_material_id][diameter] -> root_material_id for this diameter for data_dict in material_group_dict.values(): for root_material_id1 in data_dict.values(): if root_material_id1 in self._material_diameter_map: continue diameter_map = data_dict for root_material_id2 in data_dict.values(): self._material_diameter_map[ root_material_id2] = diameter_map default_root_material_id = data_dict.get( self._default_approximate_diameter_for_quality_search) if default_root_material_id is None: default_root_material_id = list(data_dict.values())[ 0] # no default diameter present, just take "the" only one for root_material_id in data_dict.values(): self._diameter_material_map[ root_material_id] = default_root_material_id # Map #4 # "machine" -> "variant_name" -> "root material ID" -> specific material InstanceContainer # Construct the "machine" -> "variant" -> "root material ID" -> specific material InstanceContainer self._diameter_machine_variant_material_map = dict() for material_metadata in material_metadatas.values(): # We don't store empty material in the lookup tables if material_metadata["id"] == "empty_material": continue root_material_id = material_metadata["base_file"] definition = material_metadata["definition"] approximate_diameter = material_metadata["approximate_diameter"] if approximate_diameter not in self._diameter_machine_variant_material_map: self._diameter_machine_variant_material_map[ approximate_diameter] = {} machine_variant_material_map = self._diameter_machine_variant_material_map[ approximate_diameter] if definition not in machine_variant_material_map: machine_variant_material_map[definition] = MaterialNode() machine_node = machine_variant_material_map[definition] variant_name = material_metadata.get("variant_name") if not variant_name: # if there is no variant, this material is for the machine, so put its metadata in the machine node. machine_node.material_map[root_material_id] = MaterialNode( material_metadata) else: # this material is variant-specific, so we save it in a variant-specific node under the # machine-specific node # Check first if the variant exist in the manager existing_variant = self._application.getVariantManager( ).getVariantNode(definition, variant_name) if existing_variant is not None: if variant_name not in machine_node.children_map: machine_node.children_map[variant_name] = MaterialNode( ) variant_node = machine_node.children_map[variant_name] if root_material_id in variant_node.material_map: # We shouldn't have duplicated variant-specific materials for the same machine. ConfigurationErrorMessage.getInstance( ).addFaultyContainers(root_material_id) continue variant_node.material_map[root_material_id] = MaterialNode( material_metadata) else: # Add this container id to the wrong containers list in the registry Logger.log( "w", "Not adding {id} to the material manager because the variant does not exist." .format(id=material_metadata["id"])) self._container_registry.addWrongContainerId( material_metadata["id"]) self.materialsUpdated.emit() def _updateMaps(self): Logger.log("i", "Updating material lookup data ...") self.initialize() def _onContainerMetadataChanged(self, container): self._onContainerChanged(container) def _onContainerChanged(self, container): container_type = container.getMetaDataEntry("type") if container_type != "material": return # update the maps self._update_timer.start() def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]: return self._material_group_map.get(root_material_id) def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str: return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id) def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str: return self._diameter_material_map.get(root_material_id) def getMaterialGroupListByGUID(self, guid: str) -> Optional[list]: return self._guid_material_groups_map.get(guid) # # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup. # def getAvailableMaterials(self, machine_definition: "DefinitionContainer", extruder_variant_name: Optional[str], diameter: float) -> Dict[str, MaterialNode]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_variant_material_map: Logger.log( "i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter) return dict() machine_definition_id = machine_definition.getId() # If there are variant materials, get the variant material machine_variant_material_map = self._diameter_machine_variant_material_map[ rounded_diameter] machine_node = machine_variant_material_map.get(machine_definition_id) default_machine_node = machine_variant_material_map.get( self._default_machine_definition_id) variant_node = None if extruder_variant_name is not None and machine_node is not None: variant_node = machine_node.getChildNode(extruder_variant_name) nodes_to_check = [variant_node, machine_node, default_machine_node] # Fallback mechanism of finding materials: # 1. variant-specific material # 2. machine-specific material # 3. generic material (for fdmprinter) machine_exclude_materials = machine_definition.getMetaDataEntry( "exclude_materials", []) material_id_metadata_dict = dict() # type: Dict[str, MaterialNode] for node in nodes_to_check: if node is not None: # Only exclude the materials that are explicitly specified in the "exclude_materials" field. # Do not exclude other materials that are of the same type. for material_id, node in node.material_map.items(): if material_id in machine_exclude_materials: Logger.log("d", "Exclude material [%s] for machine [%s]", material_id, machine_definition.getId()) continue if material_id not in material_id_metadata_dict: material_id_metadata_dict[material_id] = node return material_id_metadata_dict # # A convenience function to get available materials for the given machine with the extruder position. # def getAvailableMaterialsForMachineExtruder( self, machine: "GlobalStack", extruder_stack: "ExtruderStack") -> Optional[dict]: variant_name = None if extruder_stack.variant.getId() != "empty_variant": variant_name = extruder_stack.variant.getName() diameter = extruder_stack.approximateMaterialDiameter # Fetch the available materials (ContainerNode) for the current active machine and extruder setup. return self.getAvailableMaterials(machine.definition, variant_name, diameter) # # Gets MaterialNode for the given extruder and machine with the given material name. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNode( self, machine_definition_id: str, extruder_variant_name: Optional[str], diameter: float, root_material_id: str) -> Optional["InstanceContainer"]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_variant_material_map: Logger.log( "i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]", diameter, rounded_diameter, root_material_id) return None # If there are variant materials, get the variant material machine_variant_material_map = self._diameter_machine_variant_material_map[ rounded_diameter] machine_node = machine_variant_material_map.get(machine_definition_id) variant_node = None # Fallback for "fdmprinter" if the machine-specific materials cannot be found if machine_node is None: machine_node = machine_variant_material_map.get( self._default_machine_definition_id) if machine_node is not None and extruder_variant_name is not None: variant_node = machine_node.getChildNode(extruder_variant_name) # Fallback mechanism of finding materials: # 1. variant-specific material # 2. machine-specific material # 3. generic material (for fdmprinter) nodes_to_check = [ variant_node, machine_node, machine_variant_material_map.get( self._default_machine_definition_id) ] material_node = None for node in nodes_to_check: if node is not None: material_node = node.material_map.get(root_material_id) if material_node: break return material_node # # Gets MaterialNode for the given extruder and machine with the given material type. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, extruder_variant_name: str, material_guid: str) -> Optional["MaterialNode"]: node = None machine_definition = global_stack.definition extruder_definition = global_stack.extruders[position].definition if parseBool( machine_definition.getMetaDataEntry("has_materials", False)): material_diameter = extruder_definition.getProperty( "material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) # Look at the guid to material dictionary root_material_id = None for material_group in self._guid_material_groups_map[ material_guid]: if material_group.is_read_only: root_material_id = material_group.root_material_node.metadata[ "id"] break if not root_material_id: Logger.log("i", "Cannot find materials with guid [%s] ", material_guid) return None node = self.getMaterialNode(machine_definition.getId(), extruder_variant_name, material_diameter, root_material_id) return node # # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla". # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use # the generic material IDs to search for qualities. # # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will # be "generic_pla". This function is intended to get a generic fallback material for the given material type. # # This function returns the generic root material ID for the given material type, where material types are "PLA", # "ABS", etc. # def getFallbackMaterialIdByMaterialType( self, material_type: str) -> Optional[str]: # For safety if material_type not in self._fallback_materials_map: Logger.log( "w", "The material type [%s] does not have a fallback material" % material_type) return None fallback_material = self._fallback_materials_map[material_type] if fallback_material: return self.getRootMaterialIDWithoutDiameter( fallback_material["id"]) else: return None ## Get default material for given global stack, extruder position and extruder variant name # you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder) def getDefaultMaterial( self, global_stack: "GlobalStack", position: str, extruder_variant_name: Optional[str], extruder_definition: Optional["DefinitionContainer"] = None ) -> Optional["MaterialNode"]: node = None machine_definition = global_stack.definition if extruder_definition is None: extruder_definition = global_stack.extruders[position].definition if extruder_definition and parseBool( global_stack.getMetaDataEntry("has_materials", False)): # At this point the extruder_definition is not None material_diameter = extruder_definition.getProperty( "material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) approximate_material_diameter = str(round(material_diameter)) root_material_id = machine_definition.getMetaDataEntry( "preferred_material") root_material_id = self.getRootMaterialIDForDiameter( root_material_id, approximate_material_diameter) node = self.getMaterialNode(machine_definition.getId(), extruder_variant_name, material_diameter, root_material_id) return node def removeMaterialByRootId(self, root_material_id: str): material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log( "i", "Unable to remove the material with id %s, because it doesn't exist.", root_material_id) return nodes_to_remove = [material_group.root_material_node ] + material_group.derived_material_node_list for node in nodes_to_remove: self._container_registry.removeContainer( node.getMetaDataEntry("id", "")) # # Methods for GUI # # # Sets the new name for the given material. # @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str): root_material_id = material_node.getMetaDataEntry("base_file") if root_material_id is None: return if self._container_registry.isReadOnly(root_material_id): Logger.log("w", "Cannot set name of read-only container %s.", root_material_id) return material_group = self.getMaterialGroup(root_material_id) if material_group: container = material_group.root_material_node.getContainer() if container: container.setName(name) # # Removes the given material. # @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode"): root_material_id = material_node.getMetaDataEntry("base_file") if root_material_id is not None: self.removeMaterialByRootId(root_material_id) # # Creates a duplicate of a material, which has the same GUID and base_file metadata. # Returns the root material ID of the duplicated material if successful. # @pyqtSlot("QVariant", result=str) def duplicateMaterial(self, material_node, new_base_id=None, new_metadata=None) -> Optional[str]: root_material_id = material_node.metadata["base_file"] material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log( "i", "Unable to duplicate the material with id %s, because it doesn't exist.", root_material_id) return None base_container = material_group.root_material_node.getContainer() if not base_container: return None # Ensure all settings are saved. self._application.saveSettings() # Create a new ID & container to hold the data. new_containers = [] if new_base_id is None: new_base_id = self._container_registry.uniqueName( base_container.getId()) new_base_container = copy.deepcopy(base_container) new_base_container.getMetaData()["id"] = new_base_id new_base_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_base_container.getMetaData()[key] = value new_containers.append(new_base_container) # Clone all of them. for node in material_group.derived_material_node_list: container_to_copy = node.getContainer() if not container_to_copy: continue # Create unique IDs for every clone. new_id = new_base_id if container_to_copy.getMetaDataEntry( "definition") != "fdmprinter": new_id += "_" + container_to_copy.getMetaDataEntry( "definition") if container_to_copy.getMetaDataEntry("variant_name"): variant_name = container_to_copy.getMetaDataEntry( "variant_name") new_id += "_" + variant_name.replace(" ", "_") new_container = copy.deepcopy(container_to_copy) new_container.getMetaData()["id"] = new_id new_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_container.getMetaData()[key] = value new_containers.append(new_container) for container_to_add in new_containers: container_to_add.setDirty(True) self._container_registry.addContainer(container_to_add) return new_base_id # # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID. # @pyqtSlot(result=str) def createMaterial(self) -> str: from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") # Ensure all settings are saved. self._application.saveSettings() machine_manager = self._application.getMachineManager() extruder_stack = machine_manager.activeStack approximate_diameter = str(extruder_stack.approximateMaterialDiameter) root_material_id = "generic_pla" root_material_id = self.getRootMaterialIDForDiameter( root_material_id, approximate_diameter) material_group = self.getMaterialGroup(root_material_id) if not material_group: # This should never happen Logger.log("w", "Cannot get the material group of %s.", root_material_id) return "" # Create a new ID & container to hold the data. new_id = self._container_registry.uniqueName("custom_material") new_metadata = { "name": catalog.i18nc("@label", "Custom Material"), "brand": catalog.i18nc("@label", "Custom"), "GUID": str(uuid.uuid4()), } self.duplicateMaterial(material_group.root_material_node, new_base_id=new_id, new_metadata=new_metadata) return new_id
class SliderControl(QObject): """This class implements a slider control for a colormap""" valueChanged = pyqtSignal(float) valueMoved = pyqtSignal(float) def __init__(self, name, value, minval, maxval, step, format="%s: %.1f"): QObject.__init__(self) self.name, self.value, self.minval, self.maxval, self.step, self.format = \ name, value, minval, maxval, step, format self._default = value self._wlabel = None self._wreset = None self._wslider = None self._wslider_timer = None def makeControlWidgets(self, parent, gridlayout, row, column): toprow = QWidget(parent) gridlayout.addWidget(toprow, row * 2, column) top_lo = QHBoxLayout(toprow) top_lo.setContentsMargins(0, 0, 0, 0) self._wlabel = QLabel(self.format % (self.name, self.value), toprow) top_lo.addWidget(self._wlabel) self._wreset = QToolButton(toprow) self._wreset.setText("reset") self._wreset.setToolButtonStyle(Qt.ToolButtonTextOnly) self._wreset.setAutoRaise(True) self._wreset.setEnabled(self.value != self._default) self._wreset.clicked.connect(self._resetValue) top_lo.addWidget(self._wreset) self._wslider = QwtSlider(parent) self._wslider.setOrientation(Qt.Horizontal) # This works around a stupid bug in QwtSliders -- see comments on histogram zoom wheel above self._wslider_timer = QTimer(parent) self._wslider_timer.setSingleShot(True) self._wslider_timer.setInterval(500) self._wslider_timer.timeout.connect(self.setValue) gridlayout.addWidget(self._wslider, row * 2 + 1, column) self._wslider.setScale(self.minval, self.maxval) # self._wslider.setScaleStepSize(self.step) self._wslider.setValue(self.value) self._wslider.setTracking(False) self._wslider.valueChanged.connect(self.setValue) self._wslider.sliderMoved.connect(self._previewValue) def _resetValue(self): self._wslider.setValue(self._default) self.setValue(self._default) def setValue(self, value=None, notify=True): # only update widgets if already created self.value = value if self._wlabel is not None: if value is None: self.value = value = self._wslider.value() self._wreset.setEnabled(value != self._default) self._wlabel.setText(self.format % (self.name, self.value)) # stop timer if being called to finalize the change in value if notify: self._wslider_timer.stop() self.valueChanged.emit(self.value) def _previewValue(self, value): self.setValue(notify=False) self._wslider_timer.start(500) self.valueMoved.emit(self.value)
class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog): def __init__(self, parent, db, id_to_select, select_sort, select_link): 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.WindowContextHelpButtonHint)) self.setWindowIcon(icon) try: self.table_column_widths = \ gprefs.get('manage_authors_table_widths', None) geom = gprefs.get('manage_authors_dialog_geometry', bytearray('')) self.restoreGeometry(QByteArray(geom)) except: pass self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK')) self.buttonBox.button(QDialogButtonBox.Cancel).setText(_('&Cancel')) self.buttonBox.accepted.connect(self.accepted) # Set up the column headings self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setColumnCount(3) self.down_arrow_icon = QIcon(I('arrow-down.png')) self.up_arrow_icon = QIcon(I('arrow-up.png')) self.blank_icon = QIcon(I('blank.png')) self.auth_col = QTableWidgetItem(_('Author')) self.table.setHorizontalHeaderItem(0, self.auth_col) self.auth_col.setIcon(self.blank_icon) self.aus_col = QTableWidgetItem(_('Author sort')) self.table.setHorizontalHeaderItem(1, self.aus_col) self.aus_col.setIcon(self.up_arrow_icon) self.aul_col = QTableWidgetItem(_('Link')) self.table.setHorizontalHeaderItem(2, self.aul_col) self.aus_col.setIcon(self.blank_icon) # Add the data self.authors = {} auts = db.get_authors_with_ids() self.table.setRowCount(len(auts)) select_item = None for row, (id, author, sort, link) in enumerate(auts): author = author.replace('|', ',') self.authors[id] = (author, sort, link) aut = tableItem(author) aut.setData(Qt.UserRole, id) sort = tableItem(sort) link = tableItem(link) self.table.setItem(row, 0, aut) self.table.setItem(row, 1, sort) self.table.setItem(row, 2, link) if id_to_select in (id, author): if select_sort: select_item = sort elif select_link: select_item = link else: select_item = aut 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) # set up sort buttons self.sort_by_author.setCheckable(True) self.sort_by_author.setChecked(False) self.sort_by_author.clicked.connect(self.do_sort_by_author) self.author_order = 1 self.table.sortByColumn(1, Qt.AscendingOrder) self.sort_by_author_sort.clicked.connect(self.do_sort_by_author_sort) self.sort_by_author_sort.setCheckable(True) self.sort_by_author_sort.setChecked(True) self.author_sort_order = 1 self.recalc_author_sort.clicked.connect(self.do_recalc_author_sort) self.auth_sort_to_author.clicked.connect(self.do_auth_sort_to_author) # Position on the desired item if select_item is not None: self.table.setCurrentItem(select_item) self.table.editItem(select_item) self.start_find_pos = select_item.row() * 2 + select_item.column() else: self.table.setCurrentCell(0, 0) self.start_find_pos = -1 # set up the search box self.find_box.initialize('manage_authors_search') self.find_box.lineEdit().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) l = QLabel(self.table) self.not_found_label = l l.setFrameStyle(QFrame.StyledPanel) l.setAutoFillBackground(True) l.setText(_('No matches found')) l.setAlignment(Qt.AlignVCenter) l.resize(l.sizeHint()) l.move(10,20) l.setVisible(False) self.not_found_label.move(40, 40) 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.QueuedConnection) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.customContextMenuRequested .connect(self.show_context_menu) 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 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 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() 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) 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 copy_to_clipboard(self): cb = QApplication.clipboard() cb.setText(unicode(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(self.context_item.text()))) def lower_case(self): self.context_item.setText(icu_lower(unicode(self.context_item.text()))) def swap_case(self): self.context_item.setText(unicode(self.context_item.text()).swapcase()) def title_case(self): from calibre.utils.titlecase import titlecase self.context_item.setText(titlecase(unicode(self.context_item.text()))) def capitalize(self): from calibre.utils.icu import capitalize self.context_item.setText(capitalize(unicode(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 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.Ok).setDefault(False) self.buttonBox.button(QDialogButtonBox.Ok).setAutoDefault(False) self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(False) self.buttonBox.button(QDialogButtonBox.Cancel).setAutoDefault(False) st = icu_lower(unicode(self.find_box.currentText())) for i 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(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_by_author(self): self.author_order = 1 if self.author_order == 0 else 0 self.table.sortByColumn(0, self.author_order) self.sort_by_author.setChecked(True) self.sort_by_author_sort.setChecked(False) self.auth_col.setIcon(self.down_arrow_icon if self.author_order else self.up_arrow_icon) self.aus_col.setIcon(self.blank_icon) def do_sort_by_author_sort(self): self.author_sort_order = 1 if self.author_sort_order == 0 else 0 self.table.sortByColumn(1, self.author_sort_order) self.sort_by_author.setChecked(False) self.sort_by_author_sort.setChecked(True) self.aus_col.setIcon(self.down_arrow_icon if self.author_sort_order else self.up_arrow_icon) self.auth_col.setIcon(self.blank_icon) def accepted(self): self.save_state() self.result = [] for row in range(0,self.table.rowCount()): id = int(self.table.item(row, 0).data(Qt.UserRole)) aut = unicode(self.table.item(row, 0).text()).strip() sort = unicode(self.table.item(row, 1).text()).strip() link = unicode(self.table.item(row, 2).text()).strip() orig_aut,orig_sort,orig_link = self.authors[id] if orig_aut != aut or orig_sort != sort or orig_link != link: self.result.append((id, orig_aut, aut, sort, link)) def do_recalc_author_sort(self): self.table.cellChanged.disconnect() for row in range(0,self.table.rowCount()): item = self.table.item(row, 0) aut = unicode(item.text()).strip() c = self.table.item(row, 1) # Sometimes trailing commas are left by changing between copy algs c.setText(author_to_author_sort(aut).rstrip(',')) self.table.setFocus(Qt.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()): item = self.table.item(row, 1) aus = unicode(item.text()).strip() c = self.table.item(row, 0) # Sometimes trailing commas are left by changing between copy algs c.setText(aus) self.table.setFocus(Qt.OtherFocusReason) self.table.cellChanged.connect(self.cell_changed) def cell_changed(self, row, col): if col == 0: item = self.table.item(row, 0) aut = unicode(item.text()).strip() amper = aut.find('&') if amper >= 0: error_dialog(self.parent(), _('Invalid author name'), _('Author names cannot contain & characters.')).exec_() aut = aut.replace('&', '%') self.table.item(row, 0).setText(aut) c = self.table.item(row, 1) c.setText(author_to_author_sort(aut)) item = c else: item = self.table.item(row, col) self.table.setCurrentItem(item) self.table.scrollToItem(item)
class MaterialManager(QObject): __instance = None @classmethod @deprecated("Use the ContainerTree structure instead.", since="4.3") def getInstance(cls) -> "MaterialManager": if cls.__instance is None: cls.__instance = MaterialManager() return cls.__instance materialsUpdated = pyqtSignal( ) # Emitted whenever the material lookup tables are updated. favoritesUpdated = pyqtSignal( ) # Emitted whenever the favorites are changed def __init__(self, parent=None): super().__init__(parent) # Material_type -> generic material metadata self._fallback_materials_map = dict( ) # type: Dict[str, Dict[str, Any]] # Root_material_id -> MaterialGroup self._material_group_map = dict() # type: Dict[str, MaterialGroup] # Material id including diameter (generic_pla_175) -> material root id (generic_pla) self._diameter_material_map = dict() # type: Dict[str, str] # This is used in Legacy UM3 send material function and the material management page. # GUID -> a list of material_groups self._guid_material_groups_map = defaultdict( list) # type: Dict[str, List[MaterialGroup]] self._favorites = set(cura.CuraApplication.CuraApplication.getInstance( ).getPreferences().getValue("cura/favorite_materials").split(";")) self.materialsUpdated.emit() self._update_timer = QTimer(self) self._update_timer.setInterval(300) self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self.materialsUpdated) container_registry = ContainerRegistry.getInstance() container_registry.containerMetaDataChanged.connect( self._onContainerMetadataChanged) container_registry.containerAdded.connect( self._onContainerMetadataChanged) container_registry.containerRemoved.connect( self._onContainerMetadataChanged) def _onContainerMetadataChanged(self, container): self._onContainerChanged(container) def _onContainerChanged(self, container): container_type = container.getMetaDataEntry("type") if container_type != "material": return # update the maps self._update_timer.start() def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]: return self._material_group_map.get(root_material_id) def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str: original_material = CuraContainerRegistry.getInstance( ).findInstanceContainersMetadata(id=root_material_id)[0] if original_material["approximate_diameter"] == approximate_diameter: return root_material_id matching_materials = CuraContainerRegistry.getInstance( ).findInstanceContainersMetadata( type="material", brand=original_material["brand"], definition=original_material["definition"], material=original_material["material"], color_name=original_material["color_name"]) for material in matching_materials: if material["approximate_diameter"] == approximate_diameter: return material["id"] return root_material_id def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str: return self._diameter_material_map.get(root_material_id, "") def getMaterialGroupListByGUID(self, guid: str) -> Optional[List[MaterialGroup]]: return self._guid_material_groups_map.get(guid) # Returns a dict of all material groups organized by root_material_id. def getAllMaterialGroups(self) -> Dict[str, "MaterialGroup"]: return self._material_group_map ## Gives a dictionary of all root material IDs and their associated # MaterialNodes from the ContainerTree that are available for the given # printer and variant. def getAvailableMaterials( self, definition_id: str, nozzle_name: Optional[str]) -> Dict[str, MaterialNode]: return ContainerTree.getInstance( ).machines[definition_id].variants[nozzle_name].materials # # A convenience function to get available materials for the given machine with the extruder position. # def getAvailableMaterialsForMachineExtruder( self, machine: "GlobalStack", extruder_stack: "ExtruderStack") -> Dict[str, MaterialNode]: nozzle_name = None if extruder_stack.variant.getId() != "empty_variant": nozzle_name = extruder_stack.variant.getName() # Fetch the available materials (ContainerNode) for the current active machine and extruder setup. materials = self.getAvailableMaterials(machine.definition.getId(), nozzle_name) compatible_material_diameter = extruder_stack.getApproximateMaterialDiameter( ) result = { key: material for key, material in materials.items() if material.container and float( material.container.getMetaDataEntry("approximate_diameter")) == compatible_material_diameter } return result # # Gets MaterialNode for the given extruder and machine with the given material name. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNode(self, machine_definition_id: str, nozzle_name: Optional[str], buildplate_name: Optional[str], diameter: float, root_material_id: str) -> Optional["MaterialNode"]: container_tree = ContainerTree.getInstance() machine_node = container_tree.machines.get(machine_definition_id) if machine_node is None: Logger.log( "w", "Could not find machine with definition %s in the container tree", machine_definition_id) return None variant_node = machine_node.variants.get(nozzle_name) if variant_node is None: Logger.log( "w", "Could not find variant %s for machine with definition %s in the container tree", nozzle_name, machine_definition_id) return None material_node = variant_node.materials.get(root_material_id) if material_node is None: Logger.log( "w", "Could not find material %s for machine with definition %s and variant %s in the container tree", root_material_id, machine_definition_id, nozzle_name) return None return material_node # # Gets MaterialNode for the given extruder and machine with the given material type. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, nozzle_name: str, buildplate_name: Optional[str], material_guid: str) -> Optional["MaterialNode"]: machine_definition = global_stack.definition extruder = global_stack.extruderList[int(position)] variant_name = extruder.variant.getName() approximate_diameter = extruder.getApproximateMaterialDiameter() return self.getMaterialNode(machine_definition.getId(), variant_name, buildplate_name, approximate_diameter, material_guid) # There are 2 ways to get fallback materials; # - A fallback by type (@sa getFallbackMaterialIdByMaterialType), which adds the generic version of this material # - A fallback by GUID; If a material has been duplicated, it should also check if the original materials do have # a GUID. This should only be done if the material itself does not have a quality just yet. def getFallBackMaterialIdsByMaterial( self, material: "InstanceContainer") -> List[str]: results = [] # type: List[str] material_groups = self.getMaterialGroupListByGUID( material.getMetaDataEntry("GUID")) for material_group in material_groups: # type: ignore if material_group.name != material.getId(): # If the material in the group is read only, put it at the front of the list (since that is the most # likely one to get a result) if material_group.is_read_only: results.insert(0, material_group.name) else: results.append(material_group.name) fallback = self.getFallbackMaterialIdByMaterialType( material.getMetaDataEntry("material")) if fallback is not None: results.append(fallback) return results # # Built-in quality profiles may be based on generic material IDs such as "generic_pla". # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use # the generic material IDs to search for qualities. # # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will # be "generic_pla". This function is intended to get a generic fallback material for the given material type. # # This function returns the generic root material ID for the given material type, where material types are "PLA", # "ABS", etc. # def getFallbackMaterialIdByMaterialType( self, material_type: str) -> Optional[str]: # For safety if material_type not in self._fallback_materials_map: Logger.log( "w", "The material type [%s] does not have a fallback material" % material_type) return None fallback_material = self._fallback_materials_map[material_type] if fallback_material: return self.getRootMaterialIDWithoutDiameter( fallback_material["id"]) else: return None ## Get default material for given global stack, extruder position and extruder nozzle name # you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder) def getDefaultMaterial( self, global_stack: "GlobalStack", position: str, nozzle_name: Optional[str], extruder_definition: Optional["DefinitionContainer"] = None ) -> "MaterialNode": definition_id = global_stack.definition.getId() machine_node = ContainerTree.getInstance().machines[definition_id] if nozzle_name in machine_node.variants: nozzle_node = machine_node.variants[nozzle_name] else: Logger.log( "w", "Could not find variant {nozzle_name} for machine with definition {definition_id} in the container tree" .format(nozzle_name=nozzle_name, definition_id=definition_id)) nozzle_node = next(iter(machine_node.variants)) if not parseBool(global_stack.getMetaDataEntry("has_materials", False)): return next(iter(nozzle_node.materials)) if extruder_definition is not None: material_diameter = extruder_definition.getProperty( "material_diameter", "value") else: material_diameter = global_stack.extruders[ position].getCompatibleMaterialDiameter() approximate_material_diameter = round(material_diameter) return nozzle_node.preferredMaterial(approximate_material_diameter) def removeMaterialByRootId(self, root_material_id: str): container_registry = CuraContainerRegistry.getInstance() results = container_registry.findContainers(id=root_material_id) if not results: container_registry.addWrongContainerId(root_material_id) for result in results: container_registry.removeContainer( result.getMetaDataEntry("id", "")) @pyqtSlot("QVariant", result=bool) def canMaterialBeRemoved(self, material_node: "MaterialNode"): # Check if the material is active in any extruder train. In that case, the material shouldn't be removed! # In the future we might enable this again, but right now, it's causing a ton of issues if we do (since it # corrupts the configuration) root_material_id = material_node.base_file ids_to_remove = { metadata.get("id", "") for metadata in CuraContainerRegistry.getInstance(). findInstanceContainersMetadata(base_file=root_material_id) } for extruder_stack in CuraContainerRegistry.getInstance( ).findContainerStacks(type="extruder_train"): if extruder_stack.material.getId() in ids_to_remove: return False return True ## Change the user-visible name of a material. # \param material_node The ContainerTree node of the material to rename. # \param name The new name for the material. @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: return cura.CuraApplication.CuraApplication.getMaterialManagementModel( ).setMaterialName(material_node, name) ## Deletes a material from Cura. # # This function does not do any safety checking any more. Please call this # function only if: # - The material is not read-only. # - The material is not used in any stacks. # If the material was not lazy-loaded yet, this will fully load the # container. When removing this material node, all other materials with # the same base fill will also be removed. # \param material_node The material to remove. @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode") -> None: return cura.CuraApplication.CuraApplication.getMaterialManagementModel( ).setMaterialName(material_node) def duplicateMaterialByRootId( self, root_material_id: str, new_base_id: Optional[str] = None, new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: result = cura.CuraApplication.CuraApplication.getInstance( ).getMaterialManagementModel().duplicateMaterialByBaseFile( root_material_id, new_base_id, new_metadata) if result is None: return "ERROR" return result ## Creates a duplicate of a material with the same GUID and base_file # metadata. # \param material_node The node representing the material to duplicate. # \param new_base_id A new material ID for the base material. The IDs of # the submaterials will be based off this one. If not provided, a material # ID will be generated automatically. # \param new_metadata Metadata for the new material. If not provided, this # will be duplicated from the original material. # \return The root material ID of the duplicate material. @pyqtSlot("QVariant", result=str) def duplicateMaterial( self, material_node: MaterialNode, new_base_id: Optional[str] = None, new_metadata: Optional[Dict[str, Any]] = None) -> str: result = cura.CuraApplication.CuraApplication.getInstance( ).getMaterialManagementModel().duplicateMaterial( material_node, new_base_id, new_metadata) if result is None: return "ERROR" return result ## Create a new material by cloning the preferred material for the current # material diameter and generate a new GUID. # # The material type is explicitly left to be the one from the preferred # material, since this allows the user to still have SOME profiles to work # with. # \return The ID of the newly created material. @pyqtSlot(result=str) def createMaterial(self) -> str: return cura.CuraApplication.CuraApplication.getMaterialManagementModel( ).createMaterial() @pyqtSlot(str) def addFavorite(self, root_material_id: str) -> None: self._favorites.add(root_material_id) self.materialsUpdated.emit() # Ensure all settings are saved. cura.CuraApplication.CuraApplication.getInstance().getPreferences( ).setValue("cura/favorite_materials", ";".join(list(self._favorites))) cura.CuraApplication.CuraApplication.getInstance().saveSettings() @pyqtSlot(str) def removeFavorite(self, root_material_id: str) -> None: try: self._favorites.remove(root_material_id) except KeyError: Logger.log( "w", "Could not delete material %s from favorites as it was already deleted", root_material_id) return self.materialsUpdated.emit() # Ensure all settings are saved. cura.CuraApplication.CuraApplication.getInstance().getPreferences( ).setValue("cura/favorite_materials", ";".join(list(self._favorites))) cura.CuraApplication.CuraApplication.getInstance().saveSettings() @pyqtSlot() def getFavorites(self): return self._favorites
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(self.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(self.Batched) self.setResizeMode(self.Adjust) self.setSelectionMode(self.ExtendedSelection) self.setVerticalScrollMode(self.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.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.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'] pal = QPalette() col = QColor(r, g, b) pal.setColor(pal.Base, col) tex = gprefs['cover_grid_texture'] if tex: from calibre.gui2.preferences.texture_chooser import texture_path path = texture_path(tex) if path: 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) pal.setBrush(pal.Base, QBrush(pm)) dark = (r + g + b)/3.0 < 128 pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black)) self.setPalette(pal) self.delegate.highlight_color = pal.color(pal.Text) 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.IgnoreAspectRatio, Qt.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)), sm.Select) sm.select(sel, sm.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, sm.ClearAndSelect) def set_current_row(self, row): sm = self.selectionModel() sm.setCurrentIndex(self.model().index(row, 0), sm.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.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, sm.NoUpdate) if not ci.isValid(): return if not sm.hasSelection(): sm.select(index, sm.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), sm.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: step = max(10, self.spacing()) for y in range(step, 500, step): for x in range(step, 500, step): i = self.indexAt(QPoint(x, y)) if i.isValid(): for x in range(self.viewport().width() - step, self.viewport().width() - 300, -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.ShiftModifier and k in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down): ci = self.currentIndex() if not ci.isValid(): return c = ci.row() delta = {Qt.Key_Left: -1, Qt.Key_Right: 1, Qt.Key_Up: -self.number_of_columns(), Qt.Key_Down: self.number_of_columns()}[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), sm.ClearAndSelect) sm.setCurrentIndex(self.model().index(n, 0), sm.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.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), self.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 (QListView.MoveLeft, QListView.MoveRight) and index.isValid(): ci = self.currentIndex() if ci.isValid() and index.row() == ci.row(): nr = index.row() + (1 if action == QListView.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() == event.KeyPress and event.key() in (Qt.Key_Home, Qt.Key_End) and event.modifiers() & Qt.CTRL: return QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows return super(GridView, self).selectionCommand(index, event) def wheelEvent(self, ev): if ev.phase() not in (Qt.ScrollUpdate, 0): 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 MaterialManager(QObject): materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated. favoritesUpdated = pyqtSignal() # Emitted whenever the favorites are changed def __init__(self, container_registry, parent = None): super().__init__(parent) self._application = Application.getInstance() self._container_registry = container_registry # type: ContainerRegistry # Material_type -> generic material metadata self._fallback_materials_map = dict() # type: Dict[str, Dict[str, Any]] # Root_material_id -> MaterialGroup self._material_group_map = dict() # type: Dict[str, MaterialGroup] # Approximate diameter str self._diameter_machine_nozzle_buildplate_material_map = dict() # type: Dict[str, Dict[str, MaterialNode]] # We're using these two maps to convert between the specific diameter material id and the generic material id # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant # i.e. generic_pla -> generic_pla_175 # root_material_id -> approximate diameter str -> root_material_id for that diameter self._material_diameter_map = defaultdict(dict) # type: Dict[str, Dict[str, str]] # Material id including diameter (generic_pla_175) -> material root id (generic_pla) self._diameter_material_map = dict() # type: Dict[str, str] # This is used in Legacy UM3 send material function and the material management page. # GUID -> a list of material_groups self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]] # The machine definition ID for the non-machine-specific materials. # This is used as the last fallback option if the given machine-specific material(s) cannot be found. self._default_machine_definition_id = "fdmprinter" self._default_approximate_diameter_for_quality_search = "3" # When a material gets added/imported, there can be more than one InstanceContainers. In those cases, we don't # want to react on every container/metadata changed signal. The timer here is to buffer it a bit so we don't # react too many time. self._update_timer = QTimer(self) self._update_timer.setInterval(300) self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._updateMaps) self._container_registry.containerMetaDataChanged.connect(self._onContainerMetadataChanged) self._container_registry.containerAdded.connect(self._onContainerMetadataChanged) self._container_registry.containerRemoved.connect(self._onContainerMetadataChanged) self._favorites = set() # type: Set[str] def initialize(self) -> None: # Find all materials and put them in a matrix for quick search. material_metadatas = {metadata["id"]: metadata for metadata in self._container_registry.findContainersMetadata(type = "material") if metadata.get("GUID")} # type: Dict[str, Dict[str, Any]] self._material_group_map = dict() # type: Dict[str, MaterialGroup] # Map #1 # root_material_id -> MaterialGroup for material_id, material_metadata in material_metadatas.items(): # We don't store empty material in the lookup tables if material_id == "empty_material": continue root_material_id = material_metadata.get("base_file", "") if root_material_id not in self._material_group_map: self._material_group_map[root_material_id] = MaterialGroup(root_material_id, MaterialNode(material_metadatas[root_material_id])) self._material_group_map[root_material_id].is_read_only = self._container_registry.isReadOnly(root_material_id) group = self._material_group_map[root_material_id] # Store this material in the group of the appropriate root material. if material_id != root_material_id: new_node = MaterialNode(material_metadata) group.derived_material_node_list.append(new_node) # Order this map alphabetically so it's easier to navigate in a debugger self._material_group_map = OrderedDict(sorted(self._material_group_map.items(), key = lambda x: x[0])) # Map #1.5 # GUID -> material group list self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]] for root_material_id, material_group in self._material_group_map.items(): guid = material_group.root_material_node.getMetaDataEntry("GUID", "") self._guid_material_groups_map[guid].append(material_group) # Map #2 # Lookup table for material type -> fallback material metadata, only for read-only materials grouped_by_type_dict = dict() # type: Dict[str, Any] material_types_without_fallback = set() for root_material_id, material_node in self._material_group_map.items(): material_type = material_node.root_material_node.getMetaDataEntry("material", "") if material_type not in grouped_by_type_dict: grouped_by_type_dict[material_type] = {"generic": None, "others": []} material_types_without_fallback.add(material_type) brand = material_node.root_material_node.getMetaDataEntry("brand", "") if brand.lower() == "generic": to_add = True if material_type in grouped_by_type_dict: diameter = material_node.root_material_node.getMetaDataEntry("approximate_diameter", "") if diameter != self._default_approximate_diameter_for_quality_search: to_add = False # don't add if it's not the default diameter if to_add: # Checking this first allow us to differentiate between not read only materials: # - if it's in the list, it means that is a new material without fallback # - if it is not, then it is a custom material with a fallback material (parent) if material_type in material_types_without_fallback: grouped_by_type_dict[material_type] = material_node.root_material_node._metadata material_types_without_fallback.remove(material_type) # Remove the materials that have no fallback materials for material_type in material_types_without_fallback: del grouped_by_type_dict[material_type] self._fallback_materials_map = grouped_by_type_dict # Map #3 # There can be multiple material profiles for the same material with different diameters, such as "generic_pla" # and "generic_pla_175". This is inconvenient when we do material-specific quality lookup because a quality can # be for either "generic_pla" or "generic_pla_175", but not both. This map helps to get the correct material ID # for quality search. self._material_diameter_map = defaultdict(dict) self._diameter_material_map = dict() # Group the material IDs by the same name, material, brand, and color but with different diameters. material_group_dict = dict() # type: Dict[Tuple[Any], Dict[str, str]] keys_to_fetch = ("name", "material", "brand", "color") for root_material_id, machine_node in self._material_group_map.items(): root_material_metadata = machine_node.root_material_node._metadata key_data_list = [] # type: List[Any] for key in keys_to_fetch: key_data_list.append(machine_node.root_material_node.getMetaDataEntry(key)) key_data = cast(Tuple[Any], tuple(key_data_list)) # type: Tuple[Any] # If the key_data doesn't exist, it doesn't matter if the material is read only... if key_data not in material_group_dict: material_group_dict[key_data] = dict() else: # ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it if not machine_node.is_read_only: continue approximate_diameter = machine_node.root_material_node.getMetaDataEntry("approximate_diameter", "") material_group_dict[key_data][approximate_diameter] = machine_node.root_material_node.getMetaDataEntry("id", "") # Map [root_material_id][diameter] -> root_material_id for this diameter for data_dict in material_group_dict.values(): for root_material_id1 in data_dict.values(): if root_material_id1 in self._material_diameter_map: continue diameter_map = data_dict for root_material_id2 in data_dict.values(): self._material_diameter_map[root_material_id2] = diameter_map default_root_material_id = data_dict.get(self._default_approximate_diameter_for_quality_search) if default_root_material_id is None: default_root_material_id = list(data_dict.values())[0] # no default diameter present, just take "the" only one for root_material_id in data_dict.values(): self._diameter_material_map[root_material_id] = default_root_material_id # Map #4 # "machine" -> "nozzle name" -> "buildplate name" -> "root material ID" -> specific material InstanceContainer self._diameter_machine_nozzle_buildplate_material_map = dict() # type: Dict[str, Dict[str, MaterialNode]] for material_metadata in material_metadatas.values(): self.__addMaterialMetadataIntoLookupTree(material_metadata) favorites = self._application.getPreferences().getValue("cura/favorite_materials") for item in favorites.split(";"): self._favorites.add(item) self.materialsUpdated.emit() def __addMaterialMetadataIntoLookupTree(self, material_metadata: Dict[str, Any]) -> None: material_id = material_metadata["id"] # We don't store empty material in the lookup tables if material_id == "empty_material": return root_material_id = material_metadata["base_file"] definition = material_metadata["definition"] approximate_diameter = material_metadata["approximate_diameter"] if approximate_diameter not in self._diameter_machine_nozzle_buildplate_material_map: self._diameter_machine_nozzle_buildplate_material_map[approximate_diameter] = {} machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[ approximate_diameter] if definition not in machine_nozzle_buildplate_material_map: machine_nozzle_buildplate_material_map[definition] = MaterialNode() # This is a list of information regarding the intermediate nodes: # nozzle -> buildplate nozzle_name = material_metadata.get("variant_name") buildplate_name = material_metadata.get("buildplate_name") intermediate_node_info_list = [(nozzle_name, VariantType.NOZZLE), (buildplate_name, VariantType.BUILD_PLATE), ] variant_manager = self._application.getVariantManager() machine_node = machine_nozzle_buildplate_material_map[definition] current_node = machine_node current_intermediate_node_info_idx = 0 error_message = None # type: Optional[str] while current_intermediate_node_info_idx < len(intermediate_node_info_list): variant_name, variant_type = intermediate_node_info_list[current_intermediate_node_info_idx] if variant_name is not None: # The new material has a specific variant, so it needs to be added to that specific branch in the tree. variant = variant_manager.getVariantNode(definition, variant_name, variant_type) if variant is None: error_message = "Material {id} contains a variant {name} that does not exist.".format( id = material_metadata["id"], name = variant_name) break # Update the current node to advance to a more specific branch if variant_name not in current_node.children_map: current_node.children_map[variant_name] = MaterialNode() current_node = current_node.children_map[variant_name] current_intermediate_node_info_idx += 1 if error_message is not None: Logger.log("e", "%s It will not be added into the material lookup tree.", error_message) self._container_registry.addWrongContainerId(material_metadata["id"]) return # Add the material to the current tree node, which is the deepest (the most specific) branch we can find. # Sanity check: Make sure that there is no duplicated materials. if root_material_id in current_node.material_map: Logger.log("e", "Duplicated material [%s] with root ID [%s]. It has already been added.", material_id, root_material_id) ConfigurationErrorMessage.getInstance().addFaultyContainers(root_material_id) return current_node.material_map[root_material_id] = MaterialNode(material_metadata) def _updateMaps(self): Logger.log("i", "Updating material lookup data ...") self.initialize() def _onContainerMetadataChanged(self, container): self._onContainerChanged(container) def _onContainerChanged(self, container): container_type = container.getMetaDataEntry("type") if container_type != "material": return # update the maps self._update_timer.start() def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]: return self._material_group_map.get(root_material_id) def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str: return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id) def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str: return self._diameter_material_map.get(root_material_id, "") def getMaterialGroupListByGUID(self, guid: str) -> Optional[List[MaterialGroup]]: return self._guid_material_groups_map.get(guid) # # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup. # def getAvailableMaterials(self, machine_definition: "DefinitionContainer", nozzle_name: Optional[str], buildplate_name: Optional[str], diameter: float) -> Dict[str, MaterialNode]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map: Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter) return dict() machine_definition_id = machine_definition.getId() # If there are nozzle-and-or-buildplate materials, get the nozzle-and-or-buildplate material machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[rounded_diameter] machine_node = machine_nozzle_buildplate_material_map.get(machine_definition_id) default_machine_node = machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id) nozzle_node = None buildplate_node = None if nozzle_name is not None and machine_node is not None: nozzle_node = machine_node.getChildNode(nozzle_name) # Get buildplate node if possible if nozzle_node is not None and buildplate_name is not None: buildplate_node = nozzle_node.getChildNode(buildplate_name) nodes_to_check = [buildplate_node, nozzle_node, machine_node, default_machine_node] # Fallback mechanism of finding materials: # 1. buildplate-specific material # 2. nozzle-specific material # 3. machine-specific material # 4. generic material (for fdmprinter) machine_exclude_materials = machine_definition.getMetaDataEntry("exclude_materials", []) material_id_metadata_dict = dict() # type: Dict[str, MaterialNode] excluded_materials = set() for current_node in nodes_to_check: if current_node is None: continue # Only exclude the materials that are explicitly specified in the "exclude_materials" field. # Do not exclude other materials that are of the same type. for material_id, node in current_node.material_map.items(): if material_id in machine_exclude_materials: excluded_materials.add(material_id) continue if material_id not in material_id_metadata_dict: material_id_metadata_dict[material_id] = node if excluded_materials: Logger.log("d", "Exclude materials {excluded_materials} for machine {machine_definition_id}".format(excluded_materials = ", ".join(excluded_materials), machine_definition_id = machine_definition_id)) return material_id_metadata_dict # # A convenience function to get available materials for the given machine with the extruder position. # def getAvailableMaterialsForMachineExtruder(self, machine: "GlobalStack", extruder_stack: "ExtruderStack") -> Optional[Dict[str, MaterialNode]]: buildplate_name = machine.getBuildplateName() nozzle_name = None if extruder_stack.variant.getId() != "empty_variant": nozzle_name = extruder_stack.variant.getName() diameter = extruder_stack.getApproximateMaterialDiameter() # Fetch the available materials (ContainerNode) for the current active machine and extruder setup. return self.getAvailableMaterials(machine.definition, nozzle_name, buildplate_name, diameter) # # Gets MaterialNode for the given extruder and machine with the given material name. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNode(self, machine_definition_id: str, nozzle_name: Optional[str], buildplate_name: Optional[str], diameter: float, root_material_id: str) -> Optional["MaterialNode"]: # round the diameter to get the approximate diameter rounded_diameter = str(round(diameter)) if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map: Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]", diameter, rounded_diameter, root_material_id) return None # If there are nozzle materials, get the nozzle-specific material machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[rounded_diameter] # type: Dict[str, MaterialNode] machine_node = machine_nozzle_buildplate_material_map.get(machine_definition_id) nozzle_node = None buildplate_node = None # Fallback for "fdmprinter" if the machine-specific materials cannot be found if machine_node is None: machine_node = machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id) if machine_node is not None and nozzle_name is not None: nozzle_node = machine_node.getChildNode(nozzle_name) if nozzle_node is not None and buildplate_name is not None: buildplate_node = nozzle_node.getChildNode(buildplate_name) # Fallback mechanism of finding materials: # 1. buildplate-specific material # 2. nozzle-specific material # 3. machine-specific material # 4. generic material (for fdmprinter) nodes_to_check = [buildplate_node, nozzle_node, machine_node, machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)] material_node = None for node in nodes_to_check: if node is not None: material_node = node.material_map.get(root_material_id) if material_node: break return material_node # # Gets MaterialNode for the given extruder and machine with the given material type. # Returns None if: # 1. the given machine doesn't have materials; # 2. cannot find any material InstanceContainers with the given settings. # def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, nozzle_name: str, buildplate_name: Optional[str], material_guid: str) -> Optional["MaterialNode"]: node = None machine_definition = global_stack.definition extruder_definition = global_stack.extruders[position].definition if parseBool(machine_definition.getMetaDataEntry("has_materials", False)): material_diameter = extruder_definition.getProperty("material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) # Look at the guid to material dictionary root_material_id = None for material_group in self._guid_material_groups_map[material_guid]: root_material_id = cast(str, material_group.root_material_node.getMetaDataEntry("id", "")) break if not root_material_id: Logger.log("i", "Cannot find materials with guid [%s] ", material_guid) return None node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name, material_diameter, root_material_id) return node # There are 2 ways to get fallback materials; # - A fallback by type (@sa getFallbackMaterialIdByMaterialType), which adds the generic version of this material # - A fallback by GUID; If a material has been duplicated, it should also check if the original materials do have # a GUID. This should only be done if the material itself does not have a quality just yet. def getFallBackMaterialIdsByMaterial(self, material: "InstanceContainer") -> List[str]: results = [] # type: List[str] material_groups = self.getMaterialGroupListByGUID(material.getMetaDataEntry("GUID")) for material_group in material_groups: # type: ignore if material_group.name != material.getId(): # If the material in the group is read only, put it at the front of the list (since that is the most # likely one to get a result) if material_group.is_read_only: results.insert(0, material_group.name) else: results.append(material_group.name) fallback = self.getFallbackMaterialIdByMaterialType(material.getMetaDataEntry("material")) if fallback is not None: results.append(fallback) return results # # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla". # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use # the generic material IDs to search for qualities. # # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will # be "generic_pla". This function is intended to get a generic fallback material for the given material type. # # This function returns the generic root material ID for the given material type, where material types are "PLA", # "ABS", etc. # def getFallbackMaterialIdByMaterialType(self, material_type: str) -> Optional[str]: # For safety if material_type not in self._fallback_materials_map: Logger.log("w", "The material type [%s] does not have a fallback material" % material_type) return None fallback_material = self._fallback_materials_map[material_type] if fallback_material: return self.getRootMaterialIDWithoutDiameter(fallback_material["id"]) else: return None ## Get default material for given global stack, extruder position and extruder nozzle name # you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder) def getDefaultMaterial(self, global_stack: "GlobalStack", position: str, nozzle_name: Optional[str], extruder_definition: Optional["DefinitionContainer"] = None) -> Optional["MaterialNode"]: node = None buildplate_name = global_stack.getBuildplateName() machine_definition = global_stack.definition # The extruder-compatible material diameter in the extruder definition may not be the correct value because # the user can change it in the definition_changes container. if extruder_definition is None: extruder_stack_or_definition = global_stack.extruders[position] is_extruder_stack = True else: extruder_stack_or_definition = extruder_definition is_extruder_stack = False if extruder_stack_or_definition and parseBool(global_stack.getMetaDataEntry("has_materials", False)): if is_extruder_stack: material_diameter = extruder_stack_or_definition.getCompatibleMaterialDiameter() else: material_diameter = extruder_stack_or_definition.getProperty("material_diameter", "value") if isinstance(material_diameter, SettingFunction): material_diameter = material_diameter(global_stack) approximate_material_diameter = str(round(material_diameter)) root_material_id = machine_definition.getMetaDataEntry("preferred_material") root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_material_diameter) node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name, material_diameter, root_material_id) return node def removeMaterialByRootId(self, root_material_id: str): material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log("i", "Unable to remove the material with id %s, because it doesn't exist.", root_material_id) return nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list for node in nodes_to_remove: self._container_registry.removeContainer(node.getMetaDataEntry("id", "")) # # Methods for GUI # # # Sets the new name for the given material. # @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: root_material_id = material_node.getMetaDataEntry("base_file") if root_material_id is None: return if self._container_registry.isReadOnly(root_material_id): Logger.log("w", "Cannot set name of read-only container %s.", root_material_id) return material_group = self.getMaterialGroup(root_material_id) if material_group: container = material_group.root_material_node.getContainer() if container: container.setName(name) # # Removes the given material. # @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode") -> None: root_material_id = material_node.getMetaDataEntry("base_file") if root_material_id is not None: self.removeMaterialByRootId(root_material_id) # # Creates a duplicate of a material, which has the same GUID and base_file metadata. # Returns the root material ID of the duplicated material if successful. # @pyqtSlot("QVariant", result = str) def duplicateMaterial(self, material_node: MaterialNode, new_base_id: Optional[str] = None, new_metadata: Dict[str, Any] = None) -> Optional[str]: root_material_id = cast(str, material_node.getMetaDataEntry("base_file", "")) material_group = self.getMaterialGroup(root_material_id) if not material_group: Logger.log("i", "Unable to duplicate the material with id %s, because it doesn't exist.", root_material_id) return None base_container = material_group.root_material_node.getContainer() if not base_container: return None # Ensure all settings are saved. self._application.saveSettings() # Create a new ID & container to hold the data. new_containers = [] if new_base_id is None: new_base_id = self._container_registry.uniqueName(base_container.getId()) new_base_container = copy.deepcopy(base_container) new_base_container.getMetaData()["id"] = new_base_id new_base_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_base_container.getMetaData()[key] = value new_containers.append(new_base_container) # Clone all of them. for node in material_group.derived_material_node_list: container_to_copy = node.getContainer() if not container_to_copy: continue # Create unique IDs for every clone. new_id = new_base_id if container_to_copy.getMetaDataEntry("definition") != "fdmprinter": new_id += "_" + container_to_copy.getMetaDataEntry("definition") if container_to_copy.getMetaDataEntry("variant_name"): nozzle_name = container_to_copy.getMetaDataEntry("variant_name") new_id += "_" + nozzle_name.replace(" ", "_") new_container = copy.deepcopy(container_to_copy) new_container.getMetaData()["id"] = new_id new_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: for key, value in new_metadata.items(): new_container.getMetaData()[key] = value new_containers.append(new_container) for container_to_add in new_containers: container_to_add.setDirty(True) self._container_registry.addContainer(container_to_add) # if the duplicated material was favorite then the new material should also be added to favorite. if root_material_id in self.getFavorites(): self.addFavorite(new_base_id) return new_base_id # # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID. # Returns the ID of the newly created material. @pyqtSlot(result = str) def createMaterial(self) -> str: from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") # Ensure all settings are saved. self._application.saveSettings() machine_manager = self._application.getMachineManager() extruder_stack = machine_manager.activeStack machine_definition = self._application.getGlobalContainerStack().definition preferred_material = machine_definition.getMetaDataEntry("preferred_material") approximate_diameter = str(extruder_stack.approximateMaterialDiameter) root_material_id = preferred_material if preferred_material else "generic_pla" root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_diameter) material_group = self.getMaterialGroup(root_material_id) if not material_group: # This should never happen Logger.log("w", "Cannot get the material group of %s.", root_material_id) return "" # Create a new ID & container to hold the data. new_id = self._container_registry.uniqueName("custom_material") new_metadata = {"name": catalog.i18nc("@label", "Custom Material"), "brand": catalog.i18nc("@label", "Custom"), "GUID": str(uuid.uuid4()), } self.duplicateMaterial(material_group.root_material_node, new_base_id = new_id, new_metadata = new_metadata) return new_id @pyqtSlot(str) def addFavorite(self, root_material_id: str) -> None: self._favorites.add(root_material_id) self.materialsUpdated.emit() # Ensure all settings are saved. self._application.getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites))) self._application.saveSettings() @pyqtSlot(str) def removeFavorite(self, root_material_id: str) -> None: self._favorites.remove(root_material_id) self.materialsUpdated.emit() # Ensure all settings are saved. self._application.getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites))) self._application.saveSettings() @pyqtSlot() def getFavorites(self): return self._favorites