class NewsCategory(NewsTreeItem): def __init__(self, category, builtin, custom, scheduler_config, parent): NewsTreeItem.__init__(self, builtin, custom, scheduler_config, parent) self.category = category self.cdata = get_language(self.category) if self.category == _('Scheduled'): self.sortq = 0, '' elif self.category == _('Custom'): self.sortq = 1, '' else: self.sortq = 2, self.cdata self.bold_font = QFont() self.bold_font.setBold(True) self.bold_font = (self.bold_font) def data(self, role): if role == Qt.ItemDataRole.DisplayRole: return (self.cdata + ' [%d]' % len(self.children)) elif role == Qt.ItemDataRole.FontRole: return self.bold_font elif role == Qt.ItemDataRole.ForegroundRole and self.category == _( 'Scheduled'): return QApplication.instance().palette().color( QPalette.ColorRole.Link) elif role == Qt.ItemDataRole.UserRole: return '::category::{}'.format(self.sortq[0]) return None def flags(self): return Qt.ItemFlag.ItemIsEnabled def __eq__(self, other): return self.cdata == other.cdata def __lt__(self, other): return self.sortq < getattr(other, 'sortq', (3, ''))
def paint_line_numbers(self, ev): painter = QPainter(self.line_number_area) painter.fillRect( ev.rect(), self.line_number_palette.color(QPalette.ColorRole.Base)) block = self.firstVisibleBlock() num = block.blockNumber() top = int( self.blockBoundingGeometry(block).translated( self.contentOffset()).top()) bottom = top + int(self.blockBoundingRect(block).height()) current = self.textCursor().block().blockNumber() painter.setPen(self.line_number_palette.color(QPalette.ColorRole.Text)) while block.isValid() and top <= ev.rect().bottom(): if block.isVisible() and bottom >= ev.rect().top(): if current == num: painter.save() painter.setPen( self.line_number_palette.color( QPalette.ColorRole.BrightText)) f = QFont(self.font()) f.setBold(True) painter.setFont(f) self.last_current_lnum = (top, bottom - top) painter.drawText(0, top, self.line_number_area.width() - 5, self.fontMetrics().height(), Qt.AlignmentFlag.AlignRight, unicode_type(num + 1)) if current == num: painter.restore() block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) num += 1
def __init__(self, parent=None, is_half_star=False): QComboBox.__init__(self, parent) self.addItem(_('Not rated')) if is_half_star: [self.addItem(stars(x, True)) for x in range(1, 11)] else: [self.addItem(stars(x)) for x in (2, 4, 6, 8, 10)] self.rating_font = QFont(rating_font()) self.undo_stack = QUndoStack(self) self.undo, self.redo = self.undo_stack.undo, self.undo_stack.redo self.allow_undo = False self.is_half_star = is_half_star self.delegate = RatingItemDelegate(self) self.view().setItemDelegate(self.delegate) self.view().setStyleSheet('QListView { background: palette(window) }\nQListView::item { padding: 6px }') self.setMaxVisibleItems(self.count()) self.currentIndexChanged.connect(self.update_font)
def do_paint(self, painter, option, index): text = str(index.data(Qt.ItemDataRole.DisplayRole) or '') font = QFont(option.font) font.setPointSizeF(QFontInfo(font).pointSize() * 1.5) font2 = QFont(font) font2.setFamily(text) system, has_latin = writing_system_for_font(font2) if has_latin: font = font2 r = option.rect color = option.palette.text() if option.state & QStyle.StateFlag.State_Selected: color = option.palette.highlightedText() painter.setPen(QPen(color, 0)) if (option.direction == Qt.LayoutDirection.RightToLeft): r.setRight(r.right() - 4) else: r.setLeft(r.left() + 4) painter.setFont(font) painter.drawText( r, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeading | Qt.TextFlag.TextSingleLine, text) if (system != QFontDatabase.WritingSystem.Any): w = painter.fontMetrics().width(text + " ") painter.setFont(font2) sample = QFontDatabase().writingSystemSample(system) if (option.direction == Qt.LayoutDirection.RightToLeft): r.setRight(r.right() - w) else: r.setLeft(r.left() + w) painter.drawText( r, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeading | Qt.TextFlag.TextSingleLine, sample)
def set_value(self, g, val): from calibre.gui2.convert.xpath_wizard import XPathEdit from calibre.gui2.convert.regex_builder import RegexEdit from calibre.gui2.widgets import EncodingComboBox if self.set_value_handler(g, val): return if hasattr(g, 'set_value_for_config'): g.set_value_for_config = val return if isinstance(g, (QSpinBox, QDoubleSpinBox)): g.setValue(val) elif isinstance(g, (QLineEdit, QTextEdit, QPlainTextEdit)): if not val: val = '' getattr(g, 'setPlainText', getattr(g, 'setText', None))(val) getattr(g, 'setCursorPosition', lambda x: x)(0) elif isinstance(g, QFontComboBox): g.setCurrentFont(QFont(val or '')) elif isinstance(g, FontFamilyChooser): g.font_family = val elif isinstance(g, EncodingComboBox): if val: g.setEditText(val) else: g.setCurrentIndex(0) elif isinstance(g, QComboBox) and val: idx = g.findText(val, Qt.MatchFlag.MatchFixedString) if idx < 0: g.addItem(val) idx = g.findText(val, Qt.MatchFlag.MatchFixedString) g.setCurrentIndex(idx) elif isinstance(g, QCheckBox): g.setCheckState(Qt.CheckState.Checked if bool(val) else Qt. CheckState.Unchecked) elif isinstance(g, (XPathEdit, RegexEdit)): g.edit.setText(val if val else '') else: raise Exception('Can\'t set value %s in %s' % (repr(val), unicode_type(g.objectName()))) self.post_set_value(g, val)
def initialize_formats(self): font_name = gprefs.get('gpm_template_editor_font', None) size = gprefs['gpm_template_editor_font_size'] if font_name is None: font = QFont() font.setFixedPitch(True) font.setPointSize(size) font_name = font.family() config = self.Config = {} config["fontfamily"] = font_name app_palette = QApplication.instance().palette() for name, color, bold, italic in ( ("normal", None, False, False), ("keyword", app_palette.color(QPalette.ColorRole.Link).name(), True, False), ("builtin", app_palette.color(QPalette.ColorRole.Link).name(), False, False), ("identifier", None, False, True), ("comment", "#007F00", False, True), ("string", "#808000", False, False), ("number", "#924900", False, False), ("lparen", None, True, True), ("rparen", None, True, True)): config["%sfontcolor" % name] = color config["%sfontbold" % name] = bold config["%sfontitalic" % name] = italic base_format = QTextCharFormat() base_format.setFontFamily(config["fontfamily"]) config["fontsize"] = size base_format.setFontPointSize(config["fontsize"]) self.Formats = {} for name in ("normal", "keyword", "builtin", "comment", "identifier", "string", "number", "lparen", "rparen"): format_ = QTextCharFormat(base_format) color = config["%sfontcolor" % name] if color: format_.setForeground(QColor(color)) if config["%sfontbold" % name]: format_.setFontWeight(QFont.Weight.Bold) format_.setFontItalic(config["%sfontitalic" % name]) self.Formats[name] = format_
def paint_line_numbers(self, ev): painter = QPainter(self.line_number_area) painter.fillRect( ev.rect(), self.line_number_palette.color(QPalette.ColorRole.Base)) block = self.firstVisibleBlock() num = block.blockNumber() top = int( self.blockBoundingGeometry(block).translated( self.contentOffset()).top()) bottom = top + int(self.blockBoundingRect(block).height()) current = self.textCursor().block().blockNumber() painter.setPen(self.line_number_palette.color(QPalette.ColorRole.Text)) while block.isValid() and top <= ev.rect().bottom(): if block.isVisible() and bottom >= ev.rect().top(): set_bold = False set_italic = False if current == num: set_bold = True if num + 1 in self.clicked_line_numbers: set_italic = True painter.save() if set_bold or set_italic: f = QFont(self.font()) if set_bold: f.setBold(set_bold) painter.setPen( self.line_number_palette.color( QPalette.ColorRole.BrightText)) f.setItalic(set_italic) painter.setFont(f) else: painter.setFont(self.font()) painter.drawText(0, top, self.line_number_area.width() - 5, self.fontMetrics().height(), Qt.AlignmentFlag.AlignRight, str(num + 1)) painter.restore() block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) num += 1
def __init__(self, field_metadata, parent=None, revert_tooltip=None, datetime_fmt='MMMM yyyy', blank_as_equal=True, fields=('title', 'authors', 'series', 'tags', 'rating', 'publisher', 'pubdate', 'identifiers', 'languages', 'comments', 'cover'), db=None): QWidget.__init__(self, parent) self.l = l = QGridLayout() # l.setContentsMargins(0, 0, 0, 0) self.setLayout(l) revert_tooltip = revert_tooltip or _('Revert %s') self.current_mi = None self.changed_font = QFont(QApplication.font()) self.changed_font.setBold(True) self.changed_font.setItalic(True) self.blank_as_equal = blank_as_equal self.widgets = OrderedDict() row = 0 for field in fields: m = field_metadata[field] dt = m['datatype'] extra = None if 'series' in {field, dt}: cls = SeriesEdit elif field == 'identifiers': cls = IdentifiersEdit elif field == 'languages': cls = LanguagesEdit elif 'comments' in {field, dt}: cls = CommentsEdit elif 'rating' in {field, dt}: cls = RatingsEdit elif dt == 'datetime': extra = datetime_fmt cls = DateEdit elif field == 'cover': cls = CoverView elif dt in {'text', 'enum'}: cls = LineEdit else: continue neww = cls(field, True, self, m, extra) neww.setObjectName(field) connect_lambda( neww.changed, self, lambda self: self.changed(self.sender().objectName())) if isinstance(neww, EditWithComplete): try: neww.update_items_cache(db.new_api.all_field_names(field)) except ValueError: pass # A one-one field like title if isinstance(neww, SeriesEdit): neww.set_db(db.new_api) oldw = cls(field, False, self, m, extra) newl = QLabel('&%s:' % m['name']) newl.setBuddy(neww) button = RightClickButton(self) button.setIcon(QIcon(I('back.png'))) button.setObjectName(field) connect_lambda( button.clicked, self, lambda self: self.revert(self.sender().objectName())) button.setToolTip(revert_tooltip % m['name']) if field == 'identifiers': button.m = m = QMenu(button) button.setMenu(m) button.setPopupMode( QToolButton.ToolButtonPopupMode.DelayedPopup) m.addAction(button.toolTip()).triggered.connect(button.click) m.actions()[0].setIcon(button.icon()) m.addAction(_('Merge identifiers')).triggered.connect( self.merge_identifiers) m.actions()[1].setIcon(QIcon(I('merge.png'))) elif field == 'tags': button.m = m = QMenu(button) button.setMenu(m) button.setPopupMode( QToolButton.ToolButtonPopupMode.DelayedPopup) m.addAction(button.toolTip()).triggered.connect(button.click) m.actions()[0].setIcon(button.icon()) m.addAction(_('Merge tags')).triggered.connect(self.merge_tags) m.actions()[1].setIcon(QIcon(I('merge.png'))) self.widgets[field] = Widgets(neww, oldw, newl, button) for i, w in enumerate((newl, neww, button, oldw)): c = i if i < 2 else i + 1 if w is oldw: c += 1 l.addWidget(w, row, c) row += 1 if 'comments' in self.widgets and not gprefs.get( 'diff_widget_show_comments_controls', True): self.widgets['comments'].new.hide_toolbars()
class CompareSingle(QWidget): def __init__(self, field_metadata, parent=None, revert_tooltip=None, datetime_fmt='MMMM yyyy', blank_as_equal=True, fields=('title', 'authors', 'series', 'tags', 'rating', 'publisher', 'pubdate', 'identifiers', 'languages', 'comments', 'cover'), db=None): QWidget.__init__(self, parent) self.l = l = QGridLayout() # l.setContentsMargins(0, 0, 0, 0) self.setLayout(l) revert_tooltip = revert_tooltip or _('Revert %s') self.current_mi = None self.changed_font = QFont(QApplication.font()) self.changed_font.setBold(True) self.changed_font.setItalic(True) self.blank_as_equal = blank_as_equal self.widgets = OrderedDict() row = 0 for field in fields: m = field_metadata[field] dt = m['datatype'] extra = None if 'series' in {field, dt}: cls = SeriesEdit elif field == 'identifiers': cls = IdentifiersEdit elif field == 'languages': cls = LanguagesEdit elif 'comments' in {field, dt}: cls = CommentsEdit elif 'rating' in {field, dt}: cls = RatingsEdit elif dt == 'datetime': extra = datetime_fmt cls = DateEdit elif field == 'cover': cls = CoverView elif dt in {'text', 'enum'}: cls = LineEdit else: continue neww = cls(field, True, self, m, extra) neww.setObjectName(field) connect_lambda( neww.changed, self, lambda self: self.changed(self.sender().objectName())) if isinstance(neww, EditWithComplete): try: neww.update_items_cache(db.new_api.all_field_names(field)) except ValueError: pass # A one-one field like title if isinstance(neww, SeriesEdit): neww.set_db(db.new_api) oldw = cls(field, False, self, m, extra) newl = QLabel('&%s:' % m['name']) newl.setBuddy(neww) button = RightClickButton(self) button.setIcon(QIcon(I('back.png'))) button.setObjectName(field) connect_lambda( button.clicked, self, lambda self: self.revert(self.sender().objectName())) button.setToolTip(revert_tooltip % m['name']) if field == 'identifiers': button.m = m = QMenu(button) button.setMenu(m) button.setPopupMode( QToolButton.ToolButtonPopupMode.DelayedPopup) m.addAction(button.toolTip()).triggered.connect(button.click) m.actions()[0].setIcon(button.icon()) m.addAction(_('Merge identifiers')).triggered.connect( self.merge_identifiers) m.actions()[1].setIcon(QIcon(I('merge.png'))) elif field == 'tags': button.m = m = QMenu(button) button.setMenu(m) button.setPopupMode( QToolButton.ToolButtonPopupMode.DelayedPopup) m.addAction(button.toolTip()).triggered.connect(button.click) m.actions()[0].setIcon(button.icon()) m.addAction(_('Merge tags')).triggered.connect(self.merge_tags) m.actions()[1].setIcon(QIcon(I('merge.png'))) self.widgets[field] = Widgets(neww, oldw, newl, button) for i, w in enumerate((newl, neww, button, oldw)): c = i if i < 2 else i + 1 if w is oldw: c += 1 l.addWidget(w, row, c) row += 1 if 'comments' in self.widgets and not gprefs.get( 'diff_widget_show_comments_controls', True): self.widgets['comments'].new.hide_toolbars() def save_comments_controls_state(self): if 'comments' in self.widgets: vis = self.widgets['comments'].new.toolbars_visible if vis != gprefs.get('diff_widget_show_comments_controls', True): gprefs.set('diff_widget_show_comments_controls', vis) def changed(self, field): w = self.widgets[field] if not w.new.same_as(w.old) and (not self.blank_as_equal or not w.new.is_blank): w.label.setFont(self.changed_font) else: w.label.setFont(QApplication.font()) def revert(self, field): widgets = self.widgets[field] neww, oldw = widgets[:2] if hasattr(neww, 'set_undoable'): neww.set_undoable(oldw.current_val) else: neww.current_val = oldw.current_val def merge_identifiers(self): widgets = self.widgets['identifiers'] neww, oldw = widgets[:2] val = neww.as_dict val.update(oldw.as_dict) neww.as_dict = val def merge_tags(self): widgets = self.widgets['tags'] neww, oldw = widgets[:2] val = oldw.value lval = {icu_lower(x) for x in val} extra = [x for x in neww.value if icu_lower(x) not in lval] if extra: neww.value = val + extra def __call__(self, oldmi, newmi): self.current_mi = newmi self.initial_vals = {} for field, widgets in iteritems(self.widgets): widgets.old.from_mi(oldmi) widgets.new.from_mi(newmi) self.initial_vals[field] = widgets.new.current_val def apply_changes(self): changed = False for field, widgets in iteritems(self.widgets): val = widgets.new.current_val if val != self.initial_vals[field]: widgets.new.to_mi(self.current_mi) changed = True return changed
class EmailAccounts(QAbstractTableModel): # {{{ def __init__(self, accounts, subjects, aliases={}, tags={}): QAbstractTableModel.__init__(self) self.accounts = accounts self.subjects = subjects self.aliases = aliases self.tags = tags self.sorted_on = (0, True) self.account_order = list(self.accounts) self.do_sort() self.headers = [ _('Email'), _('Formats'), _('Subject'), _('Auto send'), _('Alias'), _('Auto send only tags') ] self.default_font = QFont() self.default_font.setBold(True) self.default_font = (self.default_font) self.tooltips = [None] + list( map(textwrap.fill, [ _('Formats to email. The first matching format will be sent.'), _('Subject of the email to use when sending. When left blank ' 'the title will be used for the subject. Also, the same ' 'templates used for "Save to disk" such as {title} and ' '{author_sort} can be used here.'), '<p>' + _('If checked, downloaded news will be automatically ' 'mailed to this email address ' '(provided it is in one of the listed formats and has not been filtered by tags).' ), _('Friendly name to use for this email address'), _('If specified, only news with one of these tags will be sent to' ' this email address. All news downloads have their title as a' ' tag, so you can use this to easily control which news downloads' ' are sent to this email address.') ])) def do_sort(self): col = self.sorted_on[0] if col == 0: def key(account_key): return numeric_sort_key(account_key) elif col == 1: def key(account_key): return numeric_sort_key(self.accounts[account_key][0] or '') elif col == 2: def key(account_key): return numeric_sort_key(self.subjects.get(account_key) or '') elif col == 3: def key(account_key): return numeric_sort_key( as_unicode(self.accounts[account_key][0]) or '') elif col == 4: def key(account_key): return numeric_sort_key(self.aliases.get(account_key) or '') elif col == 5: def key(account_key): return numeric_sort_key(self.tags.get(account_key) or '') self.account_order.sort(key=key, reverse=not self.sorted_on[1]) def sort(self, column, order=Qt.SortOrder.AscendingOrder): nsort = (column, order == Qt.SortOrder.AscendingOrder) if nsort != self.sorted_on: self.sorted_on = nsort self.beginResetModel() try: self.do_sort() finally: self.endResetModel() def rowCount(self, *args): return len(self.account_order) def columnCount(self, *args): return len(self.headers) def headerData(self, section, orientation, role): if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: return self.headers[section] return None def data(self, index, role): row, col = index.row(), index.column() if row < 0 or row >= self.rowCount(): return None account = self.account_order[row] if account not in self.accounts: return None if role == Qt.ItemDataRole.UserRole: return (account, self.accounts[account]) if role == Qt.ItemDataRole.ToolTipRole: return self.tooltips[col] if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole]: if col == 0: return (account) if col == 1: return ', '.join( x.strip() for x in (self.accounts[account][0] or '').split(',')) if col == 2: return (self.subjects.get(account, '')) if col == 4: return (self.aliases.get(account, '')) if col == 5: return (self.tags.get(account, '')) if role == Qt.ItemDataRole.FontRole and self.accounts[account][2]: return self.default_font if role == Qt.ItemDataRole.CheckStateRole and col == 3: return (Qt.CheckState.Checked if self.accounts[account][1] else Qt.CheckState.Unchecked) return None def flags(self, index): if index.column() == 3: return QAbstractTableModel.flags( self, index) | Qt.ItemFlag.ItemIsUserCheckable else: return QAbstractTableModel.flags( self, index) | Qt.ItemFlag.ItemIsEditable def setData(self, index, value, role): if not index.isValid(): return False row, col = index.row(), index.column() account = self.account_order[row] if col == 3: self.accounts[account][1] ^= True elif col == 2: self.subjects[account] = as_unicode(value or '') elif col == 4: self.aliases.pop(account, None) aval = as_unicode(value or '').strip() if aval: self.aliases[account] = aval elif col == 5: self.tags.pop(account, None) aval = as_unicode(value or '').strip() if aval: self.tags[account] = aval elif col == 1: self.accounts[account][0] = re.sub( ',+', ',', re.sub(r'\s+', ',', as_unicode(value or '').upper())) elif col == 0: na = as_unicode(value or '') from email.utils import parseaddr addr = parseaddr(na)[-1] if not addr or '@' not in na: return False self.accounts[na] = self.accounts.pop(account) self.account_order[row] = na if '@kindle.com' in addr: self.accounts[na][0] = 'AZW, MOBI, TPZ, PRC, AZW1' self.dataChanged.emit(self.index(index.row(), 0), self.index(index.row(), 3)) return True def make_default(self, index): if index.isValid(): self.beginResetModel() row = index.row() for x in self.accounts.values(): x[2] = False self.accounts[self.account_order[row]][2] = True self.endResetModel() def add(self): x = _('new email address') y = x c = 0 while y in self.accounts: c += 1 y = x + str(c) auto_send = len(self.accounts) < 1 self.beginResetModel() self.accounts[y] = [ 'MOBI, EPUB', auto_send, len(self.account_order) == 0 ] self.account_order = list(self.accounts) self.do_sort() self.endResetModel() return self.index(self.account_order.index(y), 0) def remove_rows(self, *rows): for row in sorted(rows, reverse=True): try: account = self.account_order[row] except Exception: continue self.accounts.pop(account) self.account_order = sorted(self.accounts) has_default = False for account in self.account_order: if self.accounts[account][2]: has_default = True break if not has_default and self.account_order: self.accounts[self.account_order[0]][2] = True self.beginResetModel() self.endResetModel() self.do_sort() def remove(self, index): if index.isValid(): self.remove(index.row())
class TextEdit(PlainTextEdit): link_clicked = pyqtSignal(object) class_clicked = pyqtSignal(object) smart_highlighting_updated = pyqtSignal() def __init__(self, parent=None, expected_geometry=(100, 50)): PlainTextEdit.__init__(self, parent) self.snippet_manager = SnippetManager(self) self.completion_popup = CompletionPopup(self) self.request_completion = self.completion_doc_name = None self.clear_completion_cache_timer = t = QTimer(self) t.setInterval(5000), t.timeout.connect(self.clear_completion_cache), t.setSingleShot(True) self.textChanged.connect(t.start) self.last_completion_request = -1 self.gutter_width = 0 self.tw = 2 self.expected_geometry = expected_geometry self.saved_matches = {} self.syntax = None self.smarts = NullSmarts(self) self.current_cursor_line = None self.current_search_mark = None self.smarts_highlight_timer = t = QTimer() t.setInterval(750), t.setSingleShot(True), t.timeout.connect(self.update_extra_selections) self.highlighter = SyntaxHighlighter() self.line_number_area = LineNumbers(self) self.apply_settings() self.setMouseTracking(True) self.cursorPositionChanged.connect(self.highlight_cursor_line) self.blockCountChanged[int].connect(self.update_line_number_area_width) self.updateRequest.connect(self.update_line_number_area) def get_droppable_files(self, md): def is_mt_ok(mt): return self.syntax == 'html' and ( mt in OEB_DOCS or mt in OEB_STYLES or mt.startswith('image/') ) if md.hasFormat(CONTAINER_DND_MIMETYPE): for line in as_unicode(bytes(md.data(CONTAINER_DND_MIMETYPE))).splitlines(): mt = current_container().mime_map.get(line, 'application/octet-stream') if is_mt_ok(mt): yield line, mt, True return for qurl in md.urls(): if qurl.isLocalFile() and os.access(qurl.toLocalFile(), os.R_OK): path = qurl.toLocalFile() mt = guess_type(path) if is_mt_ok(mt): yield path, mt, False def canInsertFromMimeData(self, md): if md.hasText() or (md.hasHtml() and self.syntax == 'html') or md.hasImage(): return True elif tuple(self.get_droppable_files(md)): return True return False def insertFromMimeData(self, md): files = tuple(self.get_droppable_files(md)) base = self.highlighter.doc_name or None def get_name(name): folder = get_recommended_folders(current_container(), (name,))[name] or '' if folder: folder += '/' return folder + name def get_href(name): return current_container().name_to_href(name, base) def insert_text(text): c = self.textCursor() c.insertText(text) self.setTextCursor(c) self.ensureCursorVisible() def add_file(name, data, mt=None): from calibre.gui2.tweak_book.boss import get_boss name = current_container().add_file(name, data, media_type=mt, modify_name_if_needed=True) get_boss().refresh_file_list() return name if files: for path, mt, is_name in files: if is_name: name = path else: name = get_name(os.path.basename(path)) with lopen(path, 'rb') as f: name = add_file(name, f.read(), mt) href = get_href(name) if mt.startswith('image/'): self.insert_image(href) elif mt in OEB_STYLES: insert_text('<link href="{}" rel="stylesheet" type="text/css"/>'.format(href)) elif mt in OEB_DOCS: self.insert_hyperlink(href, name) self.ensureCursorVisible() return if md.hasImage(): img = md.imageData() if img is not None and not img.isNull(): data = image_to_data(img, fmt='PNG') name = add_file(get_name('dropped_image.png'), data) self.insert_image(get_href(name)) self.ensureCursorVisible() return if md.hasText(): return insert_text(md.text()) if md.hasHtml(): insert_text(md.html()) return @property def is_modified(self): ''' True if the document has been modified since it was loaded or since the last time is_modified was set to False. ''' return self.document().isModified() @is_modified.setter def is_modified(self, val): self.document().setModified(bool(val)) def sizeHint(self): return self.size_hint def apply_settings(self, prefs=None, dictionaries_changed=False): # {{{ prefs = prefs or tprefs self.setAcceptDrops(prefs.get('editor_accepts_drops', True)) self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth if prefs['editor_line_wrap'] else QPlainTextEdit.LineWrapMode.NoWrap) theme = get_theme(prefs['editor_theme']) self.apply_theme(theme) w = self.fontMetrics() self.space_width = w.width(' ') self.tw = self.smarts.override_tab_stop_width if self.smarts.override_tab_stop_width is not None else prefs['editor_tab_stop_width'] self.setTabStopWidth(self.tw * self.space_width) if dictionaries_changed: self.highlighter.rehighlight() def apply_theme(self, theme): self.theme = theme pal = self.palette() pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'Normal', 'bg')) pal.setColor(QPalette.ColorRole.AlternateBase, theme_color(theme, 'CursorLine', 'bg')) pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'Normal', 'fg')) pal.setColor(QPalette.ColorRole.Highlight, theme_color(theme, 'Visual', 'bg')) pal.setColor(QPalette.ColorRole.HighlightedText, theme_color(theme, 'Visual', 'fg')) self.setPalette(pal) self.tooltip_palette = pal = QPalette() pal.setColor(QPalette.ColorRole.ToolTipBase, theme_color(theme, 'Tooltip', 'bg')) pal.setColor(QPalette.ColorRole.ToolTipText, theme_color(theme, 'Tooltip', 'fg')) self.line_number_palette = pal = QPalette() pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'LineNr', 'bg')) pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'LineNr', 'fg')) pal.setColor(QPalette.ColorRole.BrightText, theme_color(theme, 'LineNrC', 'fg')) self.match_paren_format = theme_format(theme, 'MatchParen') font = self.font() ff = tprefs['editor_font_family'] if ff is None: ff = default_font_family() font.setFamily(ff) font.setPointSize(tprefs['editor_font_size']) self.tooltip_font = QFont(font) self.tooltip_font.setPointSize(font.pointSize() - 1) self.setFont(font) self.highlighter.apply_theme(theme) w = self.fontMetrics() self.number_width = max(map(lambda x:w.width(unicode_type(x)), range(10))) self.size_hint = QSize(self.expected_geometry[0] * w.averageCharWidth(), self.expected_geometry[1] * w.height()) self.highlight_color = theme_color(theme, 'HighlightRegion', 'bg') self.highlight_cursor_line() self.completion_popup.clear_caches(), self.completion_popup.update() # }}} def load_text(self, text, syntax='html', process_template=False, doc_name=None): self.syntax = syntax self.highlighter = get_highlighter(syntax)() self.highlighter.apply_theme(self.theme) self.highlighter.set_document(self.document(), doc_name=doc_name) sclass = get_smarts(syntax) if sclass is not None: self.smarts = sclass(self) if self.smarts.override_tab_stop_width is not None: self.tw = self.smarts.override_tab_stop_width self.setTabStopWidth(self.tw * self.space_width) if isinstance(text, bytes): text = text.decode('utf-8', 'replace') self.setPlainText(unicodedata.normalize('NFC', unicode_type(text))) if process_template and QPlainTextEdit.find(self, '%CURSOR%'): c = self.textCursor() c.insertText('') def change_document_name(self, newname): self.highlighter.doc_name = newname self.highlighter.rehighlight() # Ensure links are checked w.r.t. the new name correctly def replace_text(self, text): c = self.textCursor() pos = c.position() c.beginEditBlock() c.clearSelection() c.select(QTextCursor.SelectionType.Document) c.insertText(unicodedata.normalize('NFC', text)) c.endEditBlock() c.setPosition(min(pos, len(text))) self.setTextCursor(c) self.ensureCursorVisible() def simple_replace(self, text, cursor=None): c = cursor or self.textCursor() c.insertText(unicodedata.normalize('NFC', text)) self.setTextCursor(c) def go_to_line(self, lnum, col=None): lnum = max(1, min(self.blockCount(), lnum)) c = self.textCursor() c.clearSelection() c.movePosition(QTextCursor.MoveOperation.Start) c.movePosition(QTextCursor.MoveOperation.NextBlock, n=lnum - 1) c.movePosition(QTextCursor.MoveOperation.StartOfLine) c.movePosition(QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor) text = unicode_type(c.selectedText()).rstrip('\0') if col is None: c.movePosition(QTextCursor.MoveOperation.StartOfLine) lt = text.lstrip() if text and lt and lt != text: c.movePosition(QTextCursor.MoveOperation.NextWord) else: c.setPosition(c.block().position() + col) if c.blockNumber() + 1 > lnum: # We have moved past the end of the line c.setPosition(c.block().position()) c.movePosition(QTextCursor.MoveOperation.EndOfBlock) self.setTextCursor(c) self.ensureCursorVisible() def update_extra_selections(self, instant=True): sel = [] if self.current_cursor_line is not None: sel.append(self.current_cursor_line) if self.current_search_mark is not None: sel.append(self.current_search_mark) if instant and not self.highlighter.has_requests and self.smarts is not None: sel.extend(self.smarts.get_extra_selections(self)) self.smart_highlighting_updated.emit() else: self.smarts_highlight_timer.start() self.setExtraSelections(sel) # Search and replace {{{ def mark_selected_text(self): sel = QTextEdit.ExtraSelection() sel.format.setBackground(self.highlight_color) sel.cursor = self.textCursor() if sel.cursor.hasSelection(): self.current_search_mark = sel c = self.textCursor() c.clearSelection() self.setTextCursor(c) else: self.current_search_mark = None self.update_extra_selections() def find_in_marked(self, pat, wrap=False, save_match=None): if self.current_search_mark is None: return False csm = self.current_search_mark.cursor reverse = pat.flags & regex.REVERSE c = self.textCursor() c.clearSelection() m_start = min(csm.position(), csm.anchor()) m_end = max(csm.position(), csm.anchor()) if c.position() < m_start: c.setPosition(m_start) if c.position() > m_end: c.setPosition(m_end) pos = m_start if reverse else m_end if wrap: pos = m_end if reverse else m_start c.setPosition(pos, QTextCursor.MoveMode.KeepAnchor) raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.search(raw) if m is None: return False start, end = m.span() if start == end: return False if wrap: if reverse: textpos = c.anchor() start, end = textpos + end, textpos + start else: start, end = m_start + start, m_start + end else: if reverse: start, end = m_start + end, m_start + start else: start, end = c.anchor() + start, c.anchor() + end c.clearSelection() c.setPosition(start) c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) # Center search result on screen self.centerCursor() if save_match is not None: self.saved_matches[save_match] = (pat, m) return True def all_in_marked(self, pat, template=None): if self.current_search_mark is None: return 0 c = self.current_search_mark.cursor raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') if template is None: count = len(pat.findall(raw)) else: from calibre.gui2.tweak_book.function_replace import Function repl_is_func = isinstance(template, Function) if repl_is_func: template.init_env() raw, count = pat.subn(template, raw) if repl_is_func: from calibre.gui2.tweak_book.search import show_function_debug_output if getattr(template.func, 'append_final_output_to_marked', False): retval = template.end() if retval: raw += unicode_type(retval) else: template.end() show_function_debug_output(template) if count > 0: start_pos = min(c.anchor(), c.position()) c.insertText(raw) end_pos = max(c.anchor(), c.position()) c.setPosition(start_pos), c.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) self.update_extra_selections() return count def smart_comment(self): from calibre.gui2.tweak_book.editor.comments import smart_comment smart_comment(self, self.syntax) def sort_css(self): from calibre.gui2.dialogs.confirm_delete import confirm if confirm(_('Sorting CSS rules can in rare cases change the effective styles applied to the book.' ' Are you sure you want to proceed?'), 'edit-book-confirm-sort-css', parent=self, config_set=tprefs): c = self.textCursor() c.beginEditBlock() c.movePosition(QTextCursor.MoveOperation.Start), c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) text = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') from calibre.ebooks.oeb.polish.css import sort_sheet text = css_text(sort_sheet(current_container(), text)) c.insertText(text) c.movePosition(QTextCursor.MoveOperation.Start) c.endEditBlock() self.setTextCursor(c) def find(self, pat, wrap=False, marked=False, complete=False, save_match=None): if marked: return self.find_in_marked(pat, wrap=wrap, save_match=save_match) reverse = pat.flags & regex.REVERSE c = self.textCursor() c.clearSelection() if complete: # Search the entire text c.movePosition(QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start) pos = QTextCursor.MoveOperation.Start if reverse else QTextCursor.MoveOperation.End if wrap and not complete: pos = QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start c.movePosition(pos, QTextCursor.MoveMode.KeepAnchor) raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.search(raw) if m is None: return False start, end = m.span() if start == end: return False if wrap and not complete: if reverse: textpos = c.anchor() start, end = textpos + end, textpos + start else: if reverse: # Put the cursor at the start of the match start, end = end, start else: textpos = c.anchor() start, end = textpos + start, textpos + end c.clearSelection() c.setPosition(start) c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) # Center search result on screen self.centerCursor() if save_match is not None: self.saved_matches[save_match] = (pat, m) return True def find_text(self, pat, wrap=False, complete=False): reverse = pat.flags & regex.REVERSE c = self.textCursor() c.clearSelection() if complete: # Search the entire text c.movePosition(QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start) pos = QTextCursor.MoveOperation.Start if reverse else QTextCursor.MoveOperation.End if wrap and not complete: pos = QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start c.movePosition(pos, QTextCursor.MoveMode.KeepAnchor) if hasattr(self.smarts, 'find_text'): self.highlighter.join() found, start, end = self.smarts.find_text(pat, c, reverse) if not found: return False else: raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.search(raw) if m is None: return False start, end = m.span() if start == end: return False if reverse: start, end = end, start c.clearSelection() c.setPosition(start) c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) # Center search result on screen self.centerCursor() return True def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True): c = self.textCursor() c.setPosition(c.position()) if not from_cursor: c.movePosition(QTextCursor.MoveOperation.Start) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) def find_first_word(haystack): match_pos, match_word = -1, None for w in original_words: idx = index_of(w, haystack, lang=lang) if idx > -1 and (match_pos == -1 or match_pos > idx): match_pos, match_word = idx, w return match_pos, match_word while True: text = unicode_type(c.selectedText()).rstrip('\0') idx, word = find_first_word(text) if idx == -1: return False c.setPosition(c.anchor() + idx) c.setPosition(c.position() + string_length(word), QTextCursor.MoveMode.KeepAnchor) if self.smarts.verify_for_spellcheck(c, self.highlighter): self.highlighter.join() # Ensure highlighting is finished locale = self.spellcheck_locale_for_cursor(c) if not lang or not locale or (locale and lang == locale.langcode): self.setTextCursor(c) if center_on_cursor: self.centerCursor() return True c.setPosition(c.position()) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) return False def find_next_spell_error(self, from_cursor=True): c = self.textCursor() if not from_cursor: c.movePosition(QTextCursor.MoveOperation.Start) block = c.block() while block.isValid(): for r in block.layout().additionalFormats(): if r.format.property(SPELL_PROPERTY): if not from_cursor or block.position() + r.start + r.length > c.position(): c.setPosition(block.position() + r.start) c.setPosition(c.position() + r.length, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) return True block = block.next() return False def replace(self, pat, template, saved_match='gui'): c = self.textCursor() raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.fullmatch(raw) if m is None: # This can happen if either the user changed the selected text or # the search expression uses lookahead/lookbehind operators. See if # the saved match matches the currently selected text and # use it, if so. if saved_match is not None and saved_match in self.saved_matches: saved_pat, saved = self.saved_matches.pop(saved_match) if saved_pat == pat and saved.group() == raw: m = saved if m is None: return False if callable(template): text = template(m) else: text = m.expand(template) c.insertText(text) return True def go_to_anchor(self, anchor): if anchor is TOP: c = self.textCursor() c.movePosition(QTextCursor.MoveOperation.Start) self.setTextCursor(c) return True base = r'''%%s\s*=\s*['"]{0,1}%s''' % regex.escape(anchor) raw = unicode_type(self.toPlainText()) m = regex.search(base % 'id', raw) if m is None: m = regex.search(base % 'name', raw) if m is not None: c = self.textCursor() c.setPosition(m.start()) self.setTextCursor(c) return True return False # }}} # Line numbers and cursor line {{{ def highlight_cursor_line(self): sel = QTextEdit.ExtraSelection() sel.format.setBackground(self.palette().alternateBase()) sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True) sel.cursor = self.textCursor() sel.cursor.clearSelection() self.current_cursor_line = sel self.update_extra_selections(instant=False) # Update the cursor line's line number in the line number area try: self.line_number_area.update(0, self.last_current_lnum[0], self.line_number_area.width(), self.last_current_lnum[1]) except AttributeError: pass block = self.textCursor().block() top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) height = int(self.blockBoundingRect(block).height()) self.line_number_area.update(0, top, self.line_number_area.width(), height) def update_line_number_area_width(self, block_count=0): self.gutter_width = self.line_number_area_width() self.setViewportMargins(self.gutter_width, 0, 0, 0) def line_number_area_width(self): digits = 1 limit = max(1, self.blockCount()) while limit >= 10: limit /= 10 digits += 1 return 8 + self.number_width * digits def update_line_number_area(self, rect, dy): if dy: self.line_number_area.scroll(0, dy) else: self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height()) if rect.contains(self.viewport().rect()): self.update_line_number_area_width() def resizeEvent(self, ev): QPlainTextEdit.resizeEvent(self, ev) cr = self.contentsRect() self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())) def paint_line_numbers(self, ev): painter = QPainter(self.line_number_area) painter.fillRect(ev.rect(), self.line_number_palette.color(QPalette.ColorRole.Base)) block = self.firstVisibleBlock() num = block.blockNumber() top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) bottom = top + int(self.blockBoundingRect(block).height()) current = self.textCursor().block().blockNumber() painter.setPen(self.line_number_palette.color(QPalette.ColorRole.Text)) while block.isValid() and top <= ev.rect().bottom(): if block.isVisible() and bottom >= ev.rect().top(): if current == num: painter.save() painter.setPen(self.line_number_palette.color(QPalette.ColorRole.BrightText)) f = QFont(self.font()) f.setBold(True) painter.setFont(f) self.last_current_lnum = (top, bottom - top) painter.drawText(0, top, self.line_number_area.width() - 5, self.fontMetrics().height(), Qt.AlignmentFlag.AlignRight, unicode_type(num + 1)) if current == num: painter.restore() block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) num += 1 # }}} def override_shortcut(self, ev): # Let the global cut/copy/paste/undo/redo shortcuts work, this avoids the nbsp # problem as well, since they use the overridden createMimeDataFromSelection() method # instead of the one from Qt (which makes copy() work), and allows proper customization # of the shortcuts if ev in ( QKeySequence.StandardKey.Copy, QKeySequence.StandardKey.Cut, QKeySequence.StandardKey.Paste, QKeySequence.StandardKey.Undo, QKeySequence.StandardKey.Redo ): ev.ignore() return True # This is used to convert typed hex codes into unicode # characters if ev.key() == Qt.Key.Key_X and ev.modifiers() == Qt.KeyboardModifier.AltModifier: ev.accept() return True return PlainTextEdit.override_shortcut(self, ev) def text_for_range(self, block, r): c = self.textCursor() c.setPosition(block.position() + r.start) c.setPosition(c.position() + r.length, QTextCursor.MoveMode.KeepAnchor) return unicode_type(c.selectedText()) def spellcheck_locale_for_cursor(self, c): with store_locale: formats = self.highlighter.parse_single_block(c.block())[0] pos = c.positionInBlock() for r in formats: if r.start <= pos <= r.start + r.length and r.format.property(SPELL_PROPERTY): return r.format.property(SPELL_LOCALE_PROPERTY) def recheck_word(self, word, locale): c = self.textCursor() c.movePosition(QTextCursor.MoveOperation.Start) block = c.block() while block.isValid(): for r in block.layout().additionalFormats(): if r.format.property(SPELL_PROPERTY) and self.text_for_range(block, r) == word: self.highlighter.reformat_block(block) break block = block.next() # Tooltips {{{ def syntax_range_for_cursor(self, cursor): if cursor.isNull(): return pos = cursor.positionInBlock() for r in cursor.block().layout().additionalFormats(): if r.start <= pos <= r.start + r.length and r.format.property(SYNTAX_PROPERTY): return r def show_tooltip(self, ev): c = self.cursorForPosition(ev.pos()) fmt_range = self.syntax_range_for_cursor(c) fmt = getattr(fmt_range, 'format', None) if fmt is not None: tt = unicode_type(fmt.toolTip()) if tt: QToolTip.setFont(self.tooltip_font) QToolTip.setPalette(self.tooltip_palette) QToolTip.showText(ev.globalPos(), textwrap.fill(tt)) return QToolTip.hideText() ev.ignore() # }}} def link_for_position(self, pos): c = self.cursorForPosition(pos) r = self.syntax_range_for_cursor(c) if r is not None and r.format.property(LINK_PROPERTY): return self.text_for_range(c.block(), r) def class_for_position(self, pos): c = self.cursorForPosition(pos) r = self.syntax_range_for_cursor(c) if r is not None and r.format.property(CLASS_ATTRIBUTE_PROPERTY): c.select(QTextCursor.SelectionType.WordUnderCursor) class_name = c.selectedText() if class_name: tags = self.current_tag(for_position_sync=False, cursor=c) return {'class': class_name, 'sourceline_address': tags} def mousePressEvent(self, ev): if self.completion_popup.isVisible() and not self.completion_popup.rect().contains(ev.pos()): # For some reason using eventFilter for this does not work, so we # implement it here self.completion_popup.abort() if ev.modifiers() & Qt.Modifier.CTRL: url = self.link_for_position(ev.pos()) if url is not None: ev.accept() self.link_clicked.emit(url) return class_data = self.class_for_position(ev.pos()) if class_data is not None: ev.accept() self.class_clicked.emit(class_data) return return PlainTextEdit.mousePressEvent(self, ev) def get_range_inside_tag(self): c = self.textCursor() left = min(c.anchor(), c.position()) right = max(c.anchor(), c.position()) # For speed we use QPlainTextEdit's toPlainText as we dont care about # spaces in this context raw = unicode_type(QPlainTextEdit.toPlainText(self)) # Make sure the left edge is not within a <> gtpos = raw.find('>', left) ltpos = raw.find('<', left) if gtpos < ltpos: left = gtpos + 1 if gtpos > -1 else left right = max(left, right) if right != left: gtpos = raw.find('>', right) ltpos = raw.find('<', right) if ltpos > gtpos: ltpos = raw.rfind('<', left, right+1) right = max(ltpos, left) return left, right def format_text(self, formatting): if self.syntax != 'html': return if formatting.startswith('justify_'): return self.smarts.set_text_alignment(self, formatting.partition('_')[-1]) color = 'currentColor' if formatting in {'color', 'background-color'}: color = QColorDialog.getColor( QColor(Qt.GlobalColor.black if formatting == 'color' else Qt.GlobalColor.white), self, _('Choose color'), QColorDialog.ColorDialogOption.ShowAlphaChannel) if not color.isValid(): return r, g, b, a = color.getRgb() if a == 255: color = 'rgb(%d, %d, %d)' % (r, g, b) else: color = 'rgba(%d, %d, %d, %.2g)' % (r, g, b, a/255) prefix, suffix = { 'bold': ('<b>', '</b>'), 'italic': ('<i>', '</i>'), 'underline': ('<u>', '</u>'), 'strikethrough': ('<strike>', '</strike>'), 'superscript': ('<sup>', '</sup>'), 'subscript': ('<sub>', '</sub>'), 'color': ('<span style="color: %s">' % color, '</span>'), 'background-color': ('<span style="background-color: %s">' % color, '</span>'), }[formatting] left, right = self.get_range_inside_tag() c = self.textCursor() c.setPosition(left) c.setPosition(right, QTextCursor.MoveMode.KeepAnchor) prev_text = unicode_type(c.selectedText()).rstrip('\0') c.insertText(prefix + prev_text + suffix) if prev_text: right = c.position() c.setPosition(left) c.setPosition(right, QTextCursor.MoveMode.KeepAnchor) else: c.setPosition(c.position() - len(suffix)) self.setTextCursor(c) def insert_image(self, href, fullpage=False, preserve_aspect_ratio=False, width=-1, height=-1): if width <= 0: width = 1200 if height <= 0: height = 1600 c = self.textCursor() template, alt = 'url(%s)', '' left = min(c.position(), c.anchor()) if self.syntax == 'html': left, right = self.get_range_inside_tag() c.setPosition(left) c.setPosition(right, QTextCursor.MoveMode.KeepAnchor) href = prepare_string_for_xml(href, True) if fullpage: template = '''\ <div style="page-break-before:always; page-break-after:always; page-break-inside:avoid">\ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" \ version="1.1" width="100%%" height="100%%" viewBox="0 0 {w} {h}" preserveAspectRatio="{a}">\ <image width="{w}" height="{h}" xlink:href="%s"/>\ </svg></div>'''.format(w=width, h=height, a='xMidYMid meet' if preserve_aspect_ratio else 'none') else: alt = _('Image') template = '<img alt="{0}" src="%s" />'.format(alt) text = template % href c.insertText(text) if self.syntax == 'html' and not fullpage: c.setPosition(left + 10) c.setPosition(c.position() + len(alt), QTextCursor.MoveMode.KeepAnchor) else: c.setPosition(left) c.setPosition(left + len(text), QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) def insert_hyperlink(self, target, text, template=None): if hasattr(self.smarts, 'insert_hyperlink'): self.smarts.insert_hyperlink(self, target, text, template=template) def insert_tag(self, tag): if hasattr(self.smarts, 'insert_tag'): self.smarts.insert_tag(self, tag) def remove_tag(self): if hasattr(self.smarts, 'remove_tag'): self.smarts.remove_tag(self) def split_tag(self): if hasattr(self.smarts, 'split_tag'): self.smarts.split_tag(self) def keyPressEvent(self, ev): if ev.key() == Qt.Key.Key_X and ev.modifiers() == Qt.KeyboardModifier.AltModifier: if self.replace_possible_unicode_sequence(): ev.accept() return if ev.key() == Qt.Key.Key_Insert: self.setOverwriteMode(self.overwriteMode() ^ True) ev.accept() return if self.snippet_manager.handle_key_press(ev): self.completion_popup.hide() return if self.smarts.handle_key_press(ev, self): self.handle_keypress_completion(ev) return QPlainTextEdit.keyPressEvent(self, ev) self.handle_keypress_completion(ev) def handle_keypress_completion(self, ev): if self.request_completion is None: return code = ev.key() if code in ( 0, Qt.Key.Key_unknown, Qt.Key.Key_Shift, Qt.Key.Key_Control, Qt.Key.Key_Alt, Qt.Key.Key_Meta, Qt.Key.Key_AltGr, Qt.Key.Key_CapsLock, Qt.Key.Key_NumLock, Qt.Key.Key_ScrollLock, Qt.Key.Key_Up, Qt.Key.Key_Down): # We ignore up/down arrow so as to not break scrolling through the # text with the arrow keys return result = self.smarts.get_completion_data(self, ev) if result is None: self.last_completion_request += 1 else: self.last_completion_request = self.request_completion(*result) self.completion_popup.mark_completion(self, None if result is None else result[-1]) def handle_completion_result(self, result): if result.request_id[0] >= self.last_completion_request: self.completion_popup.handle_result(result) def clear_completion_cache(self): if self.request_completion is not None and self.completion_doc_name: self.request_completion(None, 'file:' + self.completion_doc_name) def replace_possible_unicode_sequence(self): c = self.textCursor() has_selection = c.hasSelection() if has_selection: text = unicode_type(c.selectedText()).rstrip('\0') else: c.setPosition(c.position() - min(c.positionInBlock(), 6), QTextCursor.MoveMode.KeepAnchor) text = unicode_type(c.selectedText()).rstrip('\0') m = re.search(r'[a-fA-F0-9]{2,6}$', text) if m is None: return False text = m.group() try: num = int(text, 16) except ValueError: return False if num > 0x10ffff or num < 1: return False end_pos = max(c.anchor(), c.position()) c.setPosition(end_pos - len(text)), c.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) c.insertText(safe_chr(num)) return True def select_all(self): c = self.textCursor() c.clearSelection() c.setPosition(0) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) def rename_block_tag(self, new_name): if hasattr(self.smarts, 'rename_block_tag'): self.smarts.rename_block_tag(self, new_name) def current_tag(self, for_position_sync=True, cursor=None): use_matched_tag = False if cursor is None: use_matched_tag = True cursor = self.textCursor() return self.smarts.cursor_position_with_sourceline(cursor, for_position_sync=for_position_sync, use_matched_tag=use_matched_tag) def goto_sourceline(self, sourceline, tags, attribute=None): return self.smarts.goto_sourceline(self, sourceline, tags, attribute=attribute) def get_tag_contents(self): c = self.smarts.get_inner_HTML(self) if c is not None: return self.selected_text_from_cursor(c) def goto_css_rule(self, rule_address, sourceline_address=None): from calibre.gui2.tweak_book.editor.smarts.css import find_rule block = None if self.syntax == 'css': raw = unicode_type(self.toPlainText()) line, col = find_rule(raw, rule_address) if line is not None: block = self.document().findBlockByNumber(line - 1) elif sourceline_address is not None: sourceline, tags = sourceline_address if self.goto_sourceline(sourceline, tags): c = self.textCursor() c.setPosition(c.position() + 1) self.setTextCursor(c) raw = self.get_tag_contents() line, col = find_rule(raw, rule_address) if line is not None: block = self.document().findBlockByNumber(c.blockNumber() + line - 1) if block is not None and block.isValid(): c = self.textCursor() c.setPosition(block.position() + col) self.setTextCursor(c) def change_case(self, action, cursor=None): cursor = cursor or self.textCursor() text = self.selected_text_from_cursor(cursor) text = {'lower':lower, 'upper':upper, 'capitalize':capitalize, 'title':titlecase, 'swap':swapcase}[action](text) cursor.insertText(text) self.setTextCursor(cursor)
class Results(QTreeWidget): # {{{ show_search_result = pyqtSignal(object) current_result_changed = pyqtSignal(object) count_changed = pyqtSignal(object) def __init__(self, parent=None): QTreeWidget.__init__(self, parent) self.setHeaderHidden(True) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.delegate = ResultsDelegate(self) self.setItemDelegate(self.delegate) self.itemClicked.connect(self.item_activated) self.blank_icon = QIcon(I('blank.png')) self.not_found_icon = QIcon(I('dialog_warning.png')) self.currentItemChanged.connect(self.current_item_changed) self.section_font = QFont(self.font()) self.section_font.setItalic(True) self.section_map = {} self.search_results = [] self.item_map = {} self.gesture_manager = GestureManager(self) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) def viewportEvent(self, ev): try: ret = self.gesture_manager.handle_event(ev) except AttributeError: ret = None if ret is not None: return ret return super().viewportEvent(ev) def current_item_changed(self, current, previous): if current is not None: r = current.data(0, SEARCH_RESULT_ROLE) if isinstance(r, SearchResult): self.current_result_changed.emit(r) else: self.current_result_changed.emit(None) def add_result(self, result): section_title = _('Unknown') section_id = -1 toc_nodes = getattr(result, 'toc_nodes', ()) or () if toc_nodes: section_title = toc_nodes[-1].get('title') or _('Unknown') section_id = toc_nodes[-1].get('id') if section_id is None: section_id = -1 section_key = section_id section = self.section_map.get(section_key) spine_idx = getattr(result, 'spine_idx', -1) if section is None: section = QTreeWidgetItem([section_title], 1) section.setFlags(Qt.ItemFlag.ItemIsEnabled) section.setFont(0, self.section_font) section.setData(0, SPINE_IDX_ROLE, spine_idx) lines = [] for i, node in enumerate(toc_nodes): lines.append('\xa0\xa0' * i + '➤ ' + (node.get('title') or _('Unknown'))) if lines: tt = ngettext('Table of Contents section:', 'Table of Contents sections:', len(lines)) tt += '\n' + '\n'.join(lines) section.setToolTip(0, tt) self.section_map[section_key] = section for s in range(self.topLevelItemCount()): ti = self.topLevelItem(s) if ti.data(0, SPINE_IDX_ROLE) > spine_idx: self.insertTopLevelItem(s, section) break else: self.addTopLevelItem(section) section.setExpanded(True) item = QTreeWidgetItem(section, [' '], 2) item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) item.setData(0, SEARCH_RESULT_ROLE, result) item.setData(0, RESULT_NUMBER_ROLE, len(self.search_results)) item.setData(0, SPINE_IDX_ROLE, spine_idx) if isinstance(result, SearchResult): tt = '<p>…' + escape(result.before, False) + '<b>' + escape( result.text, False) + '</b>' + escape(result.after, False) + '…' item.setData(0, Qt.ItemDataRole.ToolTipRole, tt) item.setIcon(0, self.blank_icon) self.item_map[len(self.search_results)] = item self.search_results.append(result) n = self.number_of_results self.count_changed.emit(n) def item_activated(self): i = self.currentItem() if i: sr = i.data(0, SEARCH_RESULT_ROLE) if isinstance(sr, SearchResult): if not sr.is_hidden: self.show_search_result.emit(sr) def find_next(self, previous): if self.number_of_results < 1: return item = self.currentItem() if item is None: return i = int(item.data(0, RESULT_NUMBER_ROLE)) i += -1 if previous else 1 i %= self.number_of_results self.setCurrentItem(self.item_map[i]) self.item_activated() def search_result_not_found(self, sr): for i in range(self.number_of_results): item = self.item_map[i] r = item.data(0, SEARCH_RESULT_ROLE) if r.is_result(sr): r.is_hidden = True item.setIcon(0, self.not_found_icon) break def search_result_discovered(self, sr): q = sr['result_num'] for i in range(self.number_of_results): item = self.item_map[i] r = item.data(0, SEARCH_RESULT_ROLE) if r.result_num == q: self.setCurrentItem(item) @property def current_result_is_hidden(self): item = self.currentItem() if item is not None: sr = item.data(0, SEARCH_RESULT_ROLE) if isinstance(sr, SearchResult) and sr.is_hidden: return True return False @property def number_of_results(self): return len(self.search_results) def clear_all_results(self): self.section_map = {} self.item_map = {} self.search_results = [] self.clear() self.count_changed.emit(-1) def select_first_result(self): if self.number_of_results: item = self.item_map[0] self.setCurrentItem(item) def ensure_current_result_visible(self): item = self.currentItem() if item is not None: self.scrollToItem(item)
def family_setter(which, w, val): w.setCurrentFont(QFont(val or default_font(which)))
def layout_text(prefs, img, title, subtitle, footer, max_height, style): width = img.width() - 2 * style.hmargin title, subtitle, footer = title, subtitle, footer title_font = QFont(prefs.title_font_family or 'Liberation Serif') title_font.setPixelSize(prefs.title_font_size) title_font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) title_block = Block(title, width, title_font, img, max_height, style.TITLE_ALIGN) title_block.position = style.hmargin, style.vmargin subtitle_block = Block() if subtitle: subtitle_font = QFont(prefs.subtitle_font_family or 'Liberation Sans') subtitle_font.setPixelSize(prefs.subtitle_font_size) subtitle_font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) gap = 2 * title_block.leading mh = max_height - title_block.height - gap subtitle_block = Block(subtitle, width, subtitle_font, img, mh, style.SUBTITLE_ALIGN) subtitle_block.position = style.hmargin, title_block.position.y + title_block.height + gap footer_font = QFont(prefs.footer_font_family or 'Liberation Serif') footer_font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) footer_font.setPixelSize(prefs.footer_font_size) footer_block = Block(footer, width, footer_font, img, max_height, style.FOOTER_ALIGN) footer_block.position = style.hmargin, img.height() - style.vmargin - footer_block.height return title_block, subtitle_block, footer_block
def do_size_hint(self, option, index): text = index.data(Qt.ItemDataRole.DisplayRole) or '' font = QFont(option.font) font.setPointSize(QFontInfo(font).pointSize() * 1.5) m = QFontMetrics(font) return QSize(m.width(text), m.height())
def clear_button(self, which): b = getattr(self, 'button%d' % which) s = getattr(self, 'shortcut%d' % which, None) b.setText(_('None') if s is None else s.toString(QKeySequence.SequenceFormat.NativeText)) b.setFont(QFont())
def mark_item_as_current(self, item): font = QFont(self.font()) font.setItalic(True) font.setBold(True) item.setData(0, Qt.ItemDataRole.FontRole, font)
class Highlights(QTreeWidget): jump_to_highlight = pyqtSignal(object) current_highlight_changed = pyqtSignal(object) delete_requested = pyqtSignal() edit_requested = pyqtSignal() edit_notes_requested = pyqtSignal() def __init__(self, parent=None): QTreeWidget.__init__(self, parent) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.default_decoration = QIcon(I('blank.png')) self.setHeaderHidden(True) self.num_of_items = 0 self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) set_no_activate_on_click(self) self.itemActivated.connect(self.item_activated) self.currentItemChanged.connect(self.current_item_changed) self.uuid_map = {} self.section_font = QFont(self.font()) self.section_font.setItalic(True) self.gesture_manager = GestureManager(self) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) def viewportEvent(self, ev): try: ret = self.gesture_manager.handle_event(ev) except AttributeError: ret = None if ret is not None: return ret return super().viewportEvent(ev) def show_context_menu(self, point): index = self.indexAt(point) h = index.data(Qt.ItemDataRole.UserRole) self.context_menu = m = QMenu(self) if h is not None: m.addAction(QIcon(I('edit_input.png')), _('Modify this highlight'), self.edit_requested.emit) m.addAction(QIcon(I('modified.png')), _('Edit notes for this highlight'), self.edit_notes_requested.emit) m.addAction(QIcon(I('trash.png')), ngettext( 'Delete this highlight', 'Delete selected highlights', len(self.selectedItems()) ), self.delete_requested.emit) m.addSeparator() m.addAction(QIcon.ic('plus.png'), _('Expand all'), self.expandAll) m.addAction(QIcon.ic('minus.png'), _('Collapse all'), self.collapseAll) self.context_menu.popup(self.mapToGlobal(point)) return True def current_item_changed(self, current, previous): self.current_highlight_changed.emit(current.data(0, Qt.ItemDataRole.UserRole) if current is not None else None) def load(self, highlights, preserve_state=False): s = self.style() expanded_chapters = set() if preserve_state: root = self.invisibleRootItem() for i in range(root.childCount()): chapter = root.child(i) if chapter.isExpanded(): expanded_chapters.add(chapter.data(0, Qt.ItemDataRole.DisplayRole)) icon_size = s.pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, None, self) dpr = self.devicePixelRatioF() is_dark = is_dark_theme() self.clear() self.uuid_map = {} highlights = (h for h in highlights if not h.get('removed') and h.get('highlighted_text')) section_map = defaultdict(list) section_tt_map = {} for h in self.sorted_highlights(highlights): tfam = h.get('toc_family_titles') or () if tfam: tsec = tfam[0] lsec = tfam[-1] else: tsec = h.get('top_level_section_title') lsec = h.get('lowest_level_section_title') sec = lsec or tsec or _('Unknown') if len(tfam) > 1: lines = [] for i, node in enumerate(tfam): lines.append('\xa0\xa0' * i + '➤ ' + node) tt = ngettext('Table of Contents section:', 'Table of Contents sections:', len(lines)) tt += '\n' + '\n'.join(lines) section_tt_map[sec] = tt section_map[sec].append(h) for secnum, (sec, items) in enumerate(section_map.items()): section = QTreeWidgetItem([sec], 1) section.setFlags(Qt.ItemFlag.ItemIsEnabled) section.setFont(0, self.section_font) tt = section_tt_map.get(sec) if tt: section.setToolTip(0, tt) self.addTopLevelItem(section) section.setExpanded(not preserve_state or sec in expanded_chapters) for itemnum, h in enumerate(items): txt = h.get('highlighted_text') txt = txt.replace('\n', ' ') if h.get('notes'): txt = '•' + txt if len(txt) > 100: txt = txt[:100] + '…' item = QTreeWidgetItem(section, [txt], 2) item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) item.setData(0, Qt.ItemDataRole.UserRole, h) try: dec = decoration_for_style(self.palette(), h.get('style') or {}, icon_size, dpr, is_dark) except Exception: import traceback traceback.print_exc() dec = None if dec is None: dec = self.default_decoration item.setData(0, Qt.ItemDataRole.DecorationRole, dec) self.uuid_map[h['uuid']] = secnum, itemnum self.num_of_items += 1 def sorted_highlights(self, highlights): def_idx = 999999999999999 defval = def_idx, cfi_sort_key('/99999999') def cfi_key(h): cfi = h.get('start_cfi') si = h.get('spine_index', def_idx) return (si, cfi_sort_key(cfi)) if cfi else defval return sorted(highlights, key=cfi_key) def refresh(self, highlights): h = self.current_highlight self.load(highlights, preserve_state=True) if h is not None: idx = self.uuid_map.get(h['uuid']) if idx is not None: sec_idx, item_idx = idx self.set_current_row(sec_idx, item_idx) def iteritems(self): root = self.invisibleRootItem() for i in range(root.childCount()): sec = root.child(i) for k in range(sec.childCount()): yield sec.child(k) def count(self): return self.num_of_items def find_query(self, query): pat = query.regex items = tuple(self.iteritems()) count = len(items) cr = -1 ch = self.current_highlight if ch: q = ch['uuid'] for i, item in enumerate(items): h = item.data(0, Qt.ItemDataRole.UserRole) if h['uuid'] == q: cr = i if query.backwards: if cr < 0: cr = count indices = chain(range(cr - 1, -1, -1), range(count - 1, cr, -1)) else: if cr < 0: cr = -1 indices = chain(range(cr + 1, count), range(0, cr + 1)) for i in indices: h = items[i].data(0, Qt.ItemDataRole.UserRole) if pat.search(h['highlighted_text']) is not None or pat.search(h.get('notes') or '') is not None: self.set_current_row(*self.uuid_map[h['uuid']]) return True return False def find_annot_id(self, annot_id): q = self.uuid_map.get(annot_id) if q is not None: self.set_current_row(*q) return True return False def set_current_row(self, sec_idx, item_idx): sec = self.topLevelItem(sec_idx) if sec is not None: item = sec.child(item_idx) if item is not None: self.setCurrentItem(item, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) return True return False def item_activated(self, item): h = item.data(0, Qt.ItemDataRole.UserRole) if h is not None: self.jump_to_highlight.emit(h) @property def current_highlight(self): i = self.currentItem() if i is not None: return i.data(0, Qt.ItemDataRole.UserRole) @property def all_highlights(self): for item in self.iteritems(): yield item.data(0, Qt.ItemDataRole.UserRole) @property def selected_highlights(self): for item in self.selectedItems(): yield item.data(0, Qt.ItemDataRole.UserRole) def keyPressEvent(self, ev): if ev.matches(QKeySequence.StandardKey.Delete): self.delete_requested.emit() ev.accept() return if ev.key() == Qt.Key.Key_F2: self.edit_requested.emit() ev.accept() return return super().keyPressEvent(ev)
def font(self, text_style): device_font = text_style.fontfacename in LIBERATION_FONT_MAP try: if device_font: face = self.font_map[text_style.fontfacename] else: face = self.face_map[text_style.fontfacename] except KeyError: # Bad fontfacename field in LRF face = self.font_map['Dutch801 Rm BT Roman'] sz = text_style.fontsize wt = text_style.fontweight style = text_style.fontstyle font = ( face, wt, style, sz, ) if font in self.cache: rfont = self.cache[font] else: italic = font[2] == QFont.Style.StyleItalic rfont = QFont(font[0], font[3], font[1], italic) rfont.setPixelSize(font[3]) rfont.setBold(wt >= 69) self.cache[font] = rfont qfont = rfont if text_style.emplinetype != 'none': qfont = QFont(rfont) qfont.setOverline(text_style.emplineposition == 'before') qfont.setUnderline(text_style.emplineposition == 'after') return qfont
def set_subtitle_font(self, for_ratings=True): if for_ratings: self.setSubtitleFont(QFont(rating_font())) else: self.setSubtitleFont(self.font())
def build_font_obj(self): font_info = qt_app.original_font if self.current_font is None else self.current_font font = QFont(*(font_info[:4])) font.setStretch(font_info[4]) return font
class ResultsList(QTreeWidget): current_result_changed = pyqtSignal(object) open_annotation = pyqtSignal(object, object, object) show_book = pyqtSignal(object, object) delete_requested = pyqtSignal() export_requested = pyqtSignal() edit_annotation = pyqtSignal(object, object) def __init__(self, parent): QTreeWidget.__init__(self, parent) self.setHeaderHidden(True) self.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.delegate = AnnotsResultsDelegate(self) self.setItemDelegate(self.delegate) self.section_font = QFont(self.font()) self.itemDoubleClicked.connect(self.item_activated) self.section_font.setItalic(True) self.currentItemChanged.connect(self.current_item_changed) self.number_of_results = 0 self.item_map = [] def show_context_menu(self, pos): item = self.itemAt(pos) if item is not None: result = item.data(0, Qt.ItemDataRole.UserRole) else: result = None items = self.selectedItems() m = QMenu(self) if isinstance(result, dict): m.addAction(_('Open in viewer'), partial(self.item_activated, item)) m.addAction(_('Show in calibre'), partial(self.show_in_calibre, item)) if result.get('annotation', {}).get('type') == 'highlight': m.addAction(_('Edit notes'), partial(self.edit_notes, item)) if items: m.addSeparator() m.addAction( ngettext('Export selected item', 'Export {} selected items', len(items)).format(len(items)), self.export_requested.emit) m.addAction( ngettext('Delete selected item', 'Delete {} selected items', len(items)).format(len(items)), self.delete_requested.emit) m.addSeparator() m.addAction(_('Expand all'), self.expandAll) m.addAction(_('Collapse all'), self.collapseAll) m.exec(self.mapToGlobal(pos)) def edit_notes(self, item): r = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.edit_annotation.emit(r['id'], r['annotation']) def show_in_calibre(self, item): r = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.show_book.emit(r['book_id'], r['format']) def item_activated(self, item): r = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.open_annotation.emit(r['book_id'], r['format'], r['annotation']) def set_results(self, results, emphasize_text): self.clear() self.delegate.emphasize_text = emphasize_text self.number_of_results = 0 self.item_map = [] book_id_map = {} db = current_db() for result in results: book_id = result['book_id'] if book_id not in book_id_map: book_id_map[book_id] = { 'title': db.field_for('title', book_id), 'matches': [] } book_id_map[book_id]['matches'].append(result) for book_id, entry in book_id_map.items(): section = QTreeWidgetItem([entry['title']], 1) section.setFlags(Qt.ItemFlag.ItemIsEnabled) section.setFont(0, self.section_font) section.setData(0, Qt.ItemDataRole.UserRole, book_id) self.addTopLevelItem(section) section.setExpanded(True) for result in sorted_items(entry['matches']): item = QTreeWidgetItem(section, [' '], 2) self.item_map.append(item) item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) item.setData(0, Qt.ItemDataRole.UserRole, result) item.setData(0, Qt.ItemDataRole.UserRole + 1, self.number_of_results) self.number_of_results += 1 if self.item_map: self.setCurrentItem(self.item_map[0]) def current_item_changed(self, current, previous): if current is not None: r = current.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.current_result_changed.emit(r) else: self.current_result_changed.emit(None) def show_next(self, backwards=False): item = self.currentItem() if item is None: return i = int(item.data(0, Qt.ItemDataRole.UserRole + 1)) i += -1 if backwards else 1 i %= self.number_of_results self.setCurrentItem(self.item_map[i]) @property def selected_annot_ids(self): for item in self.selectedItems(): yield item.data(0, Qt.ItemDataRole.UserRole)['id'] @property def selected_annotations(self): for item in self.selectedItems(): x = item.data(0, Qt.ItemDataRole.UserRole) ans = x['annotation'].copy() for key in ('book_id', 'format'): ans[key] = x[key] yield ans def keyPressEvent(self, ev): if ev.matches(QKeySequence.StandardKey.Delete): self.delete_requested.emit() ev.accept() return if ev.key() == Qt.Key.Key_F2: item = self.currentItem() if item: self.edit_notes(item) ev.accept() return return QTreeWidget.keyPressEvent(self, ev) @property def tree_state(self): ans = {'closed': set()} item = self.currentItem() if item is not None: ans['current'] = item.data(0, Qt.ItemDataRole.UserRole) for item in (self.topLevelItem(i) for i in range(self.topLevelItemCount())): if not item.isExpanded(): ans['closed'].add(item.data(0, Qt.ItemDataRole.UserRole)) return ans @tree_state.setter def tree_state(self, state): closed = state['closed'] for item in (self.topLevelItem(i) for i in range(self.topLevelItemCount())): if item.data(0, Qt.ItemDataRole.UserRole) in closed: item.setExpanded(False) cur = state.get('current') if cur is not None: for item in self.item_map: if item.data(0, Qt.ItemDataRole.UserRole) == cur: self.setCurrentItem(item) break