Example #1
0
class ItemView(QStackedWidget):  # {{{

    add_new_item = pyqtSignal(object, object)
    delete_item = pyqtSignal()
    flatten_item = pyqtSignal()
    go_to_root = pyqtSignal()
    create_from_xpath = pyqtSignal(object, object)
    create_from_links = pyqtSignal()
    create_from_files = pyqtSignal()
    flatten_toc = pyqtSignal()

    def __init__(self, parent, prefs):
        QStackedWidget.__init__(self, parent)
        self.prefs = prefs
        self.setMinimumWidth(250)
        self.root_pane = rp = QWidget(self)
        self.item_pane = ip = QWidget(self)
        self.current_item = None
        sa = QScrollArea(self)
        sa.setWidgetResizable(True)
        sa.setWidget(rp)
        self.addWidget(sa)
        sa = QScrollArea(self)
        sa.setWidgetResizable(True)
        sa.setWidget(ip)
        self.addWidget(sa)

        self.l1 = la = QLabel('<p>' + _(
            'You can edit existing entries in the Table of Contents by clicking them'
            ' in the panel to the left.'
        ) + '<p>' + _(
            'Entries with a green tick next to them point to a location that has '
            'been verified to exist. Entries with a red dot are broken and may need'
            ' to be fixed.'))
        la.setStyleSheet('QLabel { margin-bottom: 20px }')
        la.setWordWrap(True)
        l = rp.l = QVBoxLayout()
        rp.setLayout(l)
        l.addWidget(la)
        self.add_new_to_root_button = b = QPushButton(_('Create a &new entry'))
        b.clicked.connect(self.add_new_to_root)
        l.addWidget(b)
        l.addStretch()

        self.cfmhb = b = QPushButton(_('Generate ToC from &major headings'))
        b.clicked.connect(self.create_from_major_headings)
        b.setToolTip(
            textwrap.fill(
                _('Generate a Table of Contents from the major headings in the book.'
                  ' This will work if the book identifies its headings using HTML'
                  ' heading tags. Uses the <h1>, <h2> and <h3> tags.')))
        l.addWidget(b)
        self.cfmab = b = QPushButton(_('Generate ToC from &all headings'))
        b.clicked.connect(self.create_from_all_headings)
        b.setToolTip(
            textwrap.fill(
                _('Generate a Table of Contents from all the headings in the book.'
                  ' This will work if the book identifies its headings using HTML'
                  ' heading tags. Uses the <h1-6> tags.')))
        l.addWidget(b)

        self.lb = b = QPushButton(_('Generate ToC from &links'))
        b.clicked.connect(self.create_from_links)
        b.setToolTip(
            textwrap.fill(
                _('Generate a Table of Contents from all the links in the book.'
                  ' Links that point to destinations that do not exist in the book are'
                  ' ignored. Also multiple links with the same destination or the same'
                  ' text are ignored.')))
        l.addWidget(b)

        self.cfb = b = QPushButton(_('Generate ToC from &files'))
        b.clicked.connect(self.create_from_files)
        b.setToolTip(
            textwrap.fill(
                _('Generate a Table of Contents from individual files in the book.'
                  ' Each entry in the ToC will point to the start of the file, the'
                  ' text of the entry will be the "first line" of text from the file.'
                  )))
        l.addWidget(b)

        self.xpb = b = QPushButton(_('Generate ToC from &XPath'))
        b.clicked.connect(self.create_from_user_xpath)
        b.setToolTip(
            textwrap.fill(
                _('Generate a Table of Contents from arbitrary XPath expressions.'
                  )))
        l.addWidget(b)

        self.fal = b = QPushButton(_('&Flatten the ToC'))
        b.clicked.connect(self.flatten_toc)
        b.setToolTip(
            textwrap.fill(
                _('Flatten the Table of Contents, putting all entries at the top level'
                  )))
        l.addWidget(b)

        l.addStretch()
        self.w1 = la = QLabel(
            _('<b>WARNING:</b> calibre only supports the '
              'creation of linear ToCs in AZW3 files. In a '
              'linear ToC every entry must point to a '
              'location after the previous entry. If you '
              'create a non-linear ToC it will be '
              'automatically re-arranged inside the AZW3 file.'))
        la.setWordWrap(True)
        l.addWidget(la)

        l = ip.l = QGridLayout()
        ip.setLayout(l)
        la = ip.heading = QLabel('')
        l.addWidget(la, 0, 0, 1, 2)
        la.setWordWrap(True)
        la = ip.la = QLabel(
            _('You can move this entry around the Table of Contents by drag '
              'and drop or using the up and down buttons to the left'))
        la.setWordWrap(True)
        l.addWidget(la, 1, 0, 1, 2)

        # Item status
        ip.hl1 = hl = QFrame()
        hl.setFrameShape(QFrame.Shape.HLine)
        l.addWidget(hl, l.rowCount(), 0, 1, 2)
        self.icon_label = QLabel()
        self.status_label = QLabel()
        self.status_label.setWordWrap(True)
        l.addWidget(self.icon_label, l.rowCount(), 0)
        l.addWidget(self.status_label, l.rowCount() - 1, 1)
        ip.hl2 = hl = QFrame()
        hl.setFrameShape(QFrame.Shape.HLine)
        l.addWidget(hl, l.rowCount(), 0, 1, 2)

        # Edit/remove item
        rs = l.rowCount()
        ip.b1 = b = QPushButton(QIcon(I('edit_input.png')),
                                _('Change the &location this entry points to'),
                                self)
        b.clicked.connect(self.edit_item)
        l.addWidget(b, l.rowCount() + 1, 0, 1, 2)
        ip.b2 = b = QPushButton(QIcon(I('trash.png')), _('&Remove this entry'),
                                self)
        l.addWidget(b, l.rowCount(), 0, 1, 2)
        b.clicked.connect(self.delete_item)
        ip.hl3 = hl = QFrame()
        hl.setFrameShape(QFrame.Shape.HLine)
        l.addWidget(hl, l.rowCount(), 0, 1, 2)
        l.setRowMinimumHeight(rs, 20)

        # Add new item
        rs = l.rowCount()
        ip.b3 = b = QPushButton(QIcon(I('plus.png')),
                                _('New entry &inside this entry'))
        connect_lambda(b.clicked, self, lambda self: self.add_new('inside'))
        l.addWidget(b, l.rowCount() + 1, 0, 1, 2)
        ip.b4 = b = QPushButton(QIcon(I('plus.png')),
                                _('New entry &above this entry'))
        connect_lambda(b.clicked, self, lambda self: self.add_new('before'))
        l.addWidget(b, l.rowCount(), 0, 1, 2)
        ip.b5 = b = QPushButton(QIcon(I('plus.png')),
                                _('New entry &below this entry'))
        connect_lambda(b.clicked, self, lambda self: self.add_new('after'))
        l.addWidget(b, l.rowCount(), 0, 1, 2)
        # Flatten entry
        ip.b3 = b = QPushButton(QIcon(I('heuristics.png')),
                                _('&Flatten this entry'))
        b.clicked.connect(self.flatten_item)
        b.setToolTip(
            _('All children of this entry are brought to the same '
              'level as this entry.'))
        l.addWidget(b, l.rowCount() + 1, 0, 1, 2)

        ip.hl4 = hl = QFrame()
        hl.setFrameShape(QFrame.Shape.HLine)
        l.addWidget(hl, l.rowCount(), 0, 1, 2)
        l.setRowMinimumHeight(rs, 20)

        # Return to welcome
        rs = l.rowCount()
        ip.b4 = b = QPushButton(QIcon(I('back.png')),
                                _('&Return to welcome screen'))
        b.clicked.connect(self.go_to_root)
        b.setToolTip(_('Go back to the top level view'))
        l.addWidget(b, l.rowCount() + 1, 0, 1, 2)

        l.setRowMinimumHeight(rs, 20)

        l.addWidget(QLabel(), l.rowCount(), 0, 1, 2)
        l.setColumnStretch(1, 10)
        l.setRowStretch(l.rowCount() - 1, 10)
        self.w2 = la = QLabel(self.w1.text())
        self.w2.setWordWrap(True)
        l.addWidget(la, l.rowCount(), 0, 1, 2)

    def ask_if_duplicates_should_be_removed(self):
        return not question_dialog(
            self,
            _('Remove duplicates'),
            _('Should headings with the same text at the same level be included?'
              ),
            yes_text=_('&Include duplicates'),
            no_text=_('&Remove duplicates'))

    def create_from_major_headings(self):
        self.create_from_xpath.emit(['//h:h%d' % i for i in range(1, 4)],
                                    self.ask_if_duplicates_should_be_removed())

    def create_from_all_headings(self):
        self.create_from_xpath.emit(['//h:h%d' % i for i in range(1, 7)],
                                    self.ask_if_duplicates_should_be_removed())

    def create_from_user_xpath(self):
        d = XPathDialog(self, self.prefs)
        if d.exec_() == QDialog.DialogCode.Accepted and d.xpaths:
            self.create_from_xpath.emit(d.xpaths,
                                        d.remove_duplicates_cb.isChecked())

    def hide_azw3_warning(self):
        self.w1.setVisible(False), self.w2.setVisible(False)

    def add_new_to_root(self):
        self.add_new_item.emit(None, None)

    def add_new(self, where):
        self.add_new_item.emit(self.current_item, where)

    def edit_item(self):
        self.add_new_item.emit(self.current_item, None)

    def __call__(self, item):
        if item is None:
            self.current_item = None
            self.setCurrentIndex(0)
        else:
            self.current_item = item
            self.setCurrentIndex(1)
            self.populate_item_pane()

    def populate_item_pane(self):
        item = self.current_item
        name = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
        self.item_pane.heading.setText('<h2>%s</h2>' % name)
        self.icon_label.setPixmap(
            item.data(0, Qt.ItemDataRole.DecorationRole).pixmap(32, 32))
        tt = _('This entry points to an existing destination')
        toc = item.data(0, Qt.ItemDataRole.UserRole)
        if toc.dest_exists is False:
            tt = _('The location this entry points to does not exist')
        elif toc.dest_exists is None:
            tt = ''
        self.status_label.setText(tt)

    def data_changed(self, item):
        if item is self.current_item:
            self.populate_item_pane()
Example #2
0
class Editor(QMainWindow):

    has_line_numbers = False

    modification_state_changed = pyqtSignal(object)
    undo_redo_state_changed = pyqtSignal(object, object)
    data_changed = pyqtSignal(object)
    cursor_position_changed = pyqtSignal()  # dummy
    copy_available_state_changed = pyqtSignal(object)

    def __init__(self, syntax, parent=None):
        QMainWindow.__init__(self, parent)
        if parent is None:
            self.setWindowFlags(Qt.WindowType.Widget)

        self.is_synced_to_container = False
        self.syntax = syntax
        self._is_modified = False
        self.copy_available = self.cut_available = False

        self.quality = 90
        self.canvas = Canvas(self)
        self.setCentralWidget(self.canvas)
        self.create_toolbars()

        self.canvas.image_changed.connect(self.image_changed)
        self.canvas.undo_redo_state_changed.connect(self.undo_redo_state_changed)
        self.canvas.selection_state_changed.connect(self.update_clipboard_actions)

    @property
    def is_modified(self):
        return self._is_modified

    @is_modified.setter
    def is_modified(self, val):
        self._is_modified = val
        self.modification_state_changed.emit(val)

    @property
    def current_editing_state(self):
        return {}

    @current_editing_state.setter
    def current_editing_state(self, val):
        pass

    @property
    def undo_available(self):
        return self.canvas.undo_action.isEnabled()

    @property
    def redo_available(self):
        return self.canvas.redo_action.isEnabled()

    @property
    def current_line(self):
        return 0

    @current_line.setter
    def current_line(self, val):
        pass

    @property
    def number_of_lines(self):
        return 0

    def pretty_print(self, name):
        return False

    def change_document_name(self, newname):
        pass

    def get_raw_data(self):
        return self.canvas.get_image_data(quality=self.quality)

    @property
    def data(self):
        return self.get_raw_data()

    @data.setter
    def data(self, val):
        self.canvas.load_image(val)
        self._is_modified = False  # The image_changed signal will have been triggered causing this editor to be incorrectly marked as modified

    def replace_data(self, raw, only_if_different=True):
        # We ignore only_if_different as it is useless in our case, and
        # there is no easy way to check two images for equality
        self.data = raw

    def apply_settings(self, prefs=None, dictionaries_changed=False):
        pass

    def go_to_line(self, *args, **kwargs):
        pass

    def save_state(self):
        for bar in self.bars:
            if bar.isFloating():
                return
        tprefs['image-editor-state'] = bytearray(self.saveState())

    def restore_state(self):
        state = tprefs.get('image-editor-state', None)
        if state is not None:
            self.restoreState(state)

    def set_focus(self):
        self.canvas.setFocus(Qt.FocusReason.OtherFocusReason)

    def undo(self):
        self.canvas.undo_action.trigger()

    def redo(self):
        self.canvas.redo_action.trigger()

    def copy(self):
        self.canvas.copy()

    def cut(self):
        return error_dialog(self, _('Not allowed'), _(
            'Cutting of images is not allowed. If you want to delete the image, use'
            ' the files browser to do it.'), show=True)

    def paste(self):
        self.canvas.paste()

    # Search and replace {{{
    def mark_selected_text(self, *args, **kwargs):
        pass

    def find(self, *args, **kwargs):
        return False

    def replace(self, *args, **kwargs):
        return False

    def all_in_marked(self, *args, **kwargs):
        return 0

    @property
    def selected_text(self):
        return ''
    # }}}

    def image_changed(self, new_image):
        self.is_synced_to_container = False
        self._is_modified = True
        self.copy_available = self.canvas.is_valid
        self.copy_available_state_changed.emit(self.copy_available)
        self.data_changed.emit(self)
        self.modification_state_changed.emit(True)
        self.fmt_label.setText(' ' + (self.canvas.original_image_format or '').upper())
        im = self.canvas.current_image
        self.size_label.setText('{} x {}{}'.format(im.width(), im.height(), ' px'))

    def break_cycles(self):
        self.canvas.break_cycles()
        self.canvas.image_changed.disconnect()
        self.canvas.undo_redo_state_changed.disconnect()
        self.canvas.selection_state_changed.disconnect()

        self.modification_state_changed.disconnect()
        self.undo_redo_state_changed.disconnect()
        self.data_changed.disconnect()
        self.cursor_position_changed.disconnect()
        self.copy_available_state_changed.disconnect()

    def contextMenuEvent(self, ev):
        ev.ignore()

    def create_toolbars(self):
        self.action_bar = b = self.addToolBar(_('File actions tool bar'))
        b.setObjectName('action_bar')  # Needed for saveState
        for x in ('undo', 'redo'):
            b.addAction(getattr(self.canvas, '%s_action' % x))
        self.edit_bar = b = self.addToolBar(_('Edit actions tool bar'))
        b.setObjectName('edit-actions-bar')
        for x in ('copy', 'paste'):
            ac = actions['editor-%s' % x]
            setattr(self, 'action_' + x, b.addAction(ac.icon(), x, getattr(self, x)))
        self.update_clipboard_actions()

        b.addSeparator()
        self.action_trim = ac = b.addAction(QIcon(I('trim.png')), _('Trim image'), self.canvas.trim_image)
        self.action_rotate = ac = b.addAction(QIcon(I('rotate-right.png')), _('Rotate image'), self.canvas.rotate_image)
        self.action_resize = ac = b.addAction(QIcon(I('resize.png')), _('Resize image'), self.resize_image)
        b.addSeparator()
        self.action_filters = ac = b.addAction(QIcon(I('filter.png')), _('Image filters'))
        b.widgetForAction(ac).setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        self.filters_menu = m = QMenu(self)
        ac.setMenu(m)
        m.addAction(_('Auto-trim image'), self.canvas.autotrim_image)
        m.addAction(_('Sharpen image'), self.sharpen_image)
        m.addAction(_('Blur image'), self.blur_image)
        m.addAction(_('De-speckle image'), self.canvas.despeckle_image)
        m.addAction(_('Improve contrast (normalize image)'), self.canvas.normalize_image)
        m.addAction(_('Make image look like an oil painting'), self.oilify_image)

        self.info_bar = b = self.addToolBar(_('Image information bar'))
        b.setObjectName('image_info_bar')
        self.fmt_label = QLabel('')
        b.addWidget(self.fmt_label)
        b.addSeparator()
        self.size_label = QLabel('')
        b.addWidget(self.size_label)
        self.bars = [self.action_bar, self.edit_bar, self.info_bar]
        for x in self.bars:
            x.setFloatable(False)
            x.topLevelChanged.connect(self.toolbar_floated)
            x.setIconSize(QSize(tprefs['toolbar_icon_size'], tprefs['toolbar_icon_size']))
        self.restore_state()

    def toolbar_floated(self, floating):
        if not floating:
            self.save_state()
            for ed in itervalues(editors):
                if ed is not self:
                    ed.restore_state()

    def update_clipboard_actions(self, *args):
        if self.canvas.has_selection:
            self.action_copy.setText(_('Copy selected region'))
            self.action_paste.setText(_('Paste into selected region'))
        else:
            self.action_copy.setText(_('Copy image'))
            self.action_paste.setText(_('Paste image'))

    def resize_image(self):
        im = self.canvas.current_image
        d = ResizeDialog(im.width(), im.height(), self)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.canvas.resize_image(d.width, d.height)

    def sharpen_image(self):
        val, ok = QInputDialog.getInt(self, _('Sharpen image'), _(
            'The standard deviation for the Gaussian sharpen operation (higher means more sharpening)'), value=3, min=1, max=20)
        if ok:
            self.canvas.sharpen_image(sigma=val)

    def blur_image(self):
        val, ok = QInputDialog.getInt(self, _('Blur image'), _(
            'The standard deviation for the Gaussian blur operation (higher means more blurring)'), value=3, min=1, max=20)
        if ok:
            self.canvas.blur_image(sigma=val)

    def oilify_image(self):
        val, ok = QInputDialog.getDouble(self, _('Oilify image'), _(
            'The strength of the operation (higher numbers have larger effects)'), value=4, min=0.1, max=20)
        if ok:
            self.canvas.oilify_image(radius=val)
class BookInfo(HTMLDisplay):

    link_clicked = pyqtSignal(object)
    remove_format = pyqtSignal(int, object)
    remove_item = pyqtSignal(int, object, object)
    save_format = pyqtSignal(int, object)
    restore_format = pyqtSignal(int, object)
    compare_format = pyqtSignal(int, object)
    set_cover_format = pyqtSignal(int, object)
    copy_link = pyqtSignal(object)
    manage_category = pyqtSignal(object, object)
    open_fmt_with = pyqtSignal(int, object, object)
    edit_book = pyqtSignal(int, object)
    edit_identifiers = pyqtSignal()
    find_in_tag_browser = pyqtSignal(object, object)

    def __init__(self, vertical, parent=None):
        HTMLDisplay.__init__(self, parent)
        self.vertical = vertical
        self.anchor_clicked.connect(self.link_activated)
        for x, icon in [
            ('remove_format', 'trash.png'), ('save_format', 'save.png'),
            ('restore_format', 'edit-undo.png'), ('copy_link','edit-copy.png'),
            ('compare_format', 'diff.png'),
            ('set_cover_format', 'default_cover.png'),
            ('find_in_tag_browser', 'search.png')
        ]:
            ac = QAction(QIcon(I(icon)), '', self)
            ac.current_fmt = None
            ac.current_url = None
            ac.triggered.connect(getattr(self, '%s_triggerred'%x))
            setattr(self, '%s_action'%x, ac)
        self.manage_action = QAction(self)
        self.manage_action.current_fmt = self.manage_action.current_url = None
        self.manage_action.triggered.connect(self.manage_action_triggered)
        self.edit_identifiers_action = QAction(QIcon(I('identifiers.png')), _('Edit identifiers for this book'), self)
        self.edit_identifiers_action.triggered.connect(self.edit_identifiers)
        self.remove_item_action = ac = QAction(QIcon(I('minus.png')), '...', self)
        ac.data = (None, None, None)
        ac.triggered.connect(self.remove_item_triggered)
        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.setDefaultStyleSheet(css())

    def refresh_css(self):
        self.setDefaultStyleSheet(css(True))

    def remove_item_triggered(self):
        field, value, book_id = self.remove_item_action.data
        if field and confirm(_('Are you sure you want to delete <b>{}</b> from the book?').format(value), 'book_details_remove_item'):
            self.remove_item.emit(book_id, field, value)

    def context_action_triggered(self, which):
        f = getattr(self, '%s_action'%which).current_fmt
        url = getattr(self, '%s_action'%which).current_url
        if f and 'format' in which:
            book_id, fmt = f
            getattr(self, which).emit(book_id, fmt)
        if url and 'link' in which:
            getattr(self, which).emit(url)

    def remove_format_triggerred(self):
        self.context_action_triggered('remove_format')

    def save_format_triggerred(self):
        self.context_action_triggered('save_format')

    def restore_format_triggerred(self):
        self.context_action_triggered('restore_format')

    def compare_format_triggerred(self):
        self.context_action_triggered('compare_format')

    def set_cover_format_triggerred(self):
        self.context_action_triggered('set_cover_format')

    def copy_link_triggerred(self):
        self.context_action_triggered('copy_link')

    def find_in_tag_browser_triggerred(self):
        if self.find_in_tag_browser_action.current_fmt:
            self.find_in_tag_browser.emit(*self.find_in_tag_browser_action.current_fmt)

    def manage_action_triggered(self):
        if self.manage_action.current_fmt:
            self.manage_category.emit(*self.manage_action.current_fmt)

    def link_activated(self, link):
        if unicode_type(link.scheme()) in ('http', 'https'):
            return safe_open_url(link)
        link = unicode_type(link.toString(NO_URL_FORMATTING))
        self.link_clicked.emit(link)

    def show_data(self, mi):
        html = render_html(mi, self.vertical, self.parent())
        set_html(mi, html, self)

    def mouseDoubleClickEvent(self, ev):
        v = self.viewport()
        if v.rect().contains(self.mapFromGlobal(ev.globalPos())):
            ev.ignore()
        else:
            return HTMLDisplay.mouseDoubleClickEvent(self, ev)

    def contextMenuEvent(self, ev):
        details_context_menu_event(self, ev, self, True)

    def open_with(self, book_id, fmt, entry):
        self.open_fmt_with.emit(book_id, fmt, entry)

    def choose_open_with(self, book_id, fmt):
        from calibre.gui2.open_with import choose_program
        entry = choose_program(fmt, self)
        if entry is not None:
            self.open_with(book_id, fmt, entry)

    def edit_fmt(self, book_id, fmt):
        self.edit_book.emit(book_id, fmt)
Example #4
0
class CoversView(QListView):  # {{{

    chosen = pyqtSignal()

    def __init__(self, current_cover, parent=None):
        QListView.__init__(self, parent)
        self.m = CoversModel(current_cover, self)
        self.setModel(self.m)

        self.setFlow(QListView.Flow.LeftToRight)
        self.setWrapping(True)
        self.setResizeMode(QListView.ResizeMode.Adjust)
        self.setGridSize(QSize(190, 260))
        self.setIconSize(QSize(*CoverDelegate.ICON_SIZE))
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setViewMode(QListView.ViewMode.IconMode)

        self.delegate = CoverDelegate(self)
        self.setItemDelegate(self.delegate)
        self.delegate.needs_redraw.connect(
            self.redraw_spinners, type=Qt.ConnectionType.QueuedConnection)

        self.doubleClicked.connect(self.chosen,
                                   type=Qt.ConnectionType.QueuedConnection)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.show_context_menu)

    def redraw_spinners(self):
        m = self.model()
        for r in range(m.rowCount()):
            idx = m.index(r)
            if bool(m.data(idx, Qt.ItemDataRole.UserRole)):
                m.dataChanged.emit(idx, idx)

    def select(self, num):
        current = self.model().index(num)
        sm = self.selectionModel()
        sm.select(current, QItemSelectionModel.SelectionFlag.SelectCurrent)

    def start(self):
        self.select(0)
        self.delegate.start_animation()

    def stop(self):
        self.delegate.stop_animation()

    def reset_covers(self):
        self.m.reset_covers()

    def clear_failed(self):
        pointer = self.m.pointer_from_index(self.currentIndex())
        self.m.clear_failed()
        if pointer is None:
            self.select(0)
        else:
            self.select(self.m.index_from_pointer(pointer).row())

    def show_context_menu(self, point):
        idx = self.currentIndex()
        if idx and idx.isValid() and not idx.data(Qt.ItemDataRole.UserRole):
            m = QMenu(self)
            m.addAction(QIcon(I('view.png')),
                        _('View this cover at full size'), self.show_cover)
            m.addAction(QIcon(I('edit-copy.png')),
                        _('Copy this cover to clipboard'), self.copy_cover)
            m.exec(QCursor.pos())

    def show_cover(self):
        idx = self.currentIndex()
        pmap = self.model().cover_pixmap(idx)
        if pmap is None and idx.row() == 0:
            pmap = self.model().cc
        if pmap is not None:
            from calibre.gui2.image_popup import ImageView
            d = ImageView(self,
                          pmap,
                          str(idx.data(Qt.ItemDataRole.DisplayRole) or ''),
                          geom_name='metadata_download_cover_popup_geom')
            d(use_exec=True)

    def copy_cover(self):
        idx = self.currentIndex()
        pmap = self.model().cover_pixmap(idx)
        if pmap is None and idx.row() == 0:
            pmap = self.model().cc
        if pmap is not None:
            QApplication.clipboard().setPixmap(pmap)

    def keyPressEvent(self, ev):
        if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
            self.chosen.emit()
            ev.accept()
            return
        return QListView.keyPressEvent(self, ev)
Example #5
0
class SaveTemplate(QWidget, Ui_Form):

    changed_signal = pyqtSignal()

    def __init__(self, *args):
        QWidget.__init__(self, *args)
        Ui_Form.__init__(self)
        self.setupUi(self)
        self.orig_help_text = self.help_label.text()

    def initialize(self, name, default, help, field_metadata):
        variables = sorted(FORMAT_ARG_DESCS.keys())
        if name == 'send_to_device':
            self.help_label.setText(self.orig_help_text + ' ' + _(
                'This setting can be overridden for <b>individual devices</b>,'
                ' by clicking the device icon and choosing "Configure this device".'
            ))
        rows = []
        for var in variables:
            rows.append('<tr><td>%s</td><td>&nbsp;</td><td>%s</td></tr>' %
                        (var, FORMAT_ARG_DESCS[var]))
        rows.append('<tr><td>%s&nbsp;</td><td>&nbsp;</td><td>%s</td></tr>' % (
            _('Any custom field'),
            _('The lookup name of any custom field (these names begin with "#").'
              )))
        table = '<table>%s</table>' % ('\n'.join(rows))
        self.template_variables.setText(table)

        self.field_metadata = field_metadata
        self.opt_template.initialize(name + '_template_history', default, help)
        self.opt_template.editTextChanged.connect(self.changed)
        self.opt_template.currentIndexChanged.connect(self.changed)
        self.option_name = name
        self.open_editor.clicked.connect(self.do_open_editor)

    def do_open_editor(self):
        t = TemplateDialog(self,
                           self.opt_template.text(),
                           fm=self.field_metadata)
        t.setWindowTitle(_('Edit template'))
        if t.exec_():
            self.opt_template.set_value(t.rule[1])

    def changed(self, *args):
        self.changed_signal.emit()

    def validate(self):
        '''
        Do a syntax check on the format string. Doing a semantic check
        (verifying that the fields exist) is not useful in the presence of
        custom fields, because they may or may not exist.
        '''
        tmpl = preprocess_template(self.opt_template.text())
        try:
            t = validation_formatter.validate(tmpl)
            if t.find(validation_formatter._validation_string) < 0:
                return question_dialog(
                    self, _('Constant template'),
                    _('The template contains no {fields}, so all '
                      'books will have the same name. Is this OK?'))
        except Exception as err:
            error_dialog(self,
                         _('Invalid template'),
                         '<p>' + _('The template %s is invalid:') % tmpl +
                         '<br>' + str(err),
                         show=True)
            return False
        return True

    def set_value(self, val):
        self.opt_template.set_value(val)

    def save_settings(self, config, name):
        val = str(self.opt_template.text())
        config.set(name, val)
        self.opt_template.save_history(self.option_name + '_template_history')
Example #6
0
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)

    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(_('Expand all'), self.expandAll)
        m.addAction(_('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):
        defval = 999999999999999, cfi_sort_key('/99999999')

        def cfi_key(h):
            cfi = h.get('start_cfi')
            return (h.get('spine_index') or defval[0],
                    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)
Example #7
0
class ResultsView(QTableView):  # {{{

    show_details_signal = pyqtSignal(object)
    book_selected = pyqtSignal(object)

    def __init__(self, parent=None):
        QTableView.__init__(self, parent)
        self.rt_delegate = RichTextDelegate(self)
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setAlternatingRowColors(True)
        self.setSelectionBehavior(
            QAbstractItemView.SelectionBehavior.SelectRows)
        self.setIconSize(QSize(24, 24))
        self.clicked.connect(self.show_details)
        self.doubleClicked.connect(self.select_index)
        self.setSortingEnabled(True)

    def show_results(self, results):
        self._model = ResultsModel(results, self)
        self.setModel(self._model)
        for i in self._model.HTML_COLS:
            self.setItemDelegateForColumn(i, self.rt_delegate)
        self.resizeRowsToContents()
        self.resizeColumnsToContents()
        self.setFocus(Qt.FocusReason.OtherFocusReason)
        idx = self.model().index(0, 0)
        if idx.isValid() and self.model().rowCount() > 0:
            self.show_details(idx)
            sm = self.selectionModel()
            sm.select(
                idx, QItemSelectionModel.SelectionFlag.ClearAndSelect
                | QItemSelectionModel.SelectionFlag.Rows)

    def resize_delegate(self):
        self.rt_delegate.max_width = int(self.width() / 2.1)
        self.resizeColumnsToContents()

    def resizeEvent(self, ev):
        ret = super().resizeEvent(ev)
        self.resize_delegate()
        return ret

    def currentChanged(self, current, previous):
        ret = QTableView.currentChanged(self, current, previous)
        self.show_details(current)
        return ret

    def show_details(self, index):
        f = rating_font()
        book = self.model().data(index, Qt.ItemDataRole.UserRole)
        parts = [
            '<center>',
            '<h2>%s</h2>' % book.title,
            '<div><i>%s</i></div>' % authors_to_string(book.authors),
        ]
        if not book.is_null('series'):
            series = book.format_field('series')
            if series[1]:
                parts.append('<div>%s: %s</div>' % series)
        if not book.is_null('rating'):
            style = 'style=\'font-family:"%s"\'' % f
            parts.append('<div %s>%s</div>' %
                         (style, rating_to_stars(int(2 * book.rating))))
        parts.append('</center>')
        if book.identifiers:
            urls = urls_from_identifiers(book.identifiers)
            ids = [
                '<a href="%s">%s</a>' % (url, name)
                for name, ign, ign, url in urls
            ]
            if ids:
                parts.append('<div><b>%s:</b> %s</div><br>' %
                             (_('See at'), ', '.join(ids)))
        if book.tags:
            parts.append('<div>%s</div><div>\u00a0</div>' %
                         ', '.join(book.tags))
        if book.comments:
            parts.append(comments_to_html(book.comments))

        self.show_details_signal.emit(''.join(parts))

    def select_index(self, index):
        if self.model() is None:
            return
        if not index.isValid():
            index = self.model().index(0, 0)
        book = self.model().data(index, Qt.ItemDataRole.UserRole)
        self.book_selected.emit(book)

    def get_result(self):
        self.select_index(self.currentIndex())

    def keyPressEvent(self, ev):
        if ev.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right):
            ac = QAbstractItemView.CursorAction.MoveDown if ev.key(
            ) == Qt.Key.Key_Right else QAbstractItemView.CursorAction.MoveUp
            index = self.moveCursor(ac, ev.modifiers())
            if index.isValid() and index != self.currentIndex():
                m = self.selectionModel()
                m.select(
                    index, QItemSelectionModel.SelectionFlag.Select
                    | QItemSelectionModel.SelectionFlag.Current
                    | QItemSelectionModel.SelectionFlag.Rows)
                self.setCurrentIndex(index)
                ev.accept()
                return
        return QTableView.keyPressEvent(self, ev)
Example #8
0
class CharView(QListView):

    show_name = pyqtSignal(object)
    char_selected = pyqtSignal(object)

    def __init__(self, parent=None):
        self.last_mouse_idx = -1
        QListView.__init__(self, parent)
        self._model = CharModel(self)
        self.setModel(self._model)
        self.delegate = CharDelegate(self)
        self.setResizeMode(QListView.ResizeMode.Adjust)
        self.setItemDelegate(self.delegate)
        self.setFlow(QListView.Flow.LeftToRight)
        self.setWrapping(True)
        self.setMouseTracking(True)
        self.setSpacing(2)
        self.setUniformItemSizes(True)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.context_menu)
        self.showing_favorites = False
        set_no_activate_on_click(self)
        self.activated.connect(self.item_activated)
        self.clicked.connect(self.item_activated)

    def item_activated(self, index):
        try:
            char_code = int(self.model().data(index, Qt.ItemDataRole.UserRole))
        except (TypeError, ValueError):
            pass
        else:
            self.char_selected.emit(codepoint_to_chr(char_code))

    def set_allow_drag_and_drop(self, enabled):
        if not enabled:
            self.setDragEnabled(False)
            self.viewport().setAcceptDrops(False)
            self.setDropIndicatorShown(True)
            self._model.allow_dnd = False
        else:
            self.setSelectionMode(
                QAbstractItemView.SelectionMode.ExtendedSelection)
            self.viewport().setAcceptDrops(True)
            self.setDragEnabled(True)
            self.setAcceptDrops(True)
            self.setDropIndicatorShown(False)
            self._model.allow_dnd = True

    def show_chars(self, name, codes):
        self.showing_favorites = name == _('Favorites')
        self._model.beginResetModel()
        self._model.chars = codes
        self._model.endResetModel()
        self.scrollToTop()

    def mouseMoveEvent(self, ev):
        index = self.indexAt(ev.pos())
        if index.isValid():
            row = index.row()
            if row != self.last_mouse_idx:
                self.last_mouse_idx = row
                try:
                    char_code = int(self.model().data(
                        index, Qt.ItemDataRole.UserRole))
                except (TypeError, ValueError):
                    pass
                else:
                    self.show_name.emit(char_code)
            self.setCursor(Qt.CursorShape.PointingHandCursor)
        else:
            self.setCursor(Qt.CursorShape.ArrowCursor)
            self.show_name.emit(-1)
            self.last_mouse_idx = -1
        return QListView.mouseMoveEvent(self, ev)

    def context_menu(self, pos):
        index = self.indexAt(pos)
        if index.isValid():
            try:
                char_code = int(self.model().data(index,
                                                  Qt.ItemDataRole.UserRole))
            except (TypeError, ValueError):
                pass
            else:
                m = QMenu(self)
                m.addAction(
                    QIcon(I('edit-copy.png')),
                    _('Copy %s to clipboard') % codepoint_to_chr(char_code),
                    partial(self.copy_to_clipboard, char_code))
                m.addAction(
                    QIcon(I('rating.png')),
                    (_('Remove %s from favorites')
                     if self.showing_favorites else _('Add %s to favorites')) %
                    codepoint_to_chr(char_code),
                    partial(self.remove_from_favorites, char_code))
                if self.showing_favorites:
                    m.addAction(_('Restore favorites to defaults'),
                                self.restore_defaults)
                m.exec(self.mapToGlobal(pos))

    def restore_defaults(self):
        del tprefs['charmap_favorites']
        self.model().beginResetModel()
        self.model().chars = list(tprefs['charmap_favorites'])
        self.model().endResetModel()

    def copy_to_clipboard(self, char_code):
        c = QApplication.clipboard()
        c.setText(codepoint_to_chr(char_code))

    def remove_from_favorites(self, char_code):
        existing = tprefs['charmap_favorites']
        if not self.showing_favorites:
            if char_code not in existing:
                tprefs['charmap_favorites'] = [char_code] + existing
        elif char_code in existing:
            existing.remove(char_code)
            tprefs['charmap_favorites'] = existing
            self.model().beginResetModel()
            self.model().chars.remove(char_code)
            self.model().endResetModel()
Example #9
0
class Splitter(QSplitter):

    state_changed = pyqtSignal(object)
    reapply_sizes = pyqtSignal(object)

    def __init__(self,
                 name,
                 label,
                 icon,
                 initial_show=True,
                 initial_side_size=120,
                 connect_button=True,
                 orientation=Qt.Orientation.Horizontal,
                 side_index=0,
                 parent=None,
                 shortcut=None,
                 hide_handle_on_single_panel=True):
        QSplitter.__init__(self, parent)
        self.reapply_sizes.connect(self.setSizes,
                                   type=Qt.ConnectionType.QueuedConnection)
        self.hide_handle_on_single_panel = hide_handle_on_single_panel
        if hide_handle_on_single_panel:
            self.state_changed.connect(self.update_handle_width)
        self.original_handle_width = self.handleWidth()
        self.resize_timer = QTimer(self)
        self.resize_timer.setSingleShot(True)
        self.desired_side_size = initial_side_size
        self.desired_show = initial_show
        self.resize_timer.setInterval(5)
        self.resize_timer.timeout.connect(self.do_resize)
        self.setOrientation(orientation)
        self.side_index = side_index
        self._name = name
        self.label = label
        self.initial_side_size = initial_side_size
        self.initial_show = initial_show
        self.splitterMoved.connect(self.splitter_moved,
                                   type=Qt.ConnectionType.QueuedConnection)
        self.button = LayoutButton(icon, label, self, shortcut=shortcut)
        if connect_button:
            self.button.clicked.connect(self.double_clicked)

        if shortcut is not None:
            self.action_toggle = QAction(QIcon(icon),
                                         _('Toggle') + ' ' + label, self)
            self.action_toggle.changed.connect(self.update_shortcut)
            self.action_toggle.triggered.connect(self.toggle_triggered)
            if parent is not None:
                parent.addAction(self.action_toggle)
                if hasattr(parent, 'keyboard'):
                    parent.keyboard.register_shortcut(
                        'splitter %s %s' % (name, label),
                        str(self.action_toggle.text()),
                        default_keys=(shortcut, ),
                        action=self.action_toggle)
                else:
                    self.action_toggle.setShortcut(shortcut)
            else:
                self.action_toggle.setShortcut(shortcut)

    def update_shortcut(self):
        self.button.update_shortcut(self.action_toggle)

    def toggle_triggered(self, *args):
        self.toggle_side_pane()

    def createHandle(self):
        return SplitterHandle(self.orientation(), self)

    def initialize(self):
        for i in range(self.count()):
            h = self.handle(i)
            if h is not None:
                h.splitter_moved()
        self.state_changed.emit(not self.is_side_index_hidden)

    def splitter_moved(self, *args):
        self.desired_side_size = self.side_index_size
        self.state_changed.emit(not self.is_side_index_hidden)

    def update_handle_width(self, not_one_panel):
        self.setHandleWidth(self.original_handle_width if not_one_panel else 0)

    @property
    def is_side_index_hidden(self):
        sizes = list(self.sizes())
        try:
            return sizes[self.side_index] == 0
        except IndexError:
            return True

    @property
    def save_name(self):
        ori = 'horizontal' if self.orientation() == Qt.Orientation.Horizontal \
                else 'vertical'
        return self._name + '_' + ori

    def print_sizes(self):
        if self.count() > 1:
            print(self.save_name,
                  'side:',
                  self.side_index_size,
                  'other:',
                  end=' ')
            print(list(self.sizes())[self.other_index])

    @property
    def side_index_size(self):
        if self.count() < 2:
            return 0
        return self.sizes()[self.side_index]

    @side_index_size.setter
    def side_index_size(self, val):
        if self.count() < 2:
            return
        side_index_hidden = self.is_side_index_hidden
        if val == 0 and not side_index_hidden:
            self.save_state()
        sizes = list(self.sizes())
        for i in range(len(sizes)):
            sizes[i] = val if i == self.side_index else 10
        self.setSizes(sizes)
        sizes = list(self.sizes())
        total = sum(sizes)
        total_needs_adjustment = self.hide_handle_on_single_panel and side_index_hidden
        if total_needs_adjustment:
            total -= self.original_handle_width
        for i in range(len(sizes)):
            sizes[i] = val if i == self.side_index else total - val
        self.setSizes(sizes)
        self.initialize()
        if total_needs_adjustment:
            # the handle visibility and therefore size distribution will change
            # when the event loop ticks
            self.reapply_sizes.emit(sizes)

    def do_resize(self, *args):
        orig = self.desired_side_size
        QSplitter.resizeEvent(self, self._resize_ev)
        if orig > 20 and self.desired_show:
            c = 0
            while abs(self.side_index_size - orig) > 10 and c < 5:
                self.apply_state(self.get_state(), save_desired=False)
                c += 1

    def resizeEvent(self, ev):
        if self.resize_timer.isActive():
            self.resize_timer.stop()
        self._resize_ev = ev
        self.resize_timer.start()

    def get_state(self):
        if self.count() < 2:
            return (False, 200)
        return (self.desired_show, self.desired_side_size)

    def apply_state(self, state, save_desired=True):
        if state[0]:
            self.side_index_size = state[1]
            if save_desired:
                self.desired_side_size = self.side_index_size
        else:
            self.side_index_size = 0
        self.desired_show = state[0]

    def default_state(self):
        return (self.initial_show, self.initial_side_size)

    # Public API {{{

    def update_desired_state(self):
        self.desired_show = not self.is_side_index_hidden

    def save_state(self):
        if self.count() > 1:
            gprefs[self.save_name + '_state'] = self.get_state()

    @property
    def other_index(self):
        return (self.side_index + 1) % 2

    def restore_state(self):
        if self.count() > 1:
            state = gprefs.get(self.save_name + '_state', self.default_state())
            self.apply_state(state, save_desired=False)
            self.desired_side_size = state[1]

    def toggle_side_pane(self, hide=None):
        if hide is None:
            action = 'show' if self.is_side_index_hidden else 'hide'
        else:
            action = 'hide' if hide else 'show'
        getattr(self, action + '_side_pane')()

    def show_side_pane(self):
        if self.count() < 2 or not self.is_side_index_hidden:
            return
        if self.desired_side_size == 0:
            self.desired_side_size = self.initial_side_size
        self.apply_state((True, self.desired_side_size))

    def hide_side_pane(self):
        if self.count() < 2 or self.is_side_index_hidden:
            return
        self.apply_state((False, self.desired_side_size))

    def double_clicked(self, *args):
        self.toggle_side_pane()
Example #10
0
class JobError(QDialog):  # {{{

    WIDTH = 600
    do_pop = pyqtSignal()

    def __init__(self, parent):
        QDialog.__init__(self, parent)
        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
        self.queue = []
        self.do_pop.connect(self.pop, type=Qt.ConnectionType.QueuedConnection)

        self._layout = l = QGridLayout()
        self.setLayout(l)
        self.icon = QIcon(I('dialog_error.png'))
        self.setWindowIcon(self.icon)
        self.icon_widget = Icon(self)
        self.icon_widget.set_icon(self.icon)
        self.msg_label = QLabel('<p>&nbsp;')
        self.msg_label.setStyleSheet('QLabel { margin-top: 1ex; }')
        self.msg_label.setWordWrap(True)
        self.msg_label.setTextFormat(Qt.TextFormat.RichText)
        self.det_msg = QPlainTextEdit(self)
        self.det_msg.setVisible(False)

        self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close,
                                   parent=self)
        self.bb.accepted.connect(self.accept)
        self.bb.rejected.connect(self.reject)
        self.ctc_button = self.bb.addButton(
            _('&Copy to clipboard'), QDialogButtonBox.ButtonRole.ActionRole)
        self.ctc_button.clicked.connect(self.copy_to_clipboard)
        self.retry_button = self.bb.addButton(
            _('&Retry'), QDialogButtonBox.ButtonRole.ActionRole)
        self.retry_button.clicked.connect(self.retry)
        self.retry_func = None
        self.show_det_msg = _('Show &details')
        self.hide_det_msg = _('Hide &details')
        self.det_msg_toggle = self.bb.addButton(
            self.show_det_msg, QDialogButtonBox.ButtonRole.ActionRole)
        self.det_msg_toggle.clicked.connect(self.toggle_det_msg)
        self.det_msg_toggle.setToolTip(
            _('Show detailed information about this error'))
        self.suppress = QCheckBox(self)

        l.addWidget(self.icon_widget, 0, 0, 1, 1)
        l.addWidget(self.msg_label, 0, 1, 1, 1)
        l.addWidget(self.det_msg, 1, 0, 1, 2)
        l.addWidget(self.suppress, 2, 0, 1, 2,
                    Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom)
        l.addWidget(self.bb, 3, 0, 1, 2,
                    Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom)
        l.setColumnStretch(1, 100)

        self.setModal(False)
        self.suppress.setVisible(False)
        self.do_resize()

    def retry(self):
        if self.retry_func is not None:
            self.accept()
            self.retry_func()

    def update_suppress_state(self):
        self.suppress.setText(
            ngettext('Hide the remaining error message',
                     'Hide the {} remaining error messages',
                     len(self.queue)).format(len(self.queue)))
        self.suppress.setVisible(len(self.queue) > 3)
        self.do_resize()

    def copy_to_clipboard(self, *args):
        d = QTextDocument()
        d.setHtml(self.msg_label.text())
        QApplication.clipboard().setText(
            'calibre, version %s (%s, embedded-python: %s)\n%s: %s\n\n%s' %
            (__version__, sys.platform, isfrozen,
             unicode_type(self.windowTitle()), unicode_type(
                 d.toPlainText()), unicode_type(self.det_msg.toPlainText())))
        if hasattr(self, 'ctc_button'):
            self.ctc_button.setText(_('Copied'))

    def toggle_det_msg(self, *args):
        vis = unicode_type(self.det_msg_toggle.text()) == self.hide_det_msg
        self.det_msg_toggle.setText(
            self.show_det_msg if vis else self.hide_det_msg)
        self.det_msg.setVisible(not vis)
        self.do_resize()

    def do_resize(self):
        h = self.sizeHint().height()
        self.setMinimumHeight(0)  # Needed as this gets set if det_msg is shown
        # Needed otherwise re-showing the box after showing det_msg causes the box
        # to not reduce in height
        self.setMaximumHeight(h)
        self.resize(QSize(self.WIDTH, h))

    def showEvent(self, ev):
        ret = QDialog.showEvent(self, ev)
        self.bb.button(QDialogButtonBox.StandardButton.Close).setFocus(
            Qt.FocusReason.OtherFocusReason)
        return ret

    def show_error(self, title, msg, det_msg='', retry_func=None):
        self.queue.append((title, msg, det_msg, retry_func))
        self.update_suppress_state()
        self.pop()

    def pop(self):
        if not self.queue or self.isVisible():
            return
        title, msg, det_msg, retry_func = self.queue.pop(0)
        self.setWindowTitle(title)
        self.msg_label.setText(msg)
        self.det_msg.setPlainText(det_msg)
        self.det_msg.setVisible(False)
        self.det_msg_toggle.setText(self.show_det_msg)
        self.det_msg_toggle.setVisible(True)
        self.suppress.setChecked(False)
        self.update_suppress_state()
        if not det_msg:
            self.det_msg_toggle.setVisible(False)
        self.retry_button.setVisible(retry_func is not None)
        self.retry_func = retry_func
        self.do_resize()
        self.show()

    def done(self, r):
        if self.suppress.isChecked():
            self.queue = []
        QDialog.done(self, r)
        self.do_pop.emit()
Example #11
0
class Widget(QWidget):

    TITLE = _('Unknown')
    ICON = I('config.png')
    HELP = ''
    COMMIT_NAME = None
    # If True, leading and trailing spaces are removed from line and text edit
    # fields
    STRIP_TEXT_FIELDS = True

    changed_signal = pyqtSignal()
    set_help_signal = pyqtSignal(object)

    def __init__(self, parent, options):
        options = list(options)
        QWidget.__init__(self, parent)
        self.setupUi(self)
        self._options = options
        self._name = self.commit_name = self.COMMIT_NAME
        assert self._name is not None
        self._icon = QIcon(self.ICON)
        for name in self._options:
            if not hasattr(self, 'opt_' + name):
                raise Exception('Option %s missing in %s' %
                                (name, self.__class__.__name__))
            self.connect_gui_obj(getattr(self, 'opt_' + name))

    def initialize_options(self, get_option, get_help, db=None, book_id=None):
        '''
        :param get_option: A callable that takes one argument: the option name
        and returns the corresponding OptionRecommendation.
        :param get_help: A callable that takes the option name and return a help
        string.
        '''
        defaults = load_defaults(self._name)
        defaults.merge_recommendations(get_option, OptionRecommendation.LOW,
                                       self._options)

        if db is not None:
            specifics = load_specifics(db, book_id)
            specifics.merge_recommendations(get_option,
                                            OptionRecommendation.HIGH,
                                            self._options,
                                            only_existing=True)
            defaults.update(specifics)

        self.apply_recommendations(defaults)
        self.setup_help(get_help)

        def process_child(child):
            for g in child.children():
                if isinstance(g, QLabel):
                    buddy = g.buddy()
                    if buddy is not None and hasattr(buddy, '_help'):
                        g._help = buddy._help
                        htext = str(buddy.toolTip()).strip()
                        g.setToolTip(htext)
                        g.setWhatsThis(htext)
                        g.__class__.enterEvent = lambda obj, event: self.set_help(
                            getattr(obj, '_help', obj.toolTip()))
                else:
                    process_child(g)

        process_child(self)

    def restore_defaults(self, get_option):
        defaults = GuiRecommendations()
        defaults.merge_recommendations(get_option, OptionRecommendation.LOW,
                                       self._options)
        self.apply_recommendations(defaults)

    def commit_options(self, save_defaults=False):
        recs = self.create_recommendations()
        if save_defaults:
            save_defaults_(self.commit_name, recs)
        return recs

    def create_recommendations(self):
        recs = GuiRecommendations()
        for name in self._options:
            gui_opt = getattr(self, 'opt_' + name, None)
            if gui_opt is None:
                continue
            recs[name] = self.get_value(gui_opt)
        return recs

    def apply_recommendations(self, recs):
        for name, val in recs.items():
            gui_opt = getattr(self, 'opt_' + name, None)
            if gui_opt is None:
                continue
            self.set_value(gui_opt, val)
            if name in getattr(recs, 'disabled_options', []):
                gui_opt.setDisabled(True)

    def get_value(self, g):
        from calibre.gui2.convert.xpath_wizard import XPathEdit
        from calibre.gui2.convert.regex_builder import RegexEdit
        from calibre.gui2.widgets import EncodingComboBox
        ret = self.get_value_handler(g)
        if ret != 'this is a dummy return value, xcswx1avcx4x':
            return ret
        if hasattr(g, 'get_value_for_config'):
            return g.get_value_for_config
        if isinstance(g, (QSpinBox, QDoubleSpinBox)):
            return g.value()
        elif isinstance(g, (QLineEdit, QTextEdit, QPlainTextEdit)):
            func = getattr(g, 'toPlainText', getattr(g, 'text', None))()
            ans = str(func)
            if self.STRIP_TEXT_FIELDS:
                ans = ans.strip()
            if not ans:
                ans = None
            return ans
        elif isinstance(g, QFontComboBox):
            return str(QFontInfo(g.currentFont()).family())
        elif isinstance(g, FontFamilyChooser):
            return g.font_family
        elif isinstance(g, EncodingComboBox):
            ans = str(g.currentText()).strip()
            try:
                codecs.lookup(ans)
            except:
                ans = ''
            if not ans:
                ans = None
            return ans
        elif isinstance(g, QComboBox):
            return str(g.currentText())
        elif isinstance(g, QCheckBox):
            return bool(g.isChecked())
        elif isinstance(g, XPathEdit):
            return g.xpath if g.xpath else None
        elif isinstance(g, RegexEdit):
            return g.regex if g.regex else None
        else:
            raise Exception('Can\'t get value from %s' % type(g))

    def gui_obj_changed(self, gui_obj, *args):
        self.changed_signal.emit()

    def connect_gui_obj(self, g):
        f = partial(self.gui_obj_changed, g)
        try:
            self.connect_gui_obj_handler(g, f)
            return
        except NotImplementedError:
            pass
        from calibre.gui2.convert.xpath_wizard import XPathEdit
        from calibre.gui2.convert.regex_builder import RegexEdit
        if isinstance(g, (QSpinBox, QDoubleSpinBox)):
            g.valueChanged.connect(f)
        elif isinstance(g, (QLineEdit, QTextEdit, QPlainTextEdit)):
            g.textChanged.connect(f)
        elif isinstance(g, QComboBox):
            g.editTextChanged.connect(f)
            g.currentIndexChanged.connect(f)
        elif isinstance(g, QCheckBox):
            g.stateChanged.connect(f)
        elif isinstance(g, (XPathEdit, RegexEdit)):
            g.edit.editTextChanged.connect(f)
            g.edit.currentIndexChanged.connect(f)
        elif isinstance(g, FontFamilyChooser):
            g.family_changed.connect(f)
        else:
            raise Exception('Can\'t connect %s' % type(g))

    def connect_gui_obj_handler(self, gui_obj, slot):
        raise NotImplementedError()

    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), str(g.objectName())))
        self.post_set_value(g, val)

    def set_help(self, msg):
        if msg and getattr(msg, 'strip', lambda: True)():
            try:
                self.set_help_signal.emit(msg)
            except:
                pass

    def setup_help(self, help_provider):
        for name in self._options:
            g = getattr(self, 'opt_' + name, None)
            if g is None:
                continue
            help = help_provider(name)
            if not help:
                continue
            if self.setup_help_handler(g, help):
                continue
            g._help = help
            self.setup_widget_help(g)

    def setup_widget_help(self, g):
        w = textwrap.TextWrapper(80)
        htext = '<div>%s</div>' % prepare_string_for_xml('\n'.join(
            w.wrap(g._help)))
        g.setToolTip(htext)
        g.setWhatsThis(htext)
        g.__class__.enterEvent = lambda obj, event: self.set_help(
            getattr(obj, '_help', obj.toolTip()))

    def set_value_handler(self, g, val):
        'Return True iff you handle setting the value for g'
        return False

    def post_set_value(self, g, val):
        pass

    def get_value_handler(self, g):
        return 'this is a dummy return value, xcswx1avcx4x'

    def post_get_value(self, g):
        pass

    def setup_help_handler(self, g, help):
        return False

    def break_cycles(self):
        self.db = None

    def pre_commit_check(self):
        return True

    def commit(self, save_defaults=False):
        return self.commit_options(save_defaults)

    def config_title(self):
        return self.TITLE

    def config_icon(self):
        return self._icon
Example #12
0
class MessageBox(QDialog):  # {{{

    ERROR = 0
    WARNING = 1
    INFO = 2
    QUESTION = 3

    resize_needed = pyqtSignal()

    def setup_ui(self):
        self.setObjectName("Dialog")
        self.resize(497, 235)
        self.gridLayout = l = QGridLayout(self)
        l.setObjectName("gridLayout")
        self.icon_widget = Icon(self)
        l.addWidget(self.icon_widget)
        self.msg = la = QLabel(self)
        la.setWordWrap(True), la.setMinimumWidth(400)
        la.setOpenExternalLinks(True)
        la.setObjectName("msg")
        l.addWidget(la, 0, 1, 1, 1)
        self.det_msg = dm = QTextBrowser(self)
        dm.setReadOnly(True)
        dm.setObjectName("det_msg")
        l.addWidget(dm, 1, 0, 1, 2)
        self.bb = bb = QDialogButtonBox(self)
        bb.setStandardButtons(QDialogButtonBox.StandardButton.Ok)
        bb.setObjectName("bb")
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)
        l.addWidget(bb, 3, 0, 1, 2)
        self.toggle_checkbox = tc = QCheckBox(self)
        tc.setObjectName("toggle_checkbox")
        l.addWidget(tc, 2, 0, 1, 2)

    def __init__(self,
                 type_,
                 title,
                 msg,
                 det_msg='',
                 q_icon=None,
                 show_copy_button=True,
                 parent=None,
                 default_yes=True,
                 yes_text=None,
                 no_text=None,
                 yes_icon=None,
                 no_icon=None,
                 add_abort_button=False,
                 only_copy_details=False):
        QDialog.__init__(self, parent)
        self.only_copy_details = only_copy_details
        self.aborted = False
        if q_icon is None:
            icon = {
                self.ERROR: 'error',
                self.WARNING: 'warning',
                self.INFO: 'information',
                self.QUESTION: 'question',
            }[type_]
            icon = 'dialog_%s.png' % icon
            self.icon = QIcon(I(icon))
        else:
            self.icon = q_icon if isinstance(q_icon, QIcon) else QIcon(
                I(q_icon))
        self.setup_ui()

        self.setWindowTitle(title)
        self.setWindowIcon(self.icon)
        self.icon_widget.set_icon(self.icon)
        self.msg.setText(msg)
        if det_msg and Qt.mightBeRichText(det_msg):
            self.det_msg.setHtml(det_msg)
        else:
            self.det_msg.setPlainText(det_msg)
        self.det_msg.setVisible(False)
        self.toggle_checkbox.setVisible(False)

        if show_copy_button:
            self.ctc_button = self.bb.addButton(
                _('&Copy to clipboard'),
                QDialogButtonBox.ButtonRole.ActionRole)
            self.ctc_button.clicked.connect(self.copy_to_clipboard)

        self.show_det_msg = _('Show &details')
        self.hide_det_msg = _('Hide &details')
        self.det_msg_toggle = self.bb.addButton(
            self.show_det_msg, QDialogButtonBox.ButtonRole.ActionRole)
        self.det_msg_toggle.clicked.connect(self.toggle_det_msg)
        self.det_msg_toggle.setToolTip(
            _('Show detailed information about this error'))

        self.copy_action = QAction(self)
        self.addAction(self.copy_action)
        self.copy_action.setShortcuts(QKeySequence.StandardKey.Copy)
        self.copy_action.triggered.connect(self.copy_to_clipboard)

        self.is_question = type_ == self.QUESTION
        if self.is_question:
            self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Yes
                                       | QDialogButtonBox.StandardButton.No)
            self.bb.button(QDialogButtonBox.StandardButton.Yes if default_yes
                           else QDialogButtonBox.StandardButton.No).setDefault(
                               True)
            self.default_yes = default_yes
            if yes_text is not None:
                self.bb.button(
                    QDialogButtonBox.StandardButton.Yes).setText(yes_text)
            if no_text is not None:
                self.bb.button(
                    QDialogButtonBox.StandardButton.No).setText(no_text)
            if yes_icon is not None:
                self.bb.button(QDialogButtonBox.StandardButton.Yes).setIcon(
                    yes_icon if isinstance(yes_icon, QIcon
                                           ) else QIcon(I(yes_icon)))
            if no_icon is not None:
                self.bb.button(QDialogButtonBox.StandardButton.No).setIcon(
                    no_icon if isinstance(no_icon, QIcon) else QIcon(I(no_icon)
                                                                     ))
        else:
            self.bb.button(QDialogButtonBox.StandardButton.Ok).setDefault(True)

        if add_abort_button:
            self.bb.addButton(
                QDialogButtonBox.StandardButton.Abort).clicked.connect(
                    self.on_abort)

        if not det_msg:
            self.det_msg_toggle.setVisible(False)

        self.resize_needed.connect(self.do_resize,
                                   type=Qt.ConnectionType.QueuedConnection)
        self.do_resize()

    def on_abort(self):
        self.aborted = True

    def sizeHint(self):
        ans = QDialog.sizeHint(self)
        ans.setWidth(
            max(min(ans.width(), 500),
                self.bb.sizeHint().width() + 100))
        ans.setHeight(min(ans.height(), 500))
        return ans

    def toggle_det_msg(self, *args):
        vis = self.det_msg.isVisible()
        self.det_msg.setVisible(not vis)
        self.det_msg_toggle.setText(
            self.show_det_msg if vis else self.hide_det_msg)
        self.resize_needed.emit()

    def do_resize(self):
        self.resize(self.sizeHint())

    def copy_to_clipboard(self, *args):
        text = self.det_msg.toPlainText()
        if not self.only_copy_details:
            text = f'calibre, version {__version__}\n{self.windowTitle()}: {self.msg.text()}\n\n{text}'
        QApplication.clipboard().setText(text)
        if hasattr(self, 'ctc_button'):
            self.ctc_button.setText(_('Copied'))

    def showEvent(self, ev):
        ret = QDialog.showEvent(self, ev)
        if self.is_question:
            try:
                self.bb.button(QDialogButtonBox.StandardButton.Yes if self.
                               default_yes else QDialogButtonBox.
                               StandardButton.No).setFocus(
                                   Qt.FocusReason.OtherFocusReason)
            except:
                pass  # Buttons were changed
        else:
            self.bb.button(QDialogButtonBox.StandardButton.Ok).setFocus(
                Qt.FocusReason.OtherFocusReason)
        return ret

    def set_details(self, msg):
        if not msg:
            msg = ''
        if Qt.mightBeRichText(msg):
            self.det_msg.setHtml(msg)
        else:
            self.det_msg.setPlainText(msg)
        self.det_msg_toggle.setText(self.show_det_msg)
        self.det_msg_toggle.setVisible(bool(msg))
        self.det_msg.setVisible(False)
        self.resize_needed.emit()
Example #13
0
class TOCView(QWidget):  # {{{

    add_new_item = pyqtSignal(object, object)

    def __init__(self, parent, prefs):
        QWidget.__init__(self, parent)
        self.toc_title = None
        self.prefs = prefs
        l = self.l = QGridLayout()
        self.setLayout(l)
        self.tocw = t = TreeWidget(self)
        self.tocw.edit_item.connect(self.edit_item)
        l.addWidget(t, 0, 0, 7, 3)
        self.up_button = b = QToolButton(self)
        b.setIcon(QIcon(I('arrow-up.png')))
        b.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
        l.addWidget(b, 0, 3)
        b.setToolTip(_('Move current entry up [Ctrl+Up]'))
        b.clicked.connect(self.move_up)

        self.left_button = b = QToolButton(self)
        b.setIcon(QIcon(I('back.png')))
        b.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
        l.addWidget(b, 2, 3)
        b.setToolTip(_('Unindent the current entry [Ctrl+Left]'))
        b.clicked.connect(self.tocw.move_left)

        self.del_button = b = QToolButton(self)
        b.setIcon(QIcon(I('trash.png')))
        b.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
        l.addWidget(b, 3, 3)
        b.setToolTip(_('Remove all selected entries'))
        b.clicked.connect(self.del_items)

        self.right_button = b = QToolButton(self)
        b.setIcon(QIcon(I('forward.png')))
        b.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
        l.addWidget(b, 4, 3)
        b.setToolTip(_('Indent the current entry [Ctrl+Right]'))
        b.clicked.connect(self.tocw.move_right)

        self.down_button = b = QToolButton(self)
        b.setIcon(QIcon(I('arrow-down.png')))
        b.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
        l.addWidget(b, 6, 3)
        b.setToolTip(_('Move current entry down [Ctrl+Down]'))
        b.clicked.connect(self.move_down)
        self.expand_all_button = b = QPushButton(_('&Expand all'))
        col = 7
        l.addWidget(b, col, 0)
        b.clicked.connect(self.tocw.expandAll)
        self.collapse_all_button = b = QPushButton(_('&Collapse all'))
        b.clicked.connect(self.tocw.collapseAll)
        l.addWidget(b, col, 1)
        self.default_msg = _('Double click on an entry to change the text')
        self.hl = hl = QLabel(self.default_msg)
        hl.setSizePolicy(QSizePolicy.Policy.Ignored,
                         QSizePolicy.Policy.Ignored)
        l.addWidget(hl, col, 2, 1, -1)
        self.item_view = i = ItemView(self, self.prefs)
        self.item_view.delete_item.connect(self.delete_current_item)
        i.add_new_item.connect(self.add_new_item)
        i.create_from_xpath.connect(self.create_from_xpath)
        i.create_from_links.connect(self.create_from_links)
        i.create_from_files.connect(self.create_from_files)
        i.flatten_item.connect(self.flatten_item)
        i.flatten_toc.connect(self.flatten_toc)
        i.go_to_root.connect(self.go_to_root)
        l.addWidget(i, 0, 4, col, 1)

        l.setColumnStretch(2, 10)

    def edit_item(self):
        self.item_view.edit_item()

    def event(self, e):
        if e.type() == QEvent.Type.StatusTip:
            txt = unicode_type(e.tip()) or self.default_msg
            self.hl.setText(txt)
        return super(TOCView, self).event(e)

    def item_title(self, item):
        return unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')

    def del_items(self):
        self.tocw.del_items()

    def delete_current_item(self):
        item = self.tocw.currentItem()
        if item is not None:
            self.tocw.push_history()
            p = item.parent() or self.root
            p.removeChild(item)

    def iter_items(self, parent=None):
        for item in self.tocw.iter_items(parent=parent):
            yield item

    def flatten_toc(self):
        self.tocw.push_history()
        found = True
        while found:
            found = False
            for item in self.iter_items():
                if item.childCount() > 0:
                    self._flatten_item(item)
                    found = True
                    break

    def flatten_item(self):
        self.tocw.push_history()
        self._flatten_item(self.tocw.currentItem())

    def _flatten_item(self, item):
        if item is not None:
            p = item.parent() or self.root
            idx = p.indexOfChild(item)
            children = [item.child(i) for i in range(item.childCount())]
            for child in reversed(children):
                item.removeChild(child)
                p.insertChild(idx + 1, child)

    def go_to_root(self):
        self.tocw.setCurrentItem(None)

    def highlight_item(self, item):
        self.tocw.highlight_item(item)

    def move_up(self):
        self.tocw.move_up()

    def move_down(self):
        self.tocw.move_down()

    def data_changed(self, top_left, bottom_right):
        for r in range(top_left.row(), bottom_right.row() + 1):
            idx = self.tocw.model().index(r, 0, top_left.parent())
            new_title = unicode_type(
                idx.data(Qt.ItemDataRole.DisplayRole) or '').strip()
            toc = idx.data(Qt.ItemDataRole.UserRole)
            if toc is not None:
                toc.title = new_title or _('(Untitled)')
            item = self.tocw.itemFromIndex(idx)
            self.tocw.update_status_tip(item)
            self.item_view.data_changed(item)

    def create_item(self, parent, child, idx=-1):
        if idx == -1:
            c = QTreeWidgetItem(parent)
        else:
            c = QTreeWidgetItem()
            parent.insertChild(idx, c)
        self.populate_item(c, child)
        return c

    def populate_item(self, c, child):
        c.setData(0, Qt.ItemDataRole.DisplayRole, child.title
                  or _('(Untitled)'))
        c.setData(0, Qt.ItemDataRole.UserRole, child)
        c.setFlags(NODE_FLAGS)
        c.setData(0, Qt.ItemDataRole.DecorationRole,
                  self.icon_map[child.dest_exists])
        if child.dest_exists is False:
            c.setData(
                0, Qt.ItemDataRole.ToolTipRole,
                _('The location this entry point to does not exist:\n%s') %
                child.dest_error)
        else:
            c.setData(0, Qt.ItemDataRole.ToolTipRole, None)

        self.tocw.update_status_tip(c)

    def __call__(self, ebook):
        self.ebook = ebook
        if not isinstance(ebook, AZW3Container):
            self.item_view.hide_azw3_warning()
        self.toc = get_toc(self.ebook)
        self.toc_lang, self.toc_uid = self.toc.lang, self.toc.uid
        self.toc_title = self.toc.toc_title
        self.blank = QIcon(I('blank.png'))
        self.ok = QIcon(I('ok.png'))
        self.err = QIcon(I('dot_red.png'))
        self.icon_map = {None: self.blank, True: self.ok, False: self.err}

        def process_item(toc_node, parent):
            for child in toc_node:
                c = self.create_item(parent, child)
                process_item(child, c)

        root = self.root = self.tocw.invisibleRootItem()
        root.setData(0, Qt.ItemDataRole.UserRole, self.toc)
        process_item(self.toc, root)
        self.tocw.model().dataChanged.connect(self.data_changed)
        self.tocw.currentItemChanged.connect(self.current_item_changed)
        self.tocw.setCurrentItem(None)

    def current_item_changed(self, current, previous):
        self.item_view(current)

    def update_item(self, item, where, name, frag, title):
        if isinstance(frag, tuple):
            frag = add_id(self.ebook, name, *frag)
        child = TOC(title, name, frag)
        child.dest_exists = True
        self.tocw.push_history()
        if item is None:
            # New entry at root level
            c = self.create_item(self.root, child)
            self.tocw.setCurrentItem(
                c, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
            self.tocw.scrollToItem(c)
        else:
            if where is None:
                # Editing existing entry
                self.populate_item(item, child)
            else:
                if where == 'inside':
                    parent = item
                    idx = -1
                else:
                    parent = item.parent() or self.root
                    idx = parent.indexOfChild(item)
                    if where == 'after':
                        idx += 1
                c = self.create_item(parent, child, idx=idx)
                self.tocw.setCurrentItem(
                    c, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
                self.tocw.scrollToItem(c)

    def create_toc(self):
        root = TOC()

        def process_node(parent, toc_parent):
            for i in range(parent.childCount()):
                item = parent.child(i)
                title = unicode_type(
                    item.data(0, Qt.ItemDataRole.DisplayRole) or '').strip()
                toc = item.data(0, Qt.ItemDataRole.UserRole)
                dest, frag = toc.dest, toc.frag
                toc = toc_parent.add(title, dest, frag)
                process_node(item, toc)

        process_node(self.tocw.invisibleRootItem(), root)
        return root

    def insert_toc_fragment(self, toc):
        def process_node(root, tocparent, added):
            for child in tocparent:
                item = self.create_item(root, child)
                added.append(item)
                process_node(item, child, added)

        self.tocw.push_history()
        nodes = []
        process_node(self.root, toc, nodes)
        self.highlight_item(nodes[0])

    def create_from_xpath(self, xpaths, remove_duplicates=True):
        toc = from_xpaths(self.ebook, xpaths)
        if len(toc) == 0:
            return error_dialog(
                self,
                _('No items found'),
                _('No items were found that could be added to the Table of Contents.'
                  ),
                show=True)
        if remove_duplicates:
            toc.remove_duplicates()
        self.insert_toc_fragment(toc)

    def create_from_links(self):
        toc = from_links(self.ebook)
        if len(toc) == 0:
            return error_dialog(
                self,
                _('No items found'),
                _('No links were found that could be added to the Table of Contents.'
                  ),
                show=True)
        self.insert_toc_fragment(toc)

    def create_from_files(self):
        toc = from_files(self.ebook)
        if len(toc) == 0:
            return error_dialog(
                self,
                _('No items found'),
                _('No files were found that could be added to the Table of Contents.'
                  ),
                show=True)
        self.insert_toc_fragment(toc)

    def undo(self):
        self.tocw.pop_history()
Example #14
0
class TreeWidget(QTreeWidget):  # {{{

    edit_item = pyqtSignal()
    history_state_changed = pyqtSignal()

    def __init__(self, parent):
        QTreeWidget.__init__(self, parent)
        self.history = []
        self.setHeaderLabel(_('Table of Contents'))
        self.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
        self.setDragEnabled(True)
        self.setSelectionMode(
            QAbstractItemView.SelectionMode.ExtendedSelection)
        self.viewport().setAcceptDrops(True)
        self.setDropIndicatorShown(True)
        self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
        self.setAutoScroll(True)
        self.setAutoScrollMargin(ICON_SIZE * 2)
        self.setDefaultDropAction(Qt.DropAction.MoveAction)
        self.setAutoExpandDelay(1000)
        self.setAnimated(True)
        self.setMouseTracking(True)
        self.in_drop_event = False
        self.root = self.invisibleRootItem()
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.show_context_menu)

    def push_history(self):
        self.history.append(self.serialize_tree())
        self.history_state_changed.emit()

    def pop_history(self):
        if self.history:
            self.unserialize_tree(self.history.pop())
            self.history_state_changed.emit()

    def commitData(self, editor):
        self.push_history()
        return QTreeWidget.commitData(self, editor)

    def iter_items(self, parent=None):
        if parent is None:
            parent = self.invisibleRootItem()
        for i in range(parent.childCount()):
            child = parent.child(i)
            yield child
            for gc in self.iter_items(parent=child):
                yield gc

    def update_status_tip(self, item):
        c = item.data(0, Qt.ItemDataRole.UserRole)
        if c is not None:
            frag = c.frag or ''
            if frag:
                frag = '#' + frag
            item.setStatusTip(
                0,
                _('<b>Title</b>: {0} <b>Dest</b>: {1}{2}').format(
                    c.title, c.dest, frag))

    def serialize_tree(self):
        def serialize_node(node):
            return {
                'title':
                node.data(0, Qt.ItemDataRole.DisplayRole),
                'toc_node':
                node.data(0, Qt.ItemDataRole.UserRole),
                'icon':
                node.data(0, Qt.ItemDataRole.DecorationRole),
                'tooltip':
                node.data(0, Qt.ItemDataRole.ToolTipRole),
                'is_selected':
                node.isSelected(),
                'is_expanded':
                node.isExpanded(),
                'children':
                list(
                    map(serialize_node,
                        (node.child(i) for i in range(node.childCount())))),
            }

        node = self.invisibleRootItem()
        return {
            'children':
            list(
                map(serialize_node,
                    (node.child(i) for i in range(node.childCount()))))
        }

    def unserialize_tree(self, serialized):
        def unserialize_node(dict_node, parent):
            n = QTreeWidgetItem(parent)
            n.setData(0, Qt.ItemDataRole.DisplayRole, dict_node['title'])
            n.setData(0, Qt.ItemDataRole.UserRole, dict_node['toc_node'])
            n.setFlags(NODE_FLAGS)
            n.setData(0, Qt.ItemDataRole.DecorationRole, dict_node['icon'])
            n.setData(0, Qt.ItemDataRole.ToolTipRole, dict_node['tooltip'])
            self.update_status_tip(n)
            n.setExpanded(dict_node['is_expanded'])
            n.setSelected(dict_node['is_selected'])
            for c in dict_node['children']:
                unserialize_node(c, n)

        i = self.invisibleRootItem()
        i.takeChildren()
        for child in serialized['children']:
            unserialize_node(child, i)

    def dropEvent(self, event):
        self.in_drop_event = True
        self.push_history()
        try:
            super(TreeWidget, self).dropEvent(event)
        finally:
            self.in_drop_event = False

    def selectedIndexes(self):
        ans = super(TreeWidget, self).selectedIndexes()
        if self.in_drop_event:
            # For order to be be preserved when moving by drag and drop, we
            # have to ensure that selectedIndexes returns an ordered list of
            # indexes.
            sort_map = {
                self.indexFromItem(item): i
                for i, item in enumerate(self.iter_items())
            }
            ans = sorted(ans, key=lambda x: sort_map.get(x, -1))
        return ans

    def highlight_item(self, item):
        self.setCurrentItem(item, 0,
                            QItemSelectionModel.SelectionFlag.ClearAndSelect)
        self.scrollToItem(item)

    def check_multi_selection(self):
        if len(self.selectedItems()) > 1:
            info_dialog(
                self,
                _('Multiple items selected'),
                _('You are trying to move multiple items at once, this is not supported. Instead use'
                  ' Drag and Drop to move multiple items'),
                show=True)
            return False
        return True

    def move_left(self):
        if not self.check_multi_selection():
            return
        self.push_history()
        item = self.currentItem()
        if item is not None:
            parent = item.parent()
            if parent is not None:
                is_expanded = item.isExpanded() or item.childCount() == 0
                gp = parent.parent() or self.invisibleRootItem()
                idx = gp.indexOfChild(parent)
                for gc in [
                        parent.child(i) for i in range(
                            parent.indexOfChild(item) + 1, parent.childCount())
                ]:
                    parent.removeChild(gc)
                    item.addChild(gc)
                parent.removeChild(item)
                gp.insertChild(idx + 1, item)
                if is_expanded:
                    self.expandItem(item)
                self.highlight_item(item)

    def move_right(self):
        if not self.check_multi_selection():
            return
        self.push_history()
        item = self.currentItem()
        if item is not None:
            parent = item.parent() or self.invisibleRootItem()
            idx = parent.indexOfChild(item)
            if idx > 0:
                is_expanded = item.isExpanded()
                np = parent.child(idx - 1)
                parent.removeChild(item)
                np.addChild(item)
                if is_expanded:
                    self.expandItem(item)
                self.highlight_item(item)

    def move_down(self):
        if not self.check_multi_selection():
            return
        self.push_history()
        item = self.currentItem()
        if item is None:
            if self.root.childCount() == 0:
                return
            item = self.root.child(0)
            self.highlight_item(item)
            return
        parent = item.parent() or self.root
        idx = parent.indexOfChild(item)
        if idx == parent.childCount() - 1:
            # At end of parent, need to become sibling of parent
            if parent is self.root:
                return
            gp = parent.parent() or self.root
            parent.removeChild(item)
            gp.insertChild(gp.indexOfChild(parent) + 1, item)
        else:
            sibling = parent.child(idx + 1)
            parent.removeChild(item)
            sibling.insertChild(0, item)
        self.highlight_item(item)

    def move_up(self):
        if not self.check_multi_selection():
            return
        self.push_history()
        item = self.currentItem()
        if item is None:
            if self.root.childCount() == 0:
                return
            item = self.root.child(self.root.childCount() - 1)
            self.highlight_item(item)
            return
        parent = item.parent() or self.root
        idx = parent.indexOfChild(item)
        if idx == 0:
            # At end of parent, need to become sibling of parent
            if parent is self.root:
                return
            gp = parent.parent() or self.root
            parent.removeChild(item)
            gp.insertChild(gp.indexOfChild(parent), item)
        else:
            sibling = parent.child(idx - 1)
            parent.removeChild(item)
            sibling.addChild(item)
        self.highlight_item(item)

    def del_items(self):
        self.push_history()
        for item in self.selectedItems():
            p = item.parent() or self.root
            p.removeChild(item)

    def title_case(self):
        self.push_history()
        from calibre.utils.titlecase import titlecase
        for item in self.selectedItems():
            t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
            item.setData(0, Qt.ItemDataRole.DisplayRole, titlecase(t))

    def upper_case(self):
        self.push_history()
        for item in self.selectedItems():
            t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
            item.setData(0, Qt.ItemDataRole.DisplayRole, icu_upper(t))

    def lower_case(self):
        self.push_history()
        for item in self.selectedItems():
            t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
            item.setData(0, Qt.ItemDataRole.DisplayRole, icu_lower(t))

    def swap_case(self):
        self.push_history()
        from calibre.utils.icu import swapcase
        for item in self.selectedItems():
            t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
            item.setData(0, Qt.ItemDataRole.DisplayRole, swapcase(t))

    def capitalize(self):
        self.push_history()
        from calibre.utils.icu import capitalize
        for item in self.selectedItems():
            t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
            item.setData(0, Qt.ItemDataRole.DisplayRole, capitalize(t))

    def bulk_rename(self):
        from calibre.gui2.tweak_book.file_list import get_bulk_rename_settings
        sort_map = {id(item): i for i, item in enumerate(self.iter_items())}
        items = sorted(self.selectedItems(),
                       key=lambda x: sort_map.get(id(x), -1))
        settings = get_bulk_rename_settings(
            self,
            len(items),
            prefix=_('Chapter '),
            msg=_(
                'All selected items will be renamed to the form prefix-number'
            ),
            sanitize=lambda x: x,
            leading_zeros=False)
        fmt, num = settings['prefix'], settings['start']
        if fmt is not None and num is not None:
            self.push_history()
            for i, item in enumerate(items):
                item.setData(0, Qt.ItemDataRole.DisplayRole, fmt % (num + i))

    def keyPressEvent(self, ev):
        if ev.key() == Qt.Key.Key_Left and ev.modifiers() & Qt.Modifier.CTRL:
            self.move_left()
            ev.accept()
        elif ev.key(
        ) == Qt.Key.Key_Right and ev.modifiers() & Qt.Modifier.CTRL:
            self.move_right()
            ev.accept()
        elif ev.key() == Qt.Key.Key_Up and (ev.modifiers() & Qt.Modifier.CTRL
                                            or
                                            ev.modifiers() & Qt.Modifier.ALT):
            self.move_up()
            ev.accept()
        elif ev.key() == Qt.Key.Key_Down and (ev.modifiers() & Qt.Modifier.CTRL
                                              or ev.modifiers()
                                              & Qt.Modifier.ALT):
            self.move_down()
            ev.accept()
        elif ev.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
            self.del_items()
            ev.accept()
        else:
            return super(TreeWidget, self).keyPressEvent(ev)

    def show_context_menu(self, point):
        item = self.currentItem()

        def key(k):
            sc = unicode_type(
                QKeySequence(k | Qt.Modifier.CTRL).toString(
                    QKeySequence.SequenceFormat.NativeText))
            return ' [%s]' % sc

        if item is not None:
            m = QMenu(self)
            m.addAction(QIcon(I('edit_input.png')),
                        _('Change the location this entry points to'),
                        self.edit_item)
            m.addAction(QIcon(I('modified.png')),
                        _('Bulk rename all selected items'), self.bulk_rename)
            m.addAction(QIcon(I('trash.png')), _('Remove all selected items'),
                        self.del_items)
            m.addSeparator()
            ci = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '')
            p = item.parent() or self.invisibleRootItem()
            idx = p.indexOfChild(item)
            if idx > 0:
                m.addAction(QIcon(I('arrow-up.png')),
                            (_('Move "%s" up') % ci) + key(Qt.Key.Key_Up),
                            self.move_up)
            if idx + 1 < p.childCount():
                m.addAction(QIcon(I('arrow-down.png')),
                            (_('Move "%s" down') % ci) + key(Qt.Key.Key_Down),
                            self.move_down)
            if item.parent() is not None:
                m.addAction(QIcon(I('back.png')),
                            (_('Unindent "%s"') % ci) + key(Qt.Key.Key_Left),
                            self.move_left)
            if idx > 0:
                m.addAction(QIcon(I('forward.png')),
                            (_('Indent "%s"') % ci) + key(Qt.Key.Key_Right),
                            self.move_right)

            m.addSeparator()
            case_menu = QMenu(_('Change case'), m)
            case_menu.addAction(_('Upper case'), self.upper_case)
            case_menu.addAction(_('Lower case'), self.lower_case)
            case_menu.addAction(_('Swap case'), self.swap_case)
            case_menu.addAction(_('Title case'), self.title_case)
            case_menu.addAction(_('Capitalize'), self.capitalize)
            m.addMenu(case_menu)

            m.exec_(QCursor.pos())
Example #15
0
class Rules(QWidget):

    RuleItemClass = RuleItem
    RuleEditDialogClass = RuleEditDialog
    changed = pyqtSignal()

    ACTION_KEY = 'action'
    MSG = _('You can specify rules to filter/transform tags here. Click the "Add rule" button'
            ' below to get started. The rules will be processed in order for every tag until either a'
            ' "remove" or a "keep" rule matches.')

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.l = l = QVBoxLayout(self)

        self.msg_label = la = QLabel(
            '<p>' + self.MSG + '<p>' + _(
            'You can <b>change an existing rule</b> by double clicking it')
        )
        la.setWordWrap(True)
        l.addWidget(la)
        self.h = h = QHBoxLayout()
        l.addLayout(h)
        self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add rule'), self)
        b.clicked.connect(self.add_rule)
        h.addWidget(b)
        self.remove_button = b = QPushButton(QIcon(I('minus.png')), _('&Remove rule(s)'), self)
        b.clicked.connect(self.remove_rules)
        h.addWidget(b)
        self.h3 = h = QHBoxLayout()
        l.addLayout(h)
        self.rule_list = r = QListWidget(self)
        self.delegate = Delegate(self)
        r.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        r.setItemDelegate(self.delegate)
        r.doubleClicked.connect(self.edit_rule)
        h.addWidget(r)
        r.setDragEnabled(True)
        r.viewport().setAcceptDrops(True)
        r.setDropIndicatorShown(True)
        r.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
        r.setDefaultDropAction(Qt.DropAction.MoveAction)
        self.l2 = l = QVBoxLayout()
        h.addLayout(l)
        self.up_button = b = QToolButton(self)
        b.setIcon(QIcon(I('arrow-up.png'))), b.setToolTip(_('Move current rule up'))
        b.clicked.connect(self.move_up)
        l.addWidget(b)
        self.down_button = b = QToolButton(self)
        b.setIcon(QIcon(I('arrow-down.png'))), b.setToolTip(_('Move current rule down'))
        b.clicked.connect(self.move_down)
        l.addStretch(10), l.addWidget(b)

    def sizeHint(self):
        return QSize(800, 600)

    def add_rule(self):
        d = self.RuleEditDialogClass(self)
        if d.exec() == QDialog.DialogCode.Accepted:
            i = self.RuleItemClass(d.edit_widget.rule, self.rule_list)
            self.rule_list.scrollToItem(i)
            self.changed.emit()

    def edit_rule(self):
        i = self.rule_list.currentItem()
        if i is not None:
            d = self.RuleEditDialogClass(self)
            d.edit_widget.rule = i.data(Qt.ItemDataRole.UserRole)
            if d.exec() == QDialog.DialogCode.Accepted:
                rule = d.edit_widget.rule
                i.setData(DATA_ROLE, rule)
                i.setData(RENDER_ROLE, self.RuleItemClass.text_from_rule(rule, self.rule_list))
                self.changed.emit()

    def remove_rules(self):
        changed = False
        for item in self.rule_list.selectedItems():
            self.rule_list.takeItem(self.rule_list.row(item))
            changed = True
        if changed:
            self.changed.emit()

    def move_selected(self, delta=-1):
        current_item = self.rule_list.currentItem()
        items = self.rule_list.selectedItems()
        if current_item is None or not items or not len(items):
            return
        row_map = {id(item): self.rule_list.row(item) for item in items}
        items.sort(key=lambda item: row_map[id(item)])
        num = self.rule_list.count()
        for item in items:
            row = row_map[id(item)]
            nrow = (row + delta + num) % num
            self.rule_list.takeItem(row)
            self.rule_list.insertItem(nrow, item)
        sm = self.rule_list.selectionModel()
        for item in items:
            sm.select(self.rule_list.indexFromItem(item), QItemSelectionModel.SelectionFlag.Select)
        sm.setCurrentIndex(self.rule_list.indexFromItem(current_item), QItemSelectionModel.SelectionFlag.Current)
        self.changed.emit()

    def move_up(self):
        self.move_selected()

    def move_down(self):
        self.move_selected(1)

    @property
    def rules(self):
        ans = []
        for r in range(self.rule_list.count()):
            ans.append(self.rule_list.item(r).data(DATA_ROLE))
        return ans

    @rules.setter
    def rules(self, rules):
        self.rule_list.clear()
        for rule in rules:
            if self.ACTION_KEY in rule and 'match_type' in rule and 'query' in rule:
                self.RuleItemClass(rule, self.rule_list)
Example #16
0
class ImageView(QWidget, ImageDropMixin):

    BORDER_WIDTH = 1
    cover_changed = pyqtSignal(object)

    def __init__(self,
                 parent=None,
                 show_size_pref_name=None,
                 default_show_size=False):
        QWidget.__init__(self, parent)
        self.show_size_pref_name = (
            'show_size_on_cover_' +
            show_size_pref_name) if show_size_pref_name else None
        self._pixmap = QPixmap()
        self.setMinimumSize(QSize(150, 200))
        ImageDropMixin.__init__(self)
        self.draw_border = True
        self.show_size = False
        if self.show_size_pref_name:
            self.show_size = gprefs.get(self.show_size_pref_name,
                                        default_show_size)

    def setPixmap(self, pixmap):
        if not isinstance(pixmap, QPixmap):
            raise TypeError('Must use a QPixmap')
        self._pixmap = pixmap
        self.updateGeometry()
        self.update()

    def build_context_menu(self):
        m = ImageDropMixin.build_context_menu(self)
        if self.show_size_pref_name:
            text = _('Hide size in corner') if self.show_size else _(
                'Show size in corner')
            m.addAction(text, self.toggle_show_size)
        return m

    def toggle_show_size(self):
        self.show_size ^= True
        if self.show_size_pref_name:
            gprefs[self.show_size_pref_name] = self.show_size
        self.update()

    def pixmap(self):
        return self._pixmap

    def sizeHint(self):
        if self._pixmap.isNull():
            return self.minimumSize()
        return self._pixmap.size()

    def paintEvent(self, event):
        QWidget.paintEvent(self, event)
        pmap = self._pixmap
        if pmap.isNull():
            return
        w, h = pmap.width(), pmap.height()
        ow, oh = w, h
        cw, ch = self.rect().width(), self.rect().height()
        scaled, nw, nh = fit_image(w, h, cw, ch)
        if scaled:
            pmap = pmap.scaled(int(nw * pmap.devicePixelRatio()),
                               int(nh * pmap.devicePixelRatio()),
                               Qt.AspectRatioMode.IgnoreAspectRatio,
                               Qt.TransformationMode.SmoothTransformation)
        w, h = int(pmap.width() / pmap.devicePixelRatio()), int(
            pmap.height() / pmap.devicePixelRatio())
        x = int(abs(cw - w) / 2)
        y = int(abs(ch - h) / 2)
        target = QRect(x, y, w, h)
        p = QPainter(self)
        p.setRenderHints(QPainter.RenderHint.Antialiasing
                         | QPainter.RenderHint.SmoothPixmapTransform)
        p.drawPixmap(target, pmap)
        if self.draw_border:
            pen = QPen()
            pen.setWidth(self.BORDER_WIDTH)
            p.setPen(pen)
            p.drawRect(target)
        if self.show_size:
            draw_size(p, target, ow, oh)
        p.end()
Example #17
0
class Editor(QMainWindow):

    has_line_numbers = True

    modification_state_changed = pyqtSignal(object)
    undo_redo_state_changed = pyqtSignal(object, object)
    copy_available_state_changed = pyqtSignal(object)
    data_changed = pyqtSignal(object)
    cursor_position_changed = pyqtSignal()
    word_ignored = pyqtSignal(object, object)
    link_clicked = pyqtSignal(object)
    class_clicked = pyqtSignal(object)
    rename_class = pyqtSignal(object)
    smart_highlighting_updated = pyqtSignal()

    def __init__(self, syntax, parent=None):
        QMainWindow.__init__(self, parent)
        if parent is None:
            self.setWindowFlags(Qt.WindowType.Widget)
        self.is_synced_to_container = False
        self.syntax = syntax
        self.editor = TextEdit(self)
        self.editor.setContextMenuPolicy(
            Qt.ContextMenuPolicy.CustomContextMenu)
        self.editor.customContextMenuRequested.connect(self.show_context_menu)
        self.setCentralWidget(self.editor)
        self.create_toolbars()
        self.undo_available = False
        self.redo_available = False
        self.copy_available = self.cut_available = False
        self.editor.modificationChanged.connect(
            self._modification_state_changed)
        self.editor.undoAvailable.connect(self._undo_available)
        self.editor.redoAvailable.connect(self._redo_available)
        self.editor.textChanged.connect(self._data_changed)
        self.editor.copyAvailable.connect(self._copy_available)
        self.editor.cursorPositionChanged.connect(
            self._cursor_position_changed)
        self.editor.link_clicked.connect(self.link_clicked)
        self.editor.class_clicked.connect(self.class_clicked)
        self.editor.smart_highlighting_updated.connect(
            self.smart_highlighting_updated)

    @property
    def current_line(self):
        return self.editor.textCursor().blockNumber()

    @current_line.setter
    def current_line(self, val):
        self.editor.go_to_line(val)

    @property
    def current_editing_state(self):
        c = self.editor.textCursor()
        return {'cursor': (c.anchor(), c.position())}

    @current_editing_state.setter
    def current_editing_state(self, val):
        anchor, position = val.get('cursor', (None, None))
        if anchor is not None and position is not None:
            c = self.editor.textCursor()
            c.setPosition(anchor), c.setPosition(
                position, QTextCursor.MoveMode.KeepAnchor)
            self.editor.setTextCursor(c)

    def current_tag(self, for_position_sync=True):
        return self.editor.current_tag(for_position_sync=for_position_sync)

    @property
    def highlighter(self):
        return self.editor.highlighter

    @property
    def number_of_lines(self):
        return self.editor.blockCount()

    @property
    def data(self):
        ans = self.get_raw_data()
        ans, changed = replace_encoding_declarations(ans,
                                                     enc='utf-8',
                                                     limit=4 * 1024)
        if changed:
            self.data = ans
        return ans.encode('utf-8')

    @data.setter
    def data(self, val):
        self.editor.load_text(val,
                              syntax=self.syntax,
                              doc_name=editor_name(self))

    def init_from_template(self, template):
        self.editor.load_text(template,
                              syntax=self.syntax,
                              process_template=True,
                              doc_name=editor_name(self))

    def change_document_name(self, newname):
        self.editor.change_document_name(newname)
        self.editor.completion_doc_name = newname

    def get_raw_data(self):
        # The EPUB spec requires NFC normalization, see section 1.3.6 of
        # http://www.idpf.org/epub/20/spec/OPS_2.0.1_draft.htm
        return unicodedata.normalize(
            'NFC',
            str(self.editor.toPlainText()).rstrip('\0'))

    def replace_data(self, raw, only_if_different=True):
        if isinstance(raw, bytes):
            raw = raw.decode('utf-8')
        current = self.get_raw_data() if only_if_different else False
        if current != raw:
            self.editor.replace_text(raw)

    def apply_settings(self, prefs=None, dictionaries_changed=False):
        self.editor.apply_settings(prefs=None,
                                   dictionaries_changed=dictionaries_changed)

    def set_focus(self):
        self.editor.setFocus(Qt.FocusReason.OtherFocusReason)

    def action_triggered(self, action):
        action, args = action[0], action[1:]
        func = getattr(self.editor, action)
        func(*args)

    def insert_image(self,
                     href,
                     fullpage=False,
                     preserve_aspect_ratio=False,
                     width=-1,
                     height=-1):
        self.editor.insert_image(href,
                                 fullpage=fullpage,
                                 preserve_aspect_ratio=preserve_aspect_ratio,
                                 width=width,
                                 height=height)

    def insert_hyperlink(self, href, text, template=None):
        self.editor.insert_hyperlink(href, text, template=template)

    def _build_insert_tag_button_menu(self):
        m = self.insert_tag_menu
        m.clear()
        names = tprefs['insert_tag_mru']
        for name in names:
            m.addAction(name, partial(self.insert_tag, name))
        m.addSeparator()
        m.addAction(_('Add a tag to this menu'), self.add_insert_tag)
        if names:
            m = m.addMenu(_('Remove from this menu'))
            for name in names:
                m.addAction(name, partial(self.remove_insert_tag, name))

    def insert_tag(self, name):
        self.editor.insert_tag(name)
        mru = tprefs['insert_tag_mru']
        try:
            mru.remove(name)
        except ValueError:
            pass
        mru.insert(0, name)
        tprefs['insert_tag_mru'] = mru
        self._build_insert_tag_button_menu()

    def add_insert_tag(self):
        name, ok = QInputDialog.getText(self, _('Name of tag to add'),
                                        _('Enter the name of the tag'))
        if ok:
            mru = tprefs['insert_tag_mru']
        mru.insert(0, name)
        tprefs['insert_tag_mru'] = mru
        self._build_insert_tag_button_menu()

    def remove_insert_tag(self, name):
        mru = tprefs['insert_tag_mru']
        try:
            mru.remove(name)
        except ValueError:
            pass
        tprefs['insert_tag_mru'] = mru
        self._build_insert_tag_button_menu()

    def set_request_completion(self, callback=None, doc_name=None):
        self.editor.request_completion = callback
        self.editor.completion_doc_name = doc_name

    def handle_completion_result(self, result):
        return self.editor.handle_completion_result(result)

    def undo(self):
        self.editor.undo()

    def redo(self):
        self.editor.redo()

    @property
    def selected_text(self):
        return self.editor.selected_text

    def get_smart_selection(self, update=True):
        return self.editor.smarts.get_smart_selection(self.editor,
                                                      update=update)

    # Search and replace {{{
    def mark_selected_text(self):
        self.editor.mark_selected_text()

    def find(self, *args, **kwargs):
        return self.editor.find(*args, **kwargs)

    def find_text(self, *args, **kwargs):
        return self.editor.find_text(*args, **kwargs)

    def find_spell_word(self, *args, **kwargs):
        return self.editor.find_spell_word(*args, **kwargs)

    def replace(self, *args, **kwargs):
        return self.editor.replace(*args, **kwargs)

    def all_in_marked(self, *args, **kwargs):
        return self.editor.all_in_marked(*args, **kwargs)

    def go_to_anchor(self, *args, **kwargs):
        return self.editor.go_to_anchor(*args, **kwargs)

    # }}}

    @property
    def has_marked_text(self):
        return self.editor.current_search_mark is not None

    @property
    def is_modified(self):
        return self.editor.is_modified

    @is_modified.setter
    def is_modified(self, val):
        self.editor.is_modified = val

    def create_toolbars(self):
        self.action_bar = b = self.addToolBar(_('Edit actions tool bar'))
        b.setObjectName('action_bar')  # Needed for saveState
        self.tools_bar = b = self.addToolBar(_('Editor tools'))
        b.setObjectName('tools_bar')
        self.bars = [self.action_bar, self.tools_bar]
        if self.syntax == 'html':
            self.format_bar = b = self.addToolBar(_('Format text'))
            b.setObjectName('html_format_bar')
            self.bars.append(self.format_bar)
        self.insert_tag_menu = QMenu(self)
        self.populate_toolbars()
        for x in self.bars:
            x.setFloatable(False)
            x.topLevelChanged.connect(self.toolbar_floated)
            x.setIconSize(
                QSize(tprefs['toolbar_icon_size'],
                      tprefs['toolbar_icon_size']))

    def toolbar_floated(self, floating):
        if not floating:
            self.save_state()
            for ed in itervalues(editors):
                if ed is not self:
                    ed.restore_state()

    def save_state(self):
        for bar in self.bars:
            if bar.isFloating():
                return
        tprefs['%s-editor-state' % self.syntax] = bytearray(self.saveState())

    def restore_state(self):
        state = tprefs.get('%s-editor-state' % self.syntax, None)
        if state is not None:
            self.restoreState(state)
        for bar in self.bars:
            bar.setVisible(len(bar.actions()) > 0)

    def populate_toolbars(self):
        self.action_bar.clear(), self.tools_bar.clear()

        def add_action(name, bar):
            if name is None:
                bar.addSeparator()
                return
            try:
                ac = actions[name]
            except KeyError:
                if DEBUG:
                    prints('Unknown editor tool: %r' % name)
                return
            bar.addAction(ac)
            if name == 'insert-tag':
                w = bar.widgetForAction(ac)
                if hasattr(w, 'setPopupMode'):
                    # For some unknown reason this button is occasionally a
                    # QPushButton instead of a QToolButton
                    w.setPopupMode(
                        QToolButton.ToolButtonPopupMode.MenuButtonPopup)
                w.setMenu(self.insert_tag_menu)
                w.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
                w.customContextMenuRequested.connect(w.showMenu)
                self._build_insert_tag_button_menu()
            elif name == 'change-paragraph':
                m = ac.m = QMenu()
                ac.setMenu(m)
                ch = bar.widgetForAction(ac)
                if hasattr(ch, 'setPopupMode'):
                    # For some unknown reason this button is occasionally a
                    # QPushButton instead of a QToolButton
                    ch.setPopupMode(
                        QToolButton.ToolButtonPopupMode.InstantPopup)
                for name in tuple('h%d' % d for d in range(1, 7)) + ('p', ):
                    m.addAction(actions['rename-block-tag-%s' % name])

        for name in tprefs.get('editor_common_toolbar', ()):
            add_action(name, self.action_bar)

        for name in tprefs.get('editor_%s_toolbar' % self.syntax, ()):
            add_action(name, self.tools_bar)

        if self.syntax == 'html':
            self.format_bar.clear()
            for name in tprefs['editor_format_toolbar']:
                add_action(name, self.format_bar)
        self.restore_state()

    def break_cycles(self):
        for x in ('modification_state_changed', 'word_ignored', 'link_clicked',
                  'class_clicked', 'smart_highlighting_updated'):
            try:
                getattr(self, x).disconnect()
            except TypeError:
                pass  # in case this signal was never connected
        self.undo_redo_state_changed.disconnect()
        self.copy_available_state_changed.disconnect()
        self.cursor_position_changed.disconnect()
        self.data_changed.disconnect()
        self.editor.undoAvailable.disconnect()
        self.editor.redoAvailable.disconnect()
        self.editor.modificationChanged.disconnect()
        self.editor.textChanged.disconnect()
        self.editor.copyAvailable.disconnect()
        self.editor.cursorPositionChanged.disconnect()
        self.editor.link_clicked.disconnect()
        self.editor.class_clicked.disconnect()
        self.editor.smart_highlighting_updated.disconnect()
        self.editor.setPlainText('')
        self.editor.smarts = None
        self.editor.request_completion = None

    def _modification_state_changed(self):
        self.is_synced_to_container = self.is_modified
        self.modification_state_changed.emit(self.is_modified)

    def _data_changed(self):
        self.is_synced_to_container = False
        self.data_changed.emit(self)

    def _undo_available(self, available):
        self.undo_available = available
        self.undo_redo_state_changed.emit(self.undo_available,
                                          self.redo_available)

    def _redo_available(self, available):
        self.redo_available = available
        self.undo_redo_state_changed.emit(self.undo_available,
                                          self.redo_available)

    def _copy_available(self, available):
        self.copy_available = self.cut_available = available
        self.copy_available_state_changed.emit(available)

    def _cursor_position_changed(self, *args):
        self.cursor_position_changed.emit()

    @property
    def cursor_position(self):
        c = self.editor.textCursor()
        char = ''
        col = c.positionInBlock()
        if not c.atStart():
            c.clearSelection()
            c.movePosition(QTextCursor.MoveOperation.PreviousCharacter,
                           QTextCursor.MoveMode.KeepAnchor)
            char = str(c.selectedText()).rstrip('\0')
        return (c.blockNumber() + 1, col, char)

    def cut(self):
        self.editor.cut()

    def copy(self):
        self.editor.copy()

    def go_to_line(self, line, col=None):
        self.editor.go_to_line(line, col=col)

    def paste(self):
        if not self.editor.canPaste():
            return error_dialog(
                self,
                _('No text'),
                _('There is no suitable text in the clipboard to paste.'),
                show=True)
        self.editor.paste()

    def contextMenuEvent(self, ev):
        ev.ignore()

    def fix_html(self):
        if self.syntax == 'html':
            from calibre.ebooks.oeb.polish.pretty import fix_html
            self.editor.replace_text(
                fix_html(current_container(),
                         str(self.editor.toPlainText())).decode('utf-8'))
            return True
        return False

    def pretty_print(self, name):
        from calibre.ebooks.oeb.polish.pretty import (pretty_css, pretty_html,
                                                      pretty_xml)
        if self.syntax in {'css', 'html', 'xml'}:
            func = {
                'css': pretty_css,
                'xml': pretty_xml
            }.get(self.syntax, pretty_html)
            original_text = str(self.editor.toPlainText())
            prettied_text = func(current_container(), name,
                                 original_text).decode('utf-8')
            if original_text != prettied_text:
                self.editor.replace_text(prettied_text)
            return True
        return False

    def show_context_menu(self, pos):
        m = QMenu(self)
        a = m.addAction
        c = self.editor.cursorForPosition(pos)
        origc = QTextCursor(c)
        current_cursor = self.editor.textCursor()
        r = origr = self.editor.syntax_range_for_cursor(c)
        if (
                r is None or not r.format.property(SPELL_PROPERTY)
        ) and c.positionInBlock() > 0 and not current_cursor.hasSelection():
            c.setPosition(c.position() - 1)
            r = self.editor.syntax_range_for_cursor(c)

        if r is not None and r.format.property(SPELL_PROPERTY):
            word = self.editor.text_for_range(c.block(), r)
            locale = self.editor.spellcheck_locale_for_cursor(c)
            orig_pos = c.position()
            c.setPosition(orig_pos - utf16_length(word))
            found = False
            self.editor.setTextCursor(c)
            if self.editor.find_spell_word([word],
                                           locale.langcode,
                                           center_on_cursor=False):
                found = True
                fc = self.editor.textCursor()
                if fc.position() < c.position():
                    self.editor.find_spell_word([word],
                                                locale.langcode,
                                                center_on_cursor=False)
            spell_cursor = self.editor.textCursor()
            if current_cursor.hasSelection():
                # Restore the current cursor so that any selection is preserved
                # for the change case actions
                self.editor.setTextCursor(current_cursor)
            if found:
                suggestions = dictionaries.suggestions(word, locale)[:7]
                if suggestions:
                    for suggestion in suggestions:
                        ac = m.addAction(
                            suggestion,
                            partial(self.editor.simple_replace,
                                    suggestion,
                                    cursor=spell_cursor))
                        f = ac.font()
                        f.setBold(True), ac.setFont(f)
                    m.addSeparator()
                m.addAction(actions['spell-next'])
                m.addAction(_('Ignore this word'),
                            partial(self._nuke_word, None, word, locale))
                dics = dictionaries.active_user_dictionaries
                if len(dics) > 0:
                    if len(dics) == 1:
                        m.addAction(
                            _('Add this word to the dictionary: {0}').format(
                                dics[0].name),
                            partial(self._nuke_word, dics[0].name, word,
                                    locale))
                    else:
                        ac = m.addAction(_('Add this word to the dictionary'))
                        dmenu = QMenu(m)
                        ac.setMenu(dmenu)
                        for dic in dics:
                            dmenu.addAction(
                                dic.name,
                                partial(self._nuke_word, dic.name, word,
                                        locale))
                m.addSeparator()

        if origr is not None and origr.format.property(LINK_PROPERTY):
            href = self.editor.text_for_range(origc.block(), origr)
            m.addAction(
                _('Open %s') % href, partial(self.link_clicked.emit, href))

        if origr is not None and origr.format.property(
                CLASS_ATTRIBUTE_PROPERTY):
            cls = self.editor.class_for_position(pos)
            if cls:
                class_name = cls['class']
                m.addAction(
                    _('Rename the class {}').format(class_name),
                    partial(self.rename_class.emit, class_name))

        if origr is not None and (origr.format.property(TAG_NAME_PROPERTY)
                                  or origr.format.property(CSS_PROPERTY)):
            word = self.editor.text_for_range(origc.block(), origr)
            item_type = 'tag_name' if origr.format.property(
                TAG_NAME_PROPERTY) else 'css_property'
            url = help_url(word,
                           item_type,
                           self.editor.highlighter.doc_name,
                           extra_data=current_container().opf_version)
            if url is not None:
                m.addAction(
                    _('Show help for: %s') % word, partial(open_url, url))

        for x in ('undo', 'redo'):
            ac = actions['editor-%s' % x]
            if ac.isEnabled():
                a(ac)
        m.addSeparator()
        for x in ('cut', 'copy', 'paste'):
            ac = actions['editor-' + x]
            if ac.isEnabled():
                a(ac)
        m.addSeparator()
        m.addAction(_('&Select all'), self.editor.select_all)
        if self.selected_text or self.has_marked_text:
            update_mark_text_action(self)
            m.addAction(actions['mark-selected-text'])
        if self.syntax != 'css' and actions['editor-cut'].isEnabled():
            cm = QMenu(_('Change &case'), m)
            for ac in 'upper lower swap title capitalize'.split():
                cm.addAction(actions['transform-case-' + ac])
            m.addMenu(cm)
        if self.syntax == 'html':
            m.addAction(actions['multisplit'])
        m.exec(self.editor.viewport().mapToGlobal(pos))

    def goto_sourceline(self, *args, **kwargs):
        return self.editor.goto_sourceline(*args, **kwargs)

    def goto_css_rule(self, *args, **kwargs):
        return self.editor.goto_css_rule(*args, **kwargs)

    def get_tag_contents(self, *args, **kwargs):
        return self.editor.get_tag_contents(*args, **kwargs)

    def _nuke_word(self, dic, word, locale):
        if dic is None:
            dictionaries.ignore_word(word, locale)
        else:
            dictionaries.add_to_user_dictionary(dic, word, locale)
        self.word_ignored.emit(word, locale)
Example #18
0
class FilenamePattern(QWidget, Ui_Form):  # {{{

    changed_signal = pyqtSignal()

    def __init__(self, parent):
        QWidget.__init__(self, parent)
        self.setupUi(self)
        try:
            self.help_label.setText(
                self.help_label.text() % localize_user_manual_link(
                    'https://manual.calibre-ebook.com/regexp.html'))
        except TypeError:
            pass  # link already localized

        self.test_button.clicked.connect(self.do_test)
        self.re.lineEdit().returnPressed[()].connect(self.do_test)
        self.filename.returnPressed[()].connect(self.do_test)
        connect_lambda(self.re.lineEdit().textChanged, self,
                       lambda self, x: self.changed_signal.emit())

    def initialize(self, defaults=False):
        # Get all items in the combobox. If we are resetting
        # to defaults we don't want to lose what the user
        # has added.
        val_hist = [str(self.re.lineEdit().text())] + [
            str(self.re.itemText(i)) for i in range(self.re.count())
        ]
        self.re.clear()

        if defaults:
            val = prefs.defaults['filename_pattern']
        else:
            val = prefs['filename_pattern']
        self.re.lineEdit().setText(val)

        val_hist += gprefs.get('filename_pattern_history', [
            '(?P<title>.+)',
            r'(?P<author>[^_-]+) -?\s*(?P<series>[^_0-9-]*)(?P<series_index>[0-9]*)\s*-\s*(?P<title>[^_].+) ?'
        ])
        if val in val_hist:
            del val_hist[val_hist.index(val)]
        val_hist.insert(0, val)
        for v in val_hist:
            # Ensure we don't have duplicate items.
            if v and self.re.findText(v) == -1:
                self.re.addItem(v)
        self.re.setCurrentIndex(0)

    def do_test(self):
        from calibre.ebooks.metadata import authors_to_string
        from calibre.ebooks.metadata.meta import metadata_from_filename
        fname = str(self.filename.text())
        ext = os.path.splitext(fname)[1][1:].lower()
        if ext not in BOOK_EXTENSIONS:
            return warning_dialog(
                self,
                _('Test file name invalid'),
                _('The file name <b>%s</b> does not appear to end with a'
                  ' file extension. It must end with a file '
                  ' extension like .epub or .mobi') % fname,
                show=True)

        try:
            pat = self.pattern()
        except Exception as err:
            error_dialog(self, _('Invalid regular expression'),
                         _('Invalid regular expression: %s') % err).exec()
            return
        mi = metadata_from_filename(fname, pat)
        if mi.title:
            self.title.setText(mi.title)
        else:
            self.title.setText(_('No match'))
        if mi.authors:
            self.authors.setText(authors_to_string(mi.authors))
        else:
            self.authors.setText(_('No match'))

        if mi.series:
            self.series.setText(mi.series)
        else:
            self.series.setText(_('No match'))

        if mi.series_index is not None:
            self.series_index.setText(str(mi.series_index))
        else:
            self.series_index.setText(_('No match'))

        if mi.publisher:
            self.publisher.setText(mi.publisher)
        else:
            self.publisher.setText(_('No match'))

        if mi.pubdate:
            self.pubdate.setText(strftime('%Y-%m-%d', mi.pubdate))
        else:
            self.pubdate.setText(_('No match'))

        self.isbn.setText(_('No match') if mi.isbn is None else str(mi.isbn))
        self.comments.setText(mi.comments if mi.comments else _('No match'))

    def pattern(self):
        pat = str(self.re.lineEdit().text())
        return re.compile(pat)

    def commit(self):
        pat = self.pattern().pattern
        prefs['filename_pattern'] = pat

        history = []
        history_pats = [str(self.re.lineEdit().text())] + [
            str(self.re.itemText(i)) for i in range(self.re.count())
        ]
        for p in history_pats[:24]:
            # Ensure we don't have duplicate items.
            if p and p not in history:
                history.append(p)
        gprefs['filename_pattern_history'] = history

        return pat
Example #19
0
class HighlightsPanel(QWidget):

    jump_to_cfi = pyqtSignal(object)
    request_highlight_action = pyqtSignal(object, object)
    web_action = pyqtSignal(object, object)
    toggle_requested = pyqtSignal()
    notes_edited_signal = pyqtSignal(object, object)

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.l = l = QVBoxLayout(self)
        l.setContentsMargins(0, 0, 0, 0)
        self.search_input = si = SearchInput(self, 'highlights-search')
        si.do_search.connect(self.search_requested)
        l.addWidget(si)

        la = QLabel(_('Double click to jump to an entry'))
        la.setWordWrap(True)
        l.addWidget(la)

        self.highlights = h = Highlights(self)
        l.addWidget(h)
        h.jump_to_highlight.connect(self.jump_to_highlight)
        h.delete_requested.connect(self.remove_highlight)
        h.edit_requested.connect(self.edit_highlight)
        h.edit_notes_requested.connect(self.edit_notes)
        h.current_highlight_changed.connect(self.current_highlight_changed)
        self.load = h.load
        self.refresh = h.refresh

        self.h = h = QHBoxLayout()

        def button(icon, text, tt, target):
            b = QPushButton(QIcon(I(icon)), text, self)
            b.setToolTip(tt)
            b.setFocusPolicy(Qt.FocusPolicy.NoFocus)
            b.clicked.connect(target)
            return b

        self.edit_button = button('edit_input.png', _('Modify'),
                                  _('Modify the selected highlight'),
                                  self.edit_highlight)
        self.remove_button = button('trash.png', _('Delete'),
                                    _('Delete the selected highlights'),
                                    self.remove_highlight)
        self.export_button = button('save.png', _('Export'),
                                    _('Export all highlights'), self.export)
        h.addWidget(self.edit_button), h.addWidget(
            self.remove_button), h.addWidget(self.export_button)

        self.notes_display = nd = NotesDisplay(self)
        nd.notes_edited.connect(self.notes_edited)
        l.addWidget(nd)
        nd.setVisible(False)
        l.addLayout(h)

    def notes_edited(self, text):
        h = self.highlights.current_highlight
        if h is not None:
            h['notes'] = text
            self.web_action.emit('set-notes-in-highlight', h)
            self.notes_edited_signal.emit(h['uuid'], text)

    def set_tooltips(self, rmap):
        a = rmap.get('create_annotation')
        if a:

            def as_text(idx):
                return index_to_key_sequence(idx).toString(
                    QKeySequence.SequenceFormat.NativeText)

            tt = self.add_button.toolTip().partition('[')[0].strip()
            keys = sorted(filter(None, map(as_text, a)))
            if keys:
                self.add_button.setToolTip('{} [{}]'.format(
                    tt, ', '.join(keys)))

    def search_requested(self, query):
        if not self.highlights.find_query(query):
            error_dialog(self,
                         _('No matches'),
                         _('No highlights match the search: {}').format(
                             query.text),
                         show=True)

    def focus(self):
        self.highlights.setFocus(Qt.FocusReason.OtherFocusReason)

    def jump_to_highlight(self, highlight):
        self.request_highlight_action.emit(highlight['uuid'], 'goto')

    def current_highlight_changed(self, highlight):
        nd = self.notes_display
        if highlight is None or not highlight.get('notes'):
            nd.show_notes()
        else:
            nd.show_notes(highlight['notes'])

    def no_selected_highlight(self):
        error_dialog(self,
                     _('No selected highlight'),
                     _('No highlight is currently selected'),
                     show=True)

    def edit_highlight(self):
        h = self.highlights.current_highlight
        if h is None:
            return self.no_selected_highlight()
        self.request_highlight_action.emit(h['uuid'], 'edit')

    def edit_notes(self):
        self.notes_display.edit_notes()

    def remove_highlight(self):
        highlights = tuple(self.highlights.selected_highlights)
        if not highlights:
            return self.no_selected_highlight()
        if confirm(ngettext(
                'Are you sure you want to delete this highlight permanently?',
                'Are you sure you want to delete all {} highlights permanently?',
                len(highlights)).format(len(highlights)),
                   'delete-highlight-from-viewer',
                   parent=self,
                   config_set=vprefs):
            for h in highlights:
                self.request_highlight_action.emit(h['uuid'], 'delete')

    def export(self):
        hl = list(self.highlights.all_highlights)
        if not hl:
            return error_dialog(self,
                                _('No highlights'),
                                _('This book has no highlights to export'),
                                show=True)
        Export(hl, self).exec_()

    def selected_text_changed(self, text, annot_id):
        if annot_id:
            self.highlights.find_annot_id(annot_id)

    def keyPressEvent(self, ev):
        sc = get_shortcut_for(self, ev)
        if sc == 'toggle_highlights' or ev.key() == Qt.Key.Key_Escape:
            self.toggle_requested.emit()
        return super().keyPressEvent(ev)
Example #20
0
class LocationManager(QObject):  # {{{

    locations_changed = pyqtSignal()
    unmount_device = pyqtSignal()
    location_selected = pyqtSignal(object)
    configure_device = pyqtSignal()
    update_device_metadata = pyqtSignal()

    def __init__(self, parent=None):
        QObject.__init__(self, parent)
        self.free = [-1, -1, -1]
        self.count = 0
        self.location_actions = QActionGroup(self)
        self.location_actions.setExclusive(True)
        self.current_location = 'library'
        self._mem = []
        self.tooltips = {}

        self.all_actions = []

        def ac(name, text, icon, tooltip):
            icon = QIcon(I(icon))
            ac = self.location_actions.addAction(icon, text)
            setattr(self, 'location_' + name, ac)
            ac.setAutoRepeat(False)
            ac.setCheckable(True)
            receiver = partial(self._location_selected, name)
            ac.triggered.connect(receiver)
            self.tooltips[name] = tooltip

            m = QMenu(parent)
            self._mem.append(m)
            a = m.addAction(icon, tooltip)
            a.triggered.connect(receiver)
            if name != 'library':
                self._mem.append(a)
                a = m.addAction(QIcon(I('eject.png')), _('Eject this device'))
                a.triggered.connect(self._eject_requested)
                self._mem.append(a)
                a = m.addAction(QIcon(I('config.png')),
                                _('Configure this device'))
                a.triggered.connect(self._configure_requested)
                self._mem.append(a)
                a = m.addAction(QIcon(I('sync.png')),
                                _('Update cached metadata on device'))
                a.triggered.connect(
                    lambda x: self.update_device_metadata.emit())
                self._mem.append(a)

            else:
                ac.setToolTip(tooltip)
            ac.setMenu(m)
            ac.calibre_name = name

            self.all_actions.append(ac)
            return ac

        self.library_action = ac('library', _('Library'), 'lt.png',
                                 _('Show books in calibre library'))
        ac('main', _('Device'), 'reader.png',
           _('Show books in the main memory of the device'))
        ac('carda', _('Card A'), 'sd.png', _('Show books in storage card A'))
        ac('cardb', _('Card B'), 'sd.png', _('Show books in storage card B'))

    def set_switch_actions(self, quick_actions, rename_actions, delete_actions,
                           switch_actions, choose_action):
        self.switch_menu = self.library_action.menu()
        if self.switch_menu:
            self.switch_menu.addSeparator()
        else:
            self.switch_menu = QMenu()

        self.switch_menu.addAction(choose_action)
        self.cs_menus = []
        for t, acs in [(_('Quick switch'), quick_actions),
                       (_('Rename library'), rename_actions),
                       (_('Delete library'), delete_actions)]:
            if acs:
                self.cs_menus.append(QMenu(t))
                for ac in acs:
                    self.cs_menus[-1].addAction(ac)
                self.switch_menu.addMenu(self.cs_menus[-1])
        self.switch_menu.addSeparator()
        for ac in switch_actions:
            self.switch_menu.addAction(ac)

        if self.switch_menu != self.library_action.menu():
            self.library_action.setMenu(self.switch_menu)

    def _location_selected(self, location, *args):
        if location != self.current_location and hasattr(
                self, 'location_' + location):
            self.current_location = location
            self.location_selected.emit(location)
            getattr(self, 'location_' + location).setChecked(True)

    def _eject_requested(self, *args):
        self.unmount_device.emit()

    def _configure_requested(self):
        self.configure_device.emit()

    def update_devices(self, cp=(None, None), fs=[-1, -1, -1], icon=None):
        if icon is None:
            icon = I('reader.png')
        self.location_main.setIcon(QIcon(icon))
        had_device = self.has_device
        if cp is None:
            cp = (None, None)
        if isinstance(cp, (bytes, unicode_type)):
            cp = (cp, None)
        if len(fs) < 3:
            fs = list(fs) + [0]
        self.free[0] = fs[0]
        self.free[1] = fs[1]
        self.free[2] = fs[2]
        cpa, cpb = cp
        self.free[1] = fs[1] if fs[1] is not None and cpa is not None else -1
        self.free[2] = fs[2] if fs[2] is not None and cpb is not None else -1
        self.update_tooltips()
        if self.has_device != had_device:
            self.location_library.setChecked(True)
            self.locations_changed.emit()
            if not self.has_device:
                self.location_library.trigger()

    def update_tooltips(self):
        for i, loc in enumerate(('main', 'carda', 'cardb')):
            t = self.tooltips[loc]
            if self.free[i] > -1:
                t += '\n\n%s ' % human_readable(self.free[i]) + _('available')
            ac = getattr(self, 'location_' + loc)
            ac.setToolTip(t)
            ac.setWhatsThis(t)
            ac.setStatusTip(t)

    @property
    def has_device(self):
        return max(self.free) > -1

    @property
    def available_actions(self):
        ans = [self.location_library]
        for i, loc in enumerate(('main', 'carda', 'cardb')):
            if self.free[i] > -1:
                ans.append(getattr(self, 'location_' + loc))
        return ans
Example #21
0
class IdentifyWidget(QWidget):  # {{{

    rejected = pyqtSignal()
    results_found = pyqtSignal()
    book_selected = pyqtSignal(object, object)

    def __init__(self, log, parent=None):
        QWidget.__init__(self, parent)
        self.log = log
        self.abort = Event()
        self.caches = {}

        self.l = l = QVBoxLayout(self)

        names = [
            '<b>' + p.name + '</b>' for p in metadata_plugins(['identify'])
            if p.is_configured()
        ]
        self.top = QLabel('<p>' + _('calibre is downloading metadata from: ') +
                          ', '.join(names))
        self.top.setWordWrap(True)
        l.addWidget(self.top)

        self.splitter = s = QSplitter(self)
        s.setChildrenCollapsible(False)
        l.addWidget(s, 100)
        self.results_view = ResultsView(self)
        self.results_view.book_selected.connect(self.emit_book_selected)
        self.get_result = self.results_view.get_result
        s.addWidget(self.results_view)

        self.comments_view = Comments(self)
        s.addWidget(self.comments_view)
        s.setStretchFactor(0, 2)
        s.setStretchFactor(1, 1)

        self.results_view.show_details_signal.connect(
            self.comments_view.show_data)

        self.query = QLabel('download starting...')
        self.query.setWordWrap(True)
        l.addWidget(self.query)

        self.comments_view.show_wait()
        state = gprefs.get('metadata-download-identify-widget-splitter-state')
        if state is not None:
            s.restoreState(state)

    def save_state(self):
        gprefs['metadata-download-identify-widget-splitter-state'] = bytearray(
            self.splitter.saveState())

    def emit_book_selected(self, book):
        self.book_selected.emit(book, self.caches)

    def start(self, title=None, authors=None, identifiers={}):
        self.log.clear()
        self.log('Starting download')
        parts, simple_desc = [], ''
        if title:
            parts.append('title:' + title)
            simple_desc += _('Title: %s ') % title
        if authors:
            parts.append('authors:' + authors_to_string(authors))
            simple_desc += _('Authors: %s ') % authors_to_string(authors)
        if identifiers:
            x = ', '.join('%s:%s' % (k, v) for k, v in iteritems(identifiers))
            parts.append(x)
            if 'isbn' in identifiers:
                simple_desc += 'ISBN: %s' % identifiers['isbn']
        self.query.setText(simple_desc)
        self.log(str(self.query.text()))

        self.worker = IdentifyWorker(self.log, self.abort, title, authors,
                                     identifiers, self.caches)

        self.worker.start()

        QTimer.singleShot(50, self.update)

    def update(self):
        if self.worker.is_alive():
            QTimer.singleShot(50, self.update)
        else:
            self.process_results()

    def process_results(self):
        if self.worker.error is not None:
            error_dialog(self,
                         _('Download failed'),
                         _('Failed to download metadata. Click '
                           'Show Details to see details'),
                         show=True,
                         det_msg=self.worker.error)
            self.rejected.emit()
            return

        if not self.worker.results:
            log = ''.join(self.log.plain_text)
            error_dialog(
                self,
                _('No matches found'),
                '<p>' +
                _('Failed to find any books that '
                  'match your search. Try making the search <b>less '
                  'specific</b>. For example, use only the author\'s '
                  'last name and a single distinctive word from '
                  'the title.<p>To see the full log, click "Show details".'),
                show=True,
                det_msg=log)
            self.rejected.emit()
            return

        self.results_view.show_results(self.worker.results)
        self.results_found.emit()

    def cancel(self):
        self.abort.set()
Example #22
0
class Page(QWebEnginePage):  # {{{

    elem_clicked = pyqtSignal(object, object, object, object, object)
    frag_shown = pyqtSignal(object)

    def __init__(self, prefs):
        self.log = default_log
        self.current_frag = None
        self.com_id = str(uuid4())
        QWebEnginePage.__init__(self)
        secure_webengine(self.settings(), for_viewer=True)
        self.titleChanged.connect(self.title_changed)
        self.loadFinished.connect(self.show_frag)
        s = QWebEngineScript()
        s.setName('toc.js')
        s.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
        s.setRunsOnSubFrames(True)
        s.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)
        js = P('toc.js', allow_user_override=False,
               data=True).decode('utf-8').replace('COM_ID', self.com_id, 1)
        if 'preview_background' in prefs.defaults and 'preview_foreground' in prefs.defaults:
            from calibre.gui2.tweak_book.preview import get_editor_settings
            settings = get_editor_settings(prefs)
        else:
            if is_dark_theme():
                settings = {
                    'is_dark_theme': True,
                    'bg': dark_color.name(),
                    'fg': dark_text_color.name(),
                    'link': dark_link_color.name(),
                }
            else:
                settings = {}
        js = js.replace('SETTINGS', json.dumps(settings), 1)
        dark_mode_css = P('dark_mode.css',
                          data=True,
                          allow_user_override=False).decode('utf-8')
        js = js.replace('CSS', json.dumps(dark_mode_css), 1)
        s.setSourceCode(js)
        self.scripts().insert(s)

    def javaScriptConsoleMessage(self, level, msg, lineno, msgid):
        self.log('JS:', str(msg))

    def javaScriptAlert(self, origin, msg):
        self.log(str(msg))

    def title_changed(self, title):
        parts = title.split('-', 1)
        if len(parts) == 2 and parts[0] == self.com_id:
            self.runJavaScript('JSON.stringify(window.calibre_toc_data)',
                               QWebEngineScript.ScriptWorldId.ApplicationWorld,
                               self.onclick)

    def onclick(self, data):
        try:
            tag, elem_id, loc, totals, frac = json.loads(data)
        except Exception:
            return
        elem_id = elem_id or None
        self.elem_clicked.emit(tag, frac, elem_id, loc, totals)

    def show_frag(self, ok):
        if ok and self.current_frag:
            self.runJavaScript('''
                document.location = '#non-existent-anchor';
                document.location = '#' + {};
            '''.format(json.dumps(self.current_frag)))
            self.current_frag = None
            self.runJavaScript('window.pageYOffset/document.body.scrollHeight',
                               QWebEngineScript.ScriptWorldId.ApplicationWorld,
                               self.frag_shown.emit)
Example #23
0
class CoversWidget(QWidget):  # {{{

    chosen = pyqtSignal()
    finished = pyqtSignal()

    def __init__(self, log, current_cover, parent=None):
        QWidget.__init__(self, parent)
        self.log = log
        self.abort = Event()

        self.l = l = QGridLayout()
        self.setLayout(l)

        self.msg = QLabel()
        self.msg.setWordWrap(True)
        l.addWidget(self.msg, 0, 0)

        self.covers_view = CoversView(current_cover, self)
        self.covers_view.chosen.connect(self.chosen)
        l.addWidget(self.covers_view, 1, 0)
        self.continue_processing = True

    def reset_covers(self):
        self.covers_view.reset_covers()

    def start(self, book, current_cover, title, authors, caches):
        self.continue_processing = True
        self.abort.clear()
        self.book, self.current_cover = book, current_cover
        self.title, self.authors = title, authors
        self.log('Starting cover download for:', book.title)
        self.log('Query:', title, authors, self.book.identifiers)
        self.msg.setText(
            '<p>' +
            _('Downloading covers for <b>%s</b>, please wait...') % book.title)
        self.covers_view.start()

        self.worker = CoverWorker(self.log, self.abort, self.title,
                                  self.authors, book.identifiers, caches)
        self.worker.start()
        QTimer.singleShot(50, self.check)
        self.covers_view.setFocus(Qt.FocusReason.OtherFocusReason)

    def check(self):
        if self.worker.is_alive() and not self.abort.is_set():
            QTimer.singleShot(50, self.check)
            try:
                self.process_result(self.worker.rq.get_nowait())
            except Empty:
                pass
        else:
            self.process_results()

    def process_results(self):
        while self.continue_processing:
            try:
                self.process_result(self.worker.rq.get_nowait())
            except Empty:
                break

        if self.continue_processing:
            self.covers_view.clear_failed()

        if self.worker.error and self.worker.error.strip():
            error_dialog(self,
                         _('Download failed'),
                         _('Failed to download any covers, click'
                           ' "Show details" for details.'),
                         det_msg=self.worker.error,
                         show=True)

        num = self.covers_view.model().rowCount()
        if num < 2:
            txt = _(
                'Could not find any covers for <b>%s</b>') % self.book.title
        else:
            if num == 2:
                txt = _('Found a cover for {title}').format(title=self.title)
            else:
                txt = _(
                    'Found <b>{num}</b> covers for {title}. When the download completes,'
                    ' the covers will be sorted by size.').format(
                        title=self.title, num=num - 1)
        self.msg.setText(txt)
        self.msg.setWordWrap(True)
        self.covers_view.stop()

        self.finished.emit()

    def process_result(self, result):
        if not self.continue_processing:
            return
        plugin_name, width, height, fmt, data = result
        self.covers_view.model().update_result(plugin_name, width, height,
                                               data)

    def cleanup(self):
        self.covers_view.delegate.stop_animation()
        self.continue_processing = False

    def cancel(self):
        self.cleanup()
        self.abort.set()

    def cover_pixmap(self):
        idx = None
        for i in self.covers_view.selectionModel().selectedIndexes():
            if i.isValid():
                idx = i
                break
        if idx is None:
            idx = self.covers_view.currentIndex()
        return self.covers_view.model().cover_pixmap(idx)
Example #24
0
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 self.selected_text_from_cursor(c)

    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 select_class_name_at_cursor(self, cursor):
        valid = re.compile(r'[\w_0-9\-]+', flags=re.UNICODE)

        def keep_going():
            q = cursor.selectedText()
            m = valid.match(q)
            return m is not None and m.group() == q

        def run_loop(forward=True):
            cursor.setPosition(pos)
            n, p = QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveOperation.PreviousCharacter
            if not forward:
                n, p = p, n
            while True:
                if not cursor.movePosition(n, QTextCursor.MoveMode.KeepAnchor):
                    break
                if not keep_going():
                    cursor.movePosition(p, QTextCursor.MoveMode.KeepAnchor)
                    break
            ans = cursor.position()
            cursor.setPosition(pos)
            return ans

        pos = cursor.position()
        forwards_limit = run_loop()
        backwards_limit = run_loop(forward=False)
        cursor.setPosition(backwards_limit)
        cursor.setPosition(forwards_limit, QTextCursor.MoveMode.KeepAnchor)
        return self.selected_text_from_cursor(cursor)

    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):
            class_name = self.select_class_name_at_cursor(c)
            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)
Example #25
0
class ChooseTheme(Dialog):

    cover_downloaded = pyqtSignal(object, object)
    themes_downloaded = pyqtSignal()

    def __init__(self, parent=None):
        try:
            self.current_theme = json.loads(I('icon-theme.json',
                                              data=True))['title']
        except Exception:
            self.current_theme = None
        Dialog.__init__(self, _('Choose an icon theme'),
                        'choose-icon-theme-dialog', parent)
        self.finished.connect(self.on_finish)
        self.dialog_closed = False
        self.themes_downloaded.connect(self.show_themes,
                                       type=Qt.ConnectionType.QueuedConnection)
        self.cover_downloaded.connect(self.set_cover,
                                      type=Qt.ConnectionType.QueuedConnection)
        self.keep_downloading = True
        self.commit_changes = None
        self.new_theme_title = None

    def on_finish(self):
        self.dialog_closed = True

    def sizeHint(self):
        h = self.screen().availableSize().height()
        return QSize(900, h - 75)

    def setup_ui(self):
        self.vl = vl = QVBoxLayout(self)
        self.stack = l = QStackedLayout()
        self.pi = pi = ProgressIndicator(self, 256)
        vl.addLayout(l), vl.addWidget(self.bb)
        self.restore_defs_button = b = self.bb.addButton(
            _('Restore &default icons'),
            QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.restore_defaults)
        b.setIcon(QIcon(I('view-refresh.png')))
        self.c = c = QWidget(self)
        self.c.v = v = QVBoxLayout(self.c)
        v.addStretch(), v.addWidget(pi, 0, Qt.AlignmentFlag.AlignCenter)
        self.wait_msg = m = QLabel(self)
        v.addWidget(m, 0, Qt.AlignmentFlag.AlignCenter), v.addStretch()
        f = m.font()
        f.setBold(True), f.setPointSize(28), m.setFont(f)
        self.start_spinner()

        l.addWidget(c)
        self.w = w = QWidget(self)
        l.addWidget(w)
        w.l = l = QGridLayout(w)

        def add_row(x, y=None):
            if isinstance(x, str):
                x = QLabel(x)
            row = l.rowCount()
            if y is None:
                if isinstance(x, QLabel):
                    x.setWordWrap(True)
                l.addWidget(x, row, 0, 1, 2)
            else:
                if isinstance(x, QLabel):
                    x.setBuddy(y)
                l.addWidget(x, row, 0), l.addWidget(y, row, 1)

        add_row(
            _('Choose an icon theme below. You will need to restart'
              ' calibre to see the new icons.'))
        add_row(
            _('Current icon theme:') + '\xa0<b>' +
            (self.current_theme or 'None'))
        self.sort_by = sb = QComboBox(self)
        add_row(_('&Sort by:'), sb)
        sb.addItems([
            _('Number of icons'),
            _('Popularity'),
            _('Name'),
        ])
        sb.setEditable(False), sb.setCurrentIndex(
            gprefs.get('choose_icon_theme_sort_by', 1))
        sb.currentIndexChanged[int].connect(self.re_sort)
        sb.currentIndexChanged[int].connect(
            lambda: gprefs.set('choose_icon_theme_sort_by', sb.currentIndex()))
        self.theme_list = tl = QListWidget(self)
        tl.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.delegate = Delegate(tl)
        tl.setItemDelegate(self.delegate)
        tl.itemDoubleClicked.connect(self.accept)
        tl.itemPressed.connect(self.item_clicked)
        add_row(tl)

        t = Thread(name='GetIconThemes', target=self.get_themes)
        t.daemon = True
        t.start()

    def item_clicked(self, item):
        if QApplication.mouseButtons() & Qt.MouseButton.RightButton:
            theme = item.data(Qt.ItemDataRole.UserRole) or {}
            url = theme.get('url')
            if url:
                safe_open_url(url)
            else:
                error_dialog(self,
                             _('No homepage'),
                             _('The {} theme has no homepage').format(
                                 theme.get('name', _('Unknown'))),
                             show=True)

    def start_spinner(self, msg=None):
        self.pi.startAnimation()
        self.stack.setCurrentIndex(0)
        self.wait_msg.setText(msg or _('Downloading, please wait...'))

    def end_spinner(self):
        self.pi.stopAnimation()
        self.stack.setCurrentIndex(1)

    @property
    def sort_on(self):
        return {
            0: 'number',
            1: 'usage',
            2: 'title'
        }[self.sort_by.currentIndex()]

    def re_sort(self):
        self.themes.sort(key=lambda x: sort_key(x.get('title', '')))
        field = self.sort_on
        if field == 'number':
            self.themes.sort(key=lambda x: x.get('number', 0), reverse=True)
        elif field == 'usage':
            self.themes.sort(key=lambda x: self.usage.get(x.get('name'), 0),
                             reverse=True)
        self.theme_list.clear()
        for theme in self.themes:
            i = QListWidgetItem(
                theme.get('title', '') + ' {} {}'.format(
                    theme.get('number'), self.usage.get(theme.get('name'))),
                self.theme_list)
            i.setData(Qt.ItemDataRole.UserRole, theme)
            if 'cover-pixmap' in theme:
                i.setData(Qt.ItemDataRole.DecorationRole,
                          theme['cover-pixmap'])

    def get_themes(self):

        self.usage = {}

        def get_usage():
            try:
                self.usage = json.loads(
                    bz2.decompress(
                        get_https_resource_securely(BASE_URL +
                                                    '/usage.json.bz2')))
            except Exception:
                import traceback
                traceback.print_exc()

        t = Thread(name='IconThemeUsage', target=get_usage)
        t.daemon = True
        t.start()

        try:
            self.themes = json.loads(
                bz2.decompress(
                    get_https_resource_securely(BASE_URL +
                                                '/themes.json.bz2')))
        except Exception:
            import traceback
            self.themes = traceback.format_exc()
        t.join()
        if not sip.isdeleted(self):
            self.themes_downloaded.emit()

    def show_themes(self):
        self.end_spinner()
        if not isinstance(self.themes, list):
            error_dialog(
                self,
                _('Failed to download list of themes'),
                _('Failed to download list of themes, click "Show details" for more information'
                  ),
                det_msg=self.themes,
                show=True)
            self.reject()
            return
        for theme in self.themes:
            theme['usage'] = self.usage.get(theme['name'], 0)
        self.re_sort()
        get_covers(self.themes, self)

    def __iter__(self):
        for i in range(self.theme_list.count()):
            yield self.theme_list.item(i)

    def item_from_name(self, name):
        for item in self:
            if item.data(Qt.ItemDataRole.UserRole)['name'] == name:
                return item

    def set_cover(self, theme, cdata):
        theme['cover-pixmap'] = p = QPixmap()
        try:
            dpr = self.devicePixelRatioF()
        except AttributeError:
            dpr = self.devicePixelRatio()
        if isinstance(cdata, bytes):
            p.loadFromData(cdata)
            p.setDevicePixelRatio(dpr)
        item = self.item_from_name(theme['name'])
        if item is not None:
            item.setData(Qt.ItemDataRole.DecorationRole, p)

    def restore_defaults(self):
        if self.current_theme is not None:
            if not question_dialog(
                    self, _('Are you sure?'),
                    _('Are you sure you want to remove the <b>%s</b> icon theme'
                      ' and return to the stock icons?') % self.current_theme):
                return
        self.commit_changes = remove_icon_theme
        Dialog.accept(self)

    def accept(self):
        if self.theme_list.currentRow() < 0:
            return error_dialog(self,
                                _('No theme selected'),
                                _('You must first select an icon theme'),
                                show=True)
        theme = self.theme_list.currentItem().data(Qt.ItemDataRole.UserRole)
        url = BASE_URL + theme['icons-url']
        size = theme['compressed-size']
        theme = {k: theme.get(k, '') for k in 'name title version'.split()}
        self.keep_downloading = True
        d = DownloadProgress(self, size)
        d.canceled_signal.connect(
            lambda: setattr(self, 'keep_downloading', False))

        self.downloaded_theme = None

        def download():
            self.downloaded_theme = buf = BytesIO()
            try:
                response = get_https_resource_securely(url, get_response=True)
                while self.keep_downloading:
                    raw = response.read(1024)
                    if not raw:
                        break
                    buf.write(raw)
                    d.downloaded(buf.tell())
                d.queue_accept()
            except Exception:
                import traceback
                self.downloaded_theme = traceback.format_exc()
                d.queue_reject()

        t = Thread(name='DownloadIconTheme', target=download)
        t.daemon = True
        t.start()
        ret = d.exec()

        if self.downloaded_theme and not isinstance(self.downloaded_theme,
                                                    BytesIO):
            return error_dialog(
                self,
                _('Download failed'),
                _('Failed to download icon theme, click "Show details" for more information.'
                  ),
                show=True,
                det_msg=self.downloaded_theme)
        if ret == QDialog.DialogCode.Rejected or not self.keep_downloading or d.canceled or self.downloaded_theme is None:
            return
        dt = self.downloaded_theme

        def commit_changes():
            import lzma
            dt.seek(0)
            f = BytesIO(lzma.decompress(dt.getvalue()))
            f.seek(0)
            remove_icon_theme()
            install_icon_theme(theme, f)

        self.commit_changes = commit_changes
        self.new_theme_title = theme['title']
        return Dialog.accept(self)
Example #26
0
class AddCover(Dialog):

    import_requested = pyqtSignal(object, object)

    def __init__(self, container, parent=None):
        self.container = container
        Dialog.__init__(self, _('Add a cover'), 'add-cover-wizard', parent)

    @property
    def image_names(self):
        img_types = {guess_type('a.' + x) for x in ('png', 'jpeg', 'gif')}
        for name, mt in iteritems(self.container.mime_map):
            if mt.lower() in img_types:
                yield name

    def setup_ui(self):
        self.l = l = QVBoxLayout(self)
        self.setLayout(l)
        self.gb = gb = QGroupBox(_('&Images in book'), self)
        self.v = v = QVBoxLayout(gb)
        gb.setLayout(v), gb.setFlat(True)
        self.names, self.names_filter = create_filterable_names_list(
            sorted(self.image_names, key=sort_key),
            filter_text=_('Filter the list of images'),
            parent=self)
        self.names.doubleClicked.connect(
            self.double_clicked, type=Qt.ConnectionType.QueuedConnection)
        self.cover_view = CoverView(self)
        l.addWidget(self.names_filter)
        v.addWidget(self.names)

        self.splitter = s = QSplitter(self)
        l.addWidget(s)
        s.addWidget(gb)
        s.addWidget(self.cover_view)

        self.h = h = QHBoxLayout()
        self.preserve = p = QCheckBox(_('Preserve aspect ratio'))
        p.setToolTip(
            textwrap.fill(
                _('If enabled the cover image you select will be embedded'
                  ' into the book in such a way that when viewed, its aspect'
                  ' ratio (ratio of width to height) will be preserved.'
                  ' This will mean blank spaces around the image if the screen'
                  ' the book is being viewed on has an aspect ratio different'
                  ' to the image.')))
        p.setChecked(tprefs['add_cover_preserve_aspect_ratio'])
        p.setVisible(self.container.book_type != 'azw3')

        def on_state_change(s):
            tprefs.set('add_cover_preserve_aspect_ratio',
                       s == Qt.CheckState.Checked)

        p.stateChanged.connect(on_state_change)
        self.info_label = il = QLabel('\xa0')
        h.addWidget(p), h.addStretch(1), h.addWidget(il)
        l.addLayout(h)

        l.addWidget(self.bb)
        b = self.bb.addButton(_('Import &image'),
                              QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.import_image)
        b.setIcon(QIcon(I('document_open.png')))
        self.names.setFocus(Qt.FocusReason.OtherFocusReason)
        self.names.selectionModel().currentChanged.connect(
            self.current_image_changed)
        cname = get_raster_cover_name(self.container)
        if cname:
            row = self.names.model().find_name(cname)
            if row > -1:
                self.names.setCurrentIndex(self.names.model().index(row))

    def double_clicked(self):
        self.accept()

    @property
    def file_name(self):
        return self.names.model().name_for_index(self.names.currentIndex())

    def current_image_changed(self):
        self.info_label.setText('')
        name = self.file_name
        if name is not None:
            data = self.container.raw_data(name, decode=False)
            self.cover_view.set_pixmap(data)
            self.info_label.setText('{0}x{1}px | {2}'.format(
                self.cover_view.pixmap.width(),
                self.cover_view.pixmap.height(), human_readable(len(data))))

    def import_image(self):
        ans = choose_images(self,
                            'add-cover-choose-image',
                            _('Choose a cover image'),
                            formats=('jpg', 'jpeg', 'png', 'gif'))
        if ans:
            from calibre.gui2.tweak_book.file_list import NewFileDialog
            d = NewFileDialog(self)
            d.do_import_file(ans[0], hide_button=True)
            if d.exec_() == QDialog.DialogCode.Accepted:
                self.import_requested.emit(d.file_name, d.file_data)
                self.container = current_container()
                self.names_filter.clear()
                self.names.model().set_names(
                    sorted(self.image_names, key=sort_key))
                i = self.names.model().find_name(d.file_name)
                self.names.setCurrentIndex(self.names.model().index(i))
                self.current_image_changed()

    @classmethod
    def test(cls):
        import sys
        from calibre.ebooks.oeb.polish.container import get_container
        c = get_container(sys.argv[-1], tweak_mode=True)
        d = cls(c)
        if d.exec_() == QDialog.DialogCode.Accepted:
            pass
class CoverView(QWidget):  # {{{

    cover_changed = pyqtSignal(object, object)
    cover_removed = pyqtSignal(object)
    open_cover_with = pyqtSignal(object, object)
    search_internet = pyqtSignal(object)

    def __init__(self, vertical, parent=None):
        QWidget.__init__(self, parent)
        self._current_pixmap_size = QSize(120, 120)
        self.vertical = vertical

        self.animation = QPropertyAnimation(self, b'current_pixmap_size', self)
        self.animation.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutExpo))
        self.animation.setDuration(1000)
        self.animation.setStartValue(QSize(0, 0))
        self.animation.valueChanged.connect(self.value_changed)

        self.setSizePolicy(
                QSizePolicy.Policy.Expanding if vertical else QSizePolicy.Policy.Minimum,
                QSizePolicy.Policy.Expanding)

        self.default_pixmap = QPixmap(I('default_cover.png'))
        self.pixmap = self.default_pixmap
        self.pwidth = self.pheight = None
        self.data = {}

        self.do_layout()

    def value_changed(self, val):
        self.update()

    def setCurrentPixmapSize(self, val):
        self._current_pixmap_size = val

    def do_layout(self):
        if self.rect().width() == 0 or self.rect().height() == 0:
            return
        pixmap = self.pixmap
        pwidth, pheight = pixmap.width(), pixmap.height()
        try:
            self.pwidth, self.pheight = fit_image(pwidth, pheight,
                            self.rect().width(), self.rect().height())[1:]
        except:
            self.pwidth, self.pheight = self.rect().width()-1, \
                    self.rect().height()-1
        self.current_pixmap_size = QSize(self.pwidth, self.pheight)
        self.animation.setEndValue(self.current_pixmap_size)

    def show_data(self, data):
        self.animation.stop()
        same_item = getattr(data, 'id', True) == self.data.get('id', False)
        self.data = {'id':data.get('id', None)}
        if data.cover_data[1]:
            self.pixmap = QPixmap.fromImage(data.cover_data[1])
            if self.pixmap.isNull() or self.pixmap.width() < 5 or \
                    self.pixmap.height() < 5:
                self.pixmap = self.default_pixmap
        else:
            self.pixmap = self.default_pixmap
        self.do_layout()
        self.update()
        if (not same_item and not config['disable_animations'] and
                self.isVisible()):
            self.animation.start()

    def paintEvent(self, event):
        canvas_size = self.rect()
        width = self.current_pixmap_size.width()
        extrax = canvas_size.width() - width
        if extrax < 0:
            extrax = 0
        x = int(extrax//2)
        height = self.current_pixmap_size.height()
        extray = canvas_size.height() - height
        if extray < 0:
            extray = 0
        y = int(extray//2)
        target = QRect(x, y, width, height)
        p = QPainter(self)
        p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
        try:
            dpr = self.devicePixelRatioF()
        except AttributeError:
            dpr = self.devicePixelRatio()
        spmap = self.pixmap.scaled(target.size() * dpr, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
        spmap.setDevicePixelRatio(dpr)
        p.drawPixmap(target, spmap)
        if gprefs['bd_overlay_cover_size']:
            sztgt = target.adjusted(0, 0, 0, -4)
            f = p.font()
            f.setBold(True)
            p.setFont(f)
            sz = '\u00a0%d x %d\u00a0'%(self.pixmap.width(), self.pixmap.height())
            flags = Qt.AlignmentFlag.AlignBottom|Qt.AlignmentFlag.AlignRight|Qt.TextFlag.TextSingleLine
            szrect = p.boundingRect(sztgt, flags, sz)
            p.fillRect(szrect.adjusted(0, 0, 0, 4), QColor(0, 0, 0, 200))
            p.setPen(QPen(QColor(255,255,255)))
            p.drawText(sztgt, flags, sz)
        p.end()

    current_pixmap_size = pyqtProperty('QSize',
            fget=lambda self: self._current_pixmap_size,
            fset=setCurrentPixmapSize
            )

    def contextMenuEvent(self, ev):
        cm = QMenu(self)
        paste = cm.addAction(_('Paste cover'))
        copy = cm.addAction(_('Copy cover'))
        save = cm.addAction(_('Save cover to disk'))
        remove = cm.addAction(_('Remove cover'))
        gc = cm.addAction(_('Generate cover from metadata'))
        cm.addSeparator()
        if not QApplication.instance().clipboard().mimeData().hasImage():
            paste.setEnabled(False)
        copy.triggered.connect(self.copy_to_clipboard)
        paste.triggered.connect(self.paste_from_clipboard)
        remove.triggered.connect(self.remove_cover)
        gc.triggered.connect(self.generate_cover)
        save.triggered.connect(self.save_cover)
        create_open_cover_with_menu(self, cm)
        cm.si = m = create_search_internet_menu(self.search_internet.emit)
        cm.addMenu(m)
        cm.exec_(ev.globalPos())

    def open_with(self, entry):
        id_ = self.data.get('id', None)
        if id_ is not None:
            self.open_cover_with.emit(id_, entry)

    def choose_open_with(self):
        from calibre.gui2.open_with import choose_program
        entry = choose_program('cover_image', self)
        if entry is not None:
            self.open_with(entry)

    def copy_to_clipboard(self):
        QApplication.instance().clipboard().setPixmap(self.pixmap)

    def paste_from_clipboard(self, pmap=None):
        if not isinstance(pmap, QPixmap):
            cb = QApplication.instance().clipboard()
            pmap = cb.pixmap()
            if pmap.isNull() and cb.supportsSelection():
                pmap = cb.pixmap(QClipboard.Mode.Selection)
        if not pmap.isNull():
            self.update_cover(pmap)

    def save_cover(self):
        from calibre.gui2.ui import get_gui
        book_id = self.data.get('id')
        db = get_gui().current_db.new_api
        path = choose_save_file(
            self, 'save-cover-from-book-details', _('Choose cover save location'),
            filters=[(_('JPEG images'), ['jpg', 'jpeg'])], all_files=False,
            initial_filename='{}.jpeg'.format(sanitize_file_name(db.field_for('title', book_id, default_value='cover')))
        )
        if path:
            db.copy_cover_to(book_id, path)

    def update_cover(self, pmap=None, cdata=None):
        if pmap is None:
            pmap = QPixmap()
            pmap.loadFromData(cdata)
        if pmap.isNull():
            return
        if pmap.hasAlphaChannel():
            pmap = QPixmap.fromImage(blend_image(image_from_x(pmap)))
        self.pixmap = pmap
        self.do_layout()
        self.update()
        self.update_tooltip(getattr(self.parent(), 'current_path', ''))
        if not config['disable_animations']:
            self.animation.start()
        id_ = self.data.get('id', None)
        if id_ is not None:
            self.cover_changed.emit(id_, cdata or pixmap_to_data(pmap))

    def generate_cover(self, *args):
        book_id = self.data.get('id')
        if book_id is not None:
            from calibre.ebooks.covers import generate_cover
            from calibre.gui2.ui import get_gui
            mi = get_gui().current_db.new_api.get_metadata(book_id)
            cdata = generate_cover(mi)
            self.update_cover(cdata=cdata)

    def remove_cover(self):
        if not confirm_delete(
            _('Are you sure you want to delete the cover permanently?'),
                'book-details-confirm-cover-remove', parent=self):
            return
        id_ = self.data.get('id', None)
        self.pixmap = self.default_pixmap
        self.do_layout()
        self.update()
        if id_ is not None:
            self.cover_removed.emit(id_)

    def update_tooltip(self, current_path):
        try:
            sz = self.pixmap.size()
        except:
            sz = QSize(0, 0)
        self.setToolTip(
            '<p>'+_('Double click to open the Book details window') +
            '<br><br>' + _('Path') + ': ' + current_path +
            '<br><br>' + _('Cover size: %(width)d x %(height)d pixels')%dict(
                width=sz.width(), height=sz.height())
        )
Example #28
0
class Results(QWidget):

    MARGIN = 4

    item_selected = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent=parent)

        self.setSizePolicy(QSizePolicy.Policy.Expanding,
                           QSizePolicy.Policy.Expanding)
        self.results = ()
        self.current_result = -1
        self.max_result = -1
        self.mouse_hover_result = -1
        self.setMouseTracking(True)
        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.text_option = to = QTextOption()
        to.setWrapMode(QTextOption.WrapMode.NoWrap)
        self.divider = QStaticText('\xa0→ \xa0')
        self.divider.setTextFormat(Qt.TextFormat.PlainText)

    def item_from_y(self, y):
        if not self.results:
            return
        delta = self.results[0][0].size().height() + self.MARGIN
        maxy = self.height()
        pos = 0
        for i, r in enumerate(self.results):
            bottom = pos + delta
            if pos <= y < bottom:
                return i
                break
            pos = bottom
            if pos > min(y, maxy):
                break
        return -1

    def mouseMoveEvent(self, ev):
        y = ev.pos().y()
        prev = self.mouse_hover_result
        self.mouse_hover_result = self.item_from_y(y)
        if prev != self.mouse_hover_result:
            self.update()

    def mousePressEvent(self, ev):
        if ev.button() == 1:
            i = self.item_from_y(ev.pos().y())
            if i != -1:
                ev.accept()
                self.current_result = i
                self.update()
                self.item_selected.emit()
                return
        return QWidget.mousePressEvent(self, ev)

    def change_current(self, delta=1):
        if not self.results:
            return
        nc = self.current_result + delta
        if 0 <= nc <= self.max_result:
            self.current_result = nc
            self.update()

    def __call__(self, results):
        if results:
            self.current_result = 0
            prefixes = [
                QStaticText('<b>%s</b>' % os.path.basename(x)) for x in results
            ]
            [(p.setTextFormat(Qt.TextFormat.RichText),
              p.setTextOption(self.text_option)) for p in prefixes]
            self.maxwidth = max([x.size().width() for x in prefixes])
            self.results = tuple(
                (prefix, self.make_text(text, positions), text)
                for prefix, (text,
                             positions) in zip(prefixes, iteritems(results)))
        else:
            self.results = ()
            self.current_result = -1
        self.max_result = min(10, len(self.results) - 1)
        self.mouse_hover_result = -1
        self.update()

    def make_text(self, text, positions):
        text = QStaticText(
            make_highlighted_text(emphasis_style(), text, positions))
        text.setTextOption(self.text_option)
        text.setTextFormat(Qt.TextFormat.RichText)
        return text

    def paintEvent(self, ev):
        offset = QPoint(0, 0)
        p = QPainter(self)
        p.setClipRect(ev.rect())
        bottom = self.rect().bottom()

        if self.results:
            for i, (prefix, full, text) in enumerate(self.results):
                size = prefix.size()
                if offset.y() + size.height() > bottom:
                    break
                self.max_result = i
                offset.setX(0)
                if i in (self.current_result, self.mouse_hover_result):
                    p.save()
                    if i != self.current_result:
                        p.setPen(Qt.PenStyle.DotLine)
                    p.drawLine(offset, QPoint(self.width(), offset.y()))
                    p.restore()
                offset.setY(offset.y() + self.MARGIN // 2)
                p.drawStaticText(offset, prefix)
                offset.setX(self.maxwidth + 5)
                p.drawStaticText(offset, self.divider)
                offset.setX(offset.x() + self.divider.size().width())
                p.drawStaticText(offset, full)
                offset.setY(offset.y() + size.height() + self.MARGIN // 2)
                if i in (self.current_result, self.mouse_hover_result):
                    offset.setX(0)
                    p.save()
                    if i != self.current_result:
                        p.setPen(Qt.PenStyle.DotLine)
                    p.drawLine(offset, QPoint(self.width(), offset.y()))
                    p.restore()
        else:
            p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter,
                       _('No results found'))

        p.end()

    @property
    def selected_result(self):
        try:
            return self.results[self.current_result][-1]
        except IndexError:
            pass
class BookDetails(QWidget):  # {{{

    show_book_info = pyqtSignal()
    open_containing_folder = pyqtSignal(int)
    view_specific_format = pyqtSignal(int, object)
    search_requested = pyqtSignal(object)
    remove_specific_format = pyqtSignal(int, object)
    remove_metadata_item = pyqtSignal(int, object, object)
    save_specific_format = pyqtSignal(int, object)
    restore_specific_format = pyqtSignal(int, object)
    set_cover_from_format = pyqtSignal(int, object)
    compare_specific_format = pyqtSignal(int, object)
    copy_link = pyqtSignal(object)
    remote_file_dropped = pyqtSignal(object, object)
    files_dropped = pyqtSignal(object, object)
    cover_changed = pyqtSignal(object, object)
    open_cover_with = pyqtSignal(object, object)
    cover_removed = pyqtSignal(object)
    view_device_book = pyqtSignal(object)
    manage_category = pyqtSignal(object, object)
    edit_identifiers = pyqtSignal()
    open_fmt_with = pyqtSignal(int, object, object)
    edit_book = pyqtSignal(int, object)
    find_in_tag_browser = pyqtSignal(object, object)

    # Drag 'n drop {{{

    def dragEnterEvent(self, event):
        md = event.mimeData()
        if dnd_has_extension(md, image_extensions() + BOOK_EXTENSIONS, allow_all_extensions=True, allow_remote=True) or \
                dnd_has_image(md):
            event.acceptProposedAction()

    def dropEvent(self, event):
        event.setDropAction(Qt.DropAction.CopyAction)
        md = event.mimeData()

        image_exts = set(image_extensions()) - set(tweaks['cover_drop_exclude'])
        x, y = dnd_get_image(md, image_exts)
        if x is not None:
            # We have an image, set cover
            event.accept()
            if y is None:
                # Local image
                self.cover_view.paste_from_clipboard(x)
                self.update_layout()
            else:
                self.remote_file_dropped.emit(x, y)
                # We do not support setting cover *and* adding formats for
                # a remote drop, anyway, so return
                return

        # Now look for ebook files
        urls, filenames = dnd_get_files(md, BOOK_EXTENSIONS, allow_all_extensions=True, filter_exts=image_exts)
        if not urls:
            # Nothing found
            return

        if not filenames:
            # Local files
            self.files_dropped.emit(event, urls)
        else:
            # Remote files, use the first file
            self.remote_file_dropped.emit(urls[0], filenames[0])
        event.accept()

    def dragMoveEvent(self, event):
        event.acceptProposedAction()

    # }}}

    def __init__(self, vertical, parent=None):
        QWidget.__init__(self, parent)
        self.last_data = {}
        self.setAcceptDrops(True)
        self._layout = DetailsLayout(vertical, self)
        self.setLayout(self._layout)
        self.current_path = ''

        self.cover_view = CoverView(vertical, self)
        self.cover_view.search_internet.connect(self.search_internet)
        self.cover_view.cover_changed.connect(self.cover_changed.emit)
        self.cover_view.open_cover_with.connect(self.open_cover_with.emit)
        self.cover_view.cover_removed.connect(self.cover_removed.emit)
        self._layout.addWidget(self.cover_view)
        self.book_info = BookInfo(vertical, self)
        self.book_info.show_book_info = self.show_book_info
        self.book_info.search_internet = self.search_internet
        self.book_info.search_requested = self.search_requested.emit
        self._layout.addWidget(self.book_info)
        self.book_info.link_clicked.connect(self.handle_click)
        self.book_info.remove_format.connect(self.remove_specific_format)
        self.book_info.remove_item.connect(self.remove_metadata_item)
        self.book_info.open_fmt_with.connect(self.open_fmt_with)
        self.book_info.edit_book.connect(self.edit_book)
        self.book_info.save_format.connect(self.save_specific_format)
        self.book_info.restore_format.connect(self.restore_specific_format)
        self.book_info.set_cover_format.connect(self.set_cover_from_format)
        self.book_info.compare_format.connect(self.compare_specific_format)
        self.book_info.copy_link.connect(self.copy_link)
        self.book_info.manage_category.connect(self.manage_category)
        self.book_info.find_in_tag_browser.connect(self.find_in_tag_browser)
        self.book_info.edit_identifiers.connect(self.edit_identifiers)
        self.setCursor(Qt.CursorShape.PointingHandCursor)

    def search_internet(self, data):
        if self.last_data:
            if data.author is None:
                url = url_for_book_search(data.where, title=self.last_data['title'], author=self.last_data['authors'][0])
            else:
                url = url_for_author_search(data.where, author=data.author)
            safe_open_url(url)

    def handle_click(self, link):
        typ, val = link.partition(':')[::2]

        def search_term(field, val):
            self.search_requested.emit('{}:"={}"'.format(field, val.replace('"', '\\"')))

        def browse(url):
            try:
                safe_open_url(QUrl(url, QUrl.ParsingMode.TolerantMode))
            except Exception:
                import traceback
                traceback.print_exc()

        if typ == 'action':
            data = json_loads(from_hex_bytes(val))
            dt = data['type']
            if dt == 'search':
                search_term(data['term'], data['value'])
            elif dt == 'author':
                url = data['url']
                if url == 'calibre':
                    search_term('authors', data['name'])
                else:
                    browse(url)
            elif dt == 'format':
                book_id, fmt = data['book_id'], data['fmt']
                self.view_specific_format.emit(int(book_id), fmt)
            elif dt == 'identifier':
                if data['url']:
                    browse(data['url'])
            elif dt == 'path':
                self.open_containing_folder.emit(int(data['loc']))
            elif dt == 'devpath':
                self.view_device_book.emit(data['loc'])
        else:
            browse(link)

    def mouseDoubleClickEvent(self, ev):
        ev.accept()
        self.show_book_info.emit()

    def show_data(self, data):
        try:
            self.last_data = {'title':data.title, 'authors':data.authors}
        except Exception:
            self.last_data = {}
        self.book_info.show_data(data)
        self.cover_view.show_data(data)
        self.current_path = getattr(data, 'path', '')
        self.update_layout()

    def update_layout(self):
        self.cover_view.setVisible(gprefs['bd_show_cover'])
        self._layout.do_layout(self.rect())
        self.cover_view.update_tooltip(self.current_path)

    def reset_info(self):
        self.show_data(Metadata(_('Unknown')))
Example #30
0
class TOCEditor(QDialog):  # {{{

    explode_done = pyqtSignal(object)
    writing_done = pyqtSignal(object)

    def __init__(self,
                 pathtobook,
                 title=None,
                 parent=None,
                 prefs=None,
                 write_result_to=None):
        QDialog.__init__(self, parent)
        self.write_result_to = write_result_to
        self.prefs = prefs or te_prefs
        self.pathtobook = pathtobook
        self.working = True

        t = title or os.path.basename(pathtobook)
        self.book_title = t
        self.setWindowTitle(_('Edit the ToC in %s') % t)
        self.setWindowIcon(QIcon(I('highlight_only_on.png')))

        l = self.l = QVBoxLayout()
        self.setLayout(l)

        self.stacks = s = QStackedWidget(self)
        l.addWidget(s)
        self.loading_widget = lw = QWidget(self)
        s.addWidget(lw)
        ll = self.ll = QVBoxLayout()
        lw.setLayout(ll)
        self.pi = pi = ProgressIndicator()
        pi.setDisplaySize(QSize(200, 200))
        pi.startAnimation()
        ll.addWidget(pi,
                     alignment=Qt.AlignmentFlag.AlignHCenter
                     | Qt.AlignmentFlag.AlignCenter)
        la = self.wait_label = QLabel(_('Loading %s, please wait...') % t)
        la.setWordWrap(True)
        f = la.font()
        f.setPointSize(20), la.setFont(f)
        ll.addWidget(la,
                     alignment=Qt.AlignmentFlag.AlignHCenter
                     | Qt.AlignmentFlag.AlignTop)
        self.toc_view = TOCView(self, self.prefs)
        self.toc_view.add_new_item.connect(self.add_new_item)
        self.toc_view.tocw.history_state_changed.connect(
            self.update_history_buttons)
        s.addWidget(self.toc_view)
        self.item_edit = ItemEdit(self)
        s.addWidget(self.item_edit)

        bb = self.bb = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel)
        l.addWidget(bb)
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)
        self.undo_button = b = bb.addButton(
            _('&Undo'), QDialogButtonBox.ButtonRole.ActionRole)
        b.setToolTip(_('Undo the last action, if any'))
        b.setIcon(QIcon(I('edit-undo.png')))
        b.clicked.connect(self.toc_view.undo)

        self.explode_done.connect(self.read_toc,
                                  type=Qt.ConnectionType.QueuedConnection)
        self.writing_done.connect(self.really_accept,
                                  type=Qt.ConnectionType.QueuedConnection)

        r = QApplication.desktop().availableGeometry(self)
        self.resize(r.width() - 100, r.height() - 100)
        geom = self.prefs.get('toc_editor_window_geom', None)
        if geom is not None:
            QApplication.instance().safe_restore_geometry(self, bytes(geom))
        self.stacks.currentChanged.connect(self.update_history_buttons)
        self.update_history_buttons()

    def update_history_buttons(self):
        self.undo_button.setVisible(self.stacks.currentIndex() == 1)
        self.undo_button.setEnabled(bool(self.toc_view.tocw.history))

    def add_new_item(self, item, where):
        self.item_edit(item, where)
        self.stacks.setCurrentIndex(2)

    def accept(self):
        if self.stacks.currentIndex() == 2:
            self.toc_view.update_item(*self.item_edit.result)
            self.prefs['toc_edit_splitter_state'] = bytearray(
                self.item_edit.splitter.saveState())
            self.stacks.setCurrentIndex(1)
        elif self.stacks.currentIndex() == 1:
            self.working = False
            Thread(target=self.write_toc).start()
            self.pi.startAnimation()
            self.wait_label.setText(
                _('Writing %s, please wait...') % self.book_title)
            self.stacks.setCurrentIndex(0)
            self.bb.setEnabled(False)

    def really_accept(self, tb):
        self.prefs['toc_editor_window_geom'] = bytearray(self.saveGeometry())
        if tb:
            error_dialog(self,
                         _('Failed to write book'),
                         _('Could not write %s. Click "Show details" for'
                           ' more information.') % self.book_title,
                         det_msg=tb,
                         show=True)
            super(TOCEditor, self).reject()
            return
        self.write_result(0)
        super(TOCEditor, self).accept()

    def reject(self):
        if not self.bb.isEnabled():
            return
        if self.stacks.currentIndex() == 2:
            self.prefs['toc_edit_splitter_state'] = bytearray(
                self.item_edit.splitter.saveState())
            self.stacks.setCurrentIndex(1)
        else:
            self.working = False
            self.prefs['toc_editor_window_geom'] = bytearray(
                self.saveGeometry())
            self.write_result(1)
            super(TOCEditor, self).reject()

    def write_result(self, res):
        if self.write_result_to:
            with tempfile.NamedTemporaryFile(dir=os.path.dirname(
                    self.write_result_to),
                                             delete=False) as f:
                src = f.name
                f.write(str(res).encode('utf-8'))
                f.flush()
            atomic_rename(src, self.write_result_to)

    def start(self):
        t = Thread(target=self.explode)
        t.daemon = True
        self.log = GUILog()
        t.start()

    def explode(self):
        tb = None
        try:
            self.ebook = get_container(self.pathtobook, log=self.log)
        except:
            import traceback
            tb = traceback.format_exc()
        if self.working:
            self.working = False
            self.explode_done.emit(tb)

    def read_toc(self, tb):
        if tb:
            error_dialog(self,
                         _('Failed to load book'),
                         _('Could not load %s. Click "Show details" for'
                           ' more information.') % self.book_title,
                         det_msg=tb,
                         show=True)
            self.reject()
            return
        self.pi.stopAnimation()
        self.toc_view(self.ebook)
        self.item_edit.load(self.ebook)
        self.stacks.setCurrentIndex(1)

    def write_toc(self):
        tb = None
        try:
            toc = self.toc_view.create_toc()
            toc.toc_title = getattr(self.toc_view, 'toc_title', None)
            commit_toc(self.ebook,
                       toc,
                       lang=self.toc_view.toc_lang,
                       uid=self.toc_view.toc_uid)
            self.ebook.commit()
        except:
            import traceback
            tb = traceback.format_exc()
        self.writing_done.emit(tb)