#!/usr/bin/env python


__license__   = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import itertools
import operator
from collections import Counter, OrderedDict
from functools import partial

from qt.core import (
    QAbstractItemView,
    QDialog,
    QDialogButtonBox,
    QDrag,
    QEvent,
    QFont,
    QFontMetrics,
    QGridLayout,
    QHeaderView,
    QIcon,
    QItemSelection,
    QItemSelectionModel,
    QLabel,
    QMenu,
    QMimeData,
    QModelIndex,
    QPoint,
    QPushButton,
    QSize,
    QSpinBox,
    QStyle,
    QStyleOptionHeader,
    Qt,
    QTableView,
    QTimer,
    pyqtSignal,
)

from calibre import force_unicode
from calibre.constants import filesystem_encoding, islinux
from calibre.gui2 import BOOK_DETAILS_DISPLAY_DEBOUNCE_DELAY, FunctionDispatcher, error_dialog, gprefs, show_restart_warning
from calibre.gui2.dialogs.enum_values_edit import EnumValuesEdit
from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.gui2.gestures import GestureManager
from calibre.gui2.library import DEFAULT_SORT
from calibre.gui2.library.alternate_views import AlternateViews, setup_dnd_interface
from calibre.gui2.library.delegates import TextDelegate
from calibre.gui2.library.models import BooksModel, DeviceBooksModel
from calibre.gui2.pin_columns import PinTableView, TableView
from calibre.gui2.preferences.create_custom_column import CreateNewCustomColumn
from calibre.utils.config import prefs, tweaks
from calibre.utils.icu import primary_sort_key


def max_permitted_column_width(self, col):
    # arbitrary: scroll bar + header + some
    sw = self.verticalScrollBar().width() if self.verticalScrollBar().isVisible() else 0
    hw = self.verticalHeader().width() if self.verticalHeader().isVisible() else 0
    return max(200, self.width() - (sw + hw + 10))


def restrict_column_width(self, col, old_size, new_size):
    max_width = max_permitted_column_width(self, col)
    if new_size > max_width:
        self.column_header.blockSignals(True)
        self.setColumnWidth(col, max_width)
        self.column_header.blockSignals(False)


class HeaderView(QHeaderView):  # {{{

    def __init__(self, *args, **kwargs):
        QHeaderView.__init__(self, *args)
        if self.orientation() == Qt.Orientation.Horizontal:
            self.setSectionsMovable(kwargs.get('allow_mouse_movement', True))
            self.setSectionsClickable(True)
            self.setTextElideMode(Qt.TextElideMode.ElideRight)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.hover = -1
        self.current_font = QFont(self.font())
        self.current_font.setBold(True)
        self.current_font.setItalic(True)
        self.fm = QFontMetrics(self.current_font)

    def event(self, e):
        if e.type() in (QEvent.Type.HoverMove, QEvent.Type.HoverEnter):
            self.hover = self.logicalIndexAt(e.position().toPoint())
        elif e.type() in (QEvent.Type.Leave, QEvent.Type.HoverLeave):
            self.hover = -1
        return QHeaderView.event(self, e)

    def sectionSizeFromContents(self, logical_index):
        self.ensurePolished()
        opt = QStyleOptionHeader()
        self.initStyleOption(opt)
        opt.section = logical_index
        opt.orientation = self.orientation()
        opt.fontMetrics = self.fm
        model = self.parent().model()
        opt.text = str(model.headerData(logical_index, opt.orientation, Qt.ItemDataRole.DisplayRole) or '')
        if opt.orientation == Qt.Orientation.Vertical:
            try:
                val = model.headerData(logical_index, opt.orientation, Qt.ItemDataRole.DecorationRole)
                if val is not None:
                    opt.icon = val
                opt.iconAlignment = Qt.AlignmentFlag.AlignVCenter
            except (IndexError, ValueError, TypeError):
                pass
        if self.isSortIndicatorShown():
            opt.sortIndicator = QStyleOptionHeader.SortIndicator.SortDown
        return self.style().sizeFromContents(QStyle.ContentsType.CT_HeaderSection, opt, QSize(), self)

    def paintSection(self, painter, rect, logical_index):
        opt = QStyleOptionHeader()
        self.initStyleOption(opt)
        opt.rect = rect
        opt.section = logical_index
        opt.orientation = self.orientation()
        opt.textAlignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
        opt.fontMetrics = self.fm
        model = self.parent().model()
        style = self.style()
        margin = 2 * style.pixelMetric(QStyle.PixelMetric.PM_HeaderMargin, None, self)
        if self.isSortIndicatorShown() and self.sortIndicatorSection() == logical_index:
            opt.sortIndicator = QStyleOptionHeader.SortIndicator.SortDown if \
                self.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder else QStyleOptionHeader.SortIndicator.SortUp
            margin += style.pixelMetric(QStyle.PixelMetric.PM_HeaderMarkSize, None, self)
        opt.text = str(model.headerData(logical_index, opt.orientation, Qt.ItemDataRole.DisplayRole) or '')
        if self.textElideMode() != Qt.TextElideMode.ElideNone:
            opt.text = opt.fontMetrics.elidedText(opt.text, Qt.TextElideMode.ElideRight, rect.width() - margin)
        if self.isEnabled():
            opt.state |= QStyle.StateFlag.State_Enabled
            if self.window().isActiveWindow():
                opt.state |= QStyle.StateFlag.State_Active
                if self.hover == logical_index:
                    opt.state |= QStyle.StateFlag.State_MouseOver
        sm = self.selectionModel()
        if opt.orientation == Qt.Orientation.Vertical:
            try:
                val = model.headerData(logical_index, opt.orientation, Qt.ItemDataRole.DecorationRole)
                if val is not None:
                    opt.icon = val
                opt.iconAlignment = Qt.AlignmentFlag.AlignVCenter
            except (IndexError, ValueError, TypeError):
                pass
            if sm.isRowSelected(logical_index, QModelIndex()):
                opt.state |= QStyle.StateFlag.State_Sunken

        painter.save()
        if (
                (opt.orientation == Qt.Orientation.Horizontal and sm.currentIndex().column() == logical_index) or (
                    opt.orientation == Qt.Orientation.Vertical and sm.currentIndex().row() == logical_index)):
            painter.setFont(self.current_font)
        self.style().drawControl(QStyle.ControlElement.CE_Header, opt, painter, self)
        painter.restore()
# }}}


class PreserveViewState:  # {{{

    '''
    Save the set of selected books at enter time. If at exit time there are no
    selected books, restore the previous selection, the previous current index
    and don't affect the scroll position.
    '''

    def __init__(self, view, preserve_hpos=True, preserve_vpos=True, require_selected_ids=True):
        self.view = view
        self.require_selected_ids = require_selected_ids
        self.preserve_hpos = preserve_hpos
        self.preserve_vpos = preserve_vpos
        self.init_vals()

    def init_vals(self):
        self.selected_ids = set()
        self.current_id = None
        self.vscroll = self.hscroll = 0
        self.original_view = None
        self.row = self.col = -1

    def __enter__(self):
        self.init_vals()
        try:
            view = self.original_view = self.view.alternate_views.current_view
            self.selected_ids = self.view.get_selected_ids()
            self.current_id = self.view.current_id
            self.vscroll = view.verticalScrollBar().value()
            self.hscroll = view.horizontalScrollBar().value()
            ci = self.view.currentIndex()
            self.row, self.col = ci.row(), ci.column()
        except Exception:
            import traceback
            traceback.print_exc()

    def __exit__(self, *args):
        ci = self.view.model().index(self.row, self.col)
        if ci.isValid():
            self.view.setCurrentIndex(ci)
        if self.selected_ids or not self.require_selected_ids:
            if self.current_id is not None:
                self.view.current_id = self.current_id
            if self.selected_ids:
                self.view.select_rows(self.selected_ids, using_ids=True,
                        scroll=False, change_current=self.current_id is None)
            view = self.original_view
            if self.view.alternate_views.current_view is view:
                if self.preserve_vpos:
                    if hasattr(view, 'restore_vpos'):
                        view.restore_vpos(self.vscroll)
                    else:
                        view.verticalScrollBar().setValue(self.vscroll)
                if self.preserve_hpos:
                    if hasattr(view, 'restore_hpos'):
                        view.restore_hpos(self.hscroll)
                    else:
                        view.horizontalScrollBar().setValue(self.hscroll)
        self.init_vals()

    @property
    def state(self):
        self.__enter__()
        return {x:getattr(self, x) for x in ('selected_ids', 'current_id',
            'vscroll', 'hscroll', 'row', 'col')}

    @state.setter
    def state(self, state):
        for k, v in state.items():
            setattr(self, k, v)
        self.__exit__()

# }}}


class AdjustColumnSize(QDialog):  # {{{

    def __init__(self, view, column, name):
        QDialog.__init__(self)
        self.setWindowTitle(_('Adjust width of {0}').format(name))
        self.view = view
        self.column = column
        l = QGridLayout(self)

        def add_row(a, b=None):
            r = l.rowCount()
            if b is None:
                l.addWidget(a, r, 0, 1, 2)
            else:
                if isinstance(a, str):
                    a = QLabel(a)
                l.addWidget(a, r, 0, 1, 1)
                l.addWidget(b, r, 1, 1, 1)
                if isinstance(a, QLabel):
                    a.setBuddy(b)
        l.addRow = add_row

        original_size = self.original_size = view.horizontalHeader().sectionSize(column)
        label = QLabel(_('{0} pixels').format(str(original_size)))
        label.setToolTip('<p>' + _('The original size can be larger than the maximum if the window has been resized') + '</p>')
        l.addRow(_('Original size:'), label)

        self.minimum_size = self.view.horizontalHeader().minimumSectionSize()
        l.addRow(_('Minimum size:'), QLabel(_('{0} pixels').format(str(self.minimum_size))))

        self.maximum_size = max_permitted_column_width(self.view, self.column)
        l.addRow(_('Maximum size:'), QLabel(_('{0} pixels').format(str(self.maximum_size))))

        la = QLabel(_('You can also adjust column widths by dragging the divider between column headers'))
        la.setWordWrap(True)
        l.addRow(la)

        self.shrink_button = QPushButton(_('&Shrink 10%'))
        b = self.expand_button = QPushButton(_('&Expand 10%'))
        l.addRow(self.shrink_button, b)

        b = self.set_minimum_button = QPushButton(_('Set to mi&nimum'))
        b = self.set_maximum_button = QPushButton(_('Set to ma&ximum'))
        l.addRow(self.set_minimum_button, b)

        b = self.resize_to_fit_button = QPushButton(_('&Resize column to fit contents'))
        b.setToolTip('<p>' + _('The width will be set to the size of the widest entry or the maximum size, whichever is smaller') + '</p>')
        l.addRow(b)

        sb = self.spin_box = QSpinBox()
        sb.setMinimum(self.view.horizontalHeader().minimumSectionSize())
        sb.setMaximum(self.maximum_size)
        sb.setValue(original_size)
        sb.setSuffix(' ' + _('pixels'))
        sb.setToolTip(_('This box shows the current width after using the above buttons'))
        l.addRow(_('Set width &to:'), sb)

        bb = self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
                                                QDialogButtonBox.StandardButton.Cancel)
        l.addRow(bb)

        self.shrink_button.clicked.connect(self.shrink_button_clicked)
        self.expand_button.clicked.connect(self.expand_button_clicked)
        self.spin_box.valueChanged.connect(self.spin_box_changed)
        self.set_minimum_button.clicked.connect(self.set_minimum_button_clicked)
        self.set_maximum_button.clicked.connect(self.set_maximum_button_clicked)
        self.resize_to_fit_button.clicked.connect(self.resize_to_fit_button_clicked)
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)

    def reject(self):
        self.view.setColumnWidth(self.column, self.original_size)
        QDialog.reject(self)

    def resize_to_fit_button_clicked(self):
        self.view.resizeColumnToContents(self.column)
        w = self.view.horizontalHeader().sectionSize(self.column)
        w = min(w, self.maximum_size)
        self.spin_box.setValue(w)

    def set_maximum_button_clicked(self):
        # Do this dance in case the current width exceeds the maximum, which can
        # happen if the user is playing across multiple monitors
        self.spin_box.setValue(self.maximum_size-1)
        self.spin_box.setValue(self.maximum_size)

    def set_minimum_button_clicked(self):
        self.spin_box.setValue(self.minimum_size)

    def shrink_button_clicked(self):
        w = int(self.view.horizontalHeader().sectionSize(self.column) * 0.9)
        self.spin_box.setValue(w)

    def expand_button_clicked(self):
        w = int(self.view.horizontalHeader().sectionSize(self.column) * 1.1)
        self.spin_box.setValue(w)

    def spin_box_changed(self, val):
        self.view.setColumnWidth(self.column, val)
# }}}


@setup_dnd_interface
class BooksView(TableView):  # {{{

    files_dropped = pyqtSignal(object)
    books_dropped = pyqtSignal(object)
    selection_changed = pyqtSignal()
    add_column_signal = pyqtSignal()
    is_library_view = True

    def viewportEvent(self, event):
        if (event.type() == QEvent.Type.ToolTip and not gprefs['book_list_tooltips']):
            return False
        if hasattr(self, 'gesture_manager'):
            ret = self.gesture_manager.handle_event(event)
            if ret is not None:
                return ret
        return super().viewportEvent(event)

    def __init__(self, parent, modelcls=BooksModel, use_edit_metadata_dialog=True):
        QTableView.__init__(self, parent)
        self.pin_view = PinTableView(self, parent)
        self.gesture_manager = GestureManager(self)
        self.default_row_height = self.verticalHeader().defaultSectionSize()
        self.gui = parent
        self.setProperty('highlight_current_item', 150)
        self.pin_view.setProperty('highlight_current_item', 150)
        self.row_sizing_done = False
        self.alternate_views = AlternateViews(self)

        for wv in self, self.pin_view:
            if tweaks['horizontal_scrolling_per_column']:
                wv.update_momentum_scroll_settings(enable_x=False)
            else:
                wv.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
            if tweaks['vertical_scrolling_per_row']:
                wv.update_momentum_scroll_settings(enable_y=False)
            else:
                wv.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)

            wv.setEditTriggers(QAbstractItemView.EditTrigger.EditKeyPressed)
            tval = tweaks['doubleclick_on_library_view']
            if tval == 'edit_cell':
                wv.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked|wv.editTriggers())
            elif tval == 'open_viewer':
                wv.setEditTriggers(QAbstractItemView.EditTrigger.SelectedClicked|wv.editTriggers())
                wv.doubleClicked.connect(parent.iactions['View'].view_triggered)
            elif tval in ('show_book_details', 'show_locked_book_details'):
                wv.setEditTriggers(QAbstractItemView.EditTrigger.SelectedClicked|wv.editTriggers())
                wv.doubleClicked.connect(partial(parent.iactions['Show Book Details'].show_book_info,
                                                 locked=tval == 'show_locked_book_details'))
            elif tval == 'edit_metadata':
                # Must not enable single-click to edit, or the field will remain
                # open in edit mode underneath the edit metadata dialog
                if use_edit_metadata_dialog:
                    wv.doubleClicked.connect(
                            partial(parent.iactions['Edit Metadata'].edit_metadata,
                                    checked=False))
                else:
                    wv.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked|wv.editTriggers())

        setup_dnd_interface(self)
        for wv in self, self.pin_view:
            wv.setAlternatingRowColors(True)
            wv.setWordWrap(False)
        self.refresh_grid()
        self.create_delegates()
        self.pin_view.create_delegates()
        self.display_parent = parent
        self._model = modelcls(self)
        self.setModel(self._model)
        self.pin_view.setModel(self._model)
        self._model.count_changed_signal.connect(self.do_row_sizing,
                                                 type=Qt.ConnectionType.QueuedConnection)
        for wv in self, self.pin_view:
            wv.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
            wv.setSortingEnabled(True)
        self.selectionModel().currentRowChanged.connect(self.on_current_row_change)
        self.selectionModel().selectionChanged.connect(self.selection_changed.emit)
        self.preserve_state = partial(PreserveViewState, self)
        self.marked_changed_listener = FunctionDispatcher(self.marked_changed)

        # Column Header setup {{{
        self.can_add_columns = True
        self.was_restored = False
        self.allow_save_state = True
        self.column_header = HeaderView(Qt.Orientation.Horizontal, self,
                        allow_mouse_movement=gprefs.get('allow_column_movement_with_mouse', True))
        self.pin_view.column_header = HeaderView(Qt.Orientation.Horizontal, self.pin_view,
                        allow_mouse_movement=gprefs.get('allow_column_movement_with_mouse', True))
        self.setHorizontalHeader(self.column_header)
        self.pin_view.setHorizontalHeader(self.pin_view.column_header)
        self.column_header.sectionMoved.connect(self.save_state)
        self.column_header.sortIndicatorChanged.disconnect()
        self.column_header.sortIndicatorChanged.connect(self.user_sort_requested)
        self.pin_view.column_header.sortIndicatorChanged.disconnect()
        self.pin_view.column_header.sortIndicatorChanged.connect(self.pin_view_user_sort_requested)
        self.column_header.customContextMenuRequested.connect(partial(self.show_column_header_context_menu, view=self))
        self.column_header.sectionResized.connect(self.column_resized, Qt.ConnectionType.QueuedConnection)
        if self.is_library_view:
            self.pin_view.column_header.sectionResized.connect(self.pin_view_column_resized, Qt.ConnectionType.QueuedConnection)
            self.pin_view.column_header.sectionMoved.connect(self.pin_view.save_state)
            self.pin_view.column_header.customContextMenuRequested.connect(partial(self.show_column_header_context_menu, view=self.pin_view))
        self.row_header = HeaderView(Qt.Orientation.Vertical, self)
        self.row_header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        self.row_header.customContextMenuRequested.connect(self.show_row_header_context_menu)
        self.setVerticalHeader(self.row_header)
        # }}}

        self._model.database_changed.connect(self.database_changed)
        hv = self.verticalHeader()
        hv.setSectionsClickable(True)
        hv.setCursor(Qt.CursorShape.PointingHandCursor)
        self.set_row_header_visibility()
        self.allow_mirroring = True
        if self.is_library_view:
            self.set_pin_view_visibility(gprefs['book_list_split'])
            for wv in self, self.pin_view:
                wv.selectionModel().currentRowChanged.connect(partial(self.mirror_selection_between_views, wv))
                wv.selectionModel().selectionChanged.connect(partial(self.mirror_selection_between_views, wv))
                wv.verticalScrollBar().valueChanged.connect(partial(self.mirror_vscroll, wv))
                wv.verticalScrollBar().rangeChanged.connect(partial(self.mirror_vscroll, wv))
        else:
            self.pin_view.setVisible(False)

    # Pin view {{{
    def set_pin_view_visibility(self, visible=False):
        self.pin_view.setVisible(visible)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff if visible else Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        self.mirror_selection_between_views(self)

    def mirror_selection_between_views(self, src):
        if self.allow_mirroring:
            dest = self.pin_view if src is self else self
            if dest is self.pin_view and not dest.isVisible():
                return
            self.allow_mirroring = False
            dest.selectionModel().select(src.selectionModel().selection(), QItemSelectionModel.SelectionFlag.ClearAndSelect)
            ci = dest.currentIndex()
            nci = src.selectionModel().currentIndex()
            # Save/restore horz scroll.  ci column may be scrolled out of view.
            hpos = dest.horizontalScrollBar().value()
            if ci.isValid():
                nci = dest.model().index(nci.row(), ci.column())
            dest.selectionModel().setCurrentIndex(nci, QItemSelectionModel.SelectionFlag.NoUpdate)
            dest.horizontalScrollBar().setValue(hpos)
            self.allow_mirroring = True

    def mirror_vscroll(self, src, *a):
        if self.allow_mirroring:
            dest = self.pin_view if src is self else self
            if dest is self.pin_view and not dest.isVisible():
                return
            self.allow_mirroring = False
            s, d = src.verticalScrollBar(), dest.verticalScrollBar()
            d.setRange(s.minimum(), s.maximum()), d.setValue(s.value())
            self.allow_mirroring = True
    # }}}

    # Column Header Context Menu {{{
    def column_header_context_handler(self, action=None, column=None, view=None):
        if action == 'split':
            self.set_pin_view_visibility(not self.pin_view.isVisible())
            gprefs['book_list_split'] = self.pin_view.isVisible()
            self.save_state()
            return
        if not action or not column or not view:
            return
        try:
            idx = self.column_map.index(column)
        except Exception:
            return
        h = view.column_header

        if action == 'lock':
            val = not view.column_header.sectionsMovable()
            self.column_header.setSectionsMovable(val)
            self.pin_view.column_header.setSectionsMovable(val)
            gprefs.set('allow_column_movement_with_mouse', val)
        elif action == 'hide':
            if h.hiddenSectionCount() >= h.count():
                return error_dialog(self, _('Cannot hide all columns'), _(
                    'You must not hide all columns'), show=True)
            h.setSectionHidden(idx, True)
        elif action == 'show':
            h.setSectionHidden(idx, False)
            if h.sectionSize(idx) < 3:
                sz = h.sectionSizeHint(idx)
                h.resizeSection(idx, sz)
        elif action == 'ascending':
            self.sort_by_column_and_order(idx, True)
        elif action == 'descending':
            self.sort_by_column_and_order(idx, False)
        elif action == 'defaults':
            view.apply_state(view.get_default_state())
        elif action == 'addcustcol':
            self.add_column_signal.emit()
        elif action == 'editcustcol':
            def show_restart_dialog():
                from calibre.gui2.preferences.main import must_restart_message
                if show_restart_warning(must_restart_message):
                    self.gui.quit(restart=True)
            col_manager = CreateNewCustomColumn(self.gui)
            if col_manager.must_restart():
                show_restart_dialog()
            else:
                res = col_manager.edit_existing_column(column)
                if res[0] == CreateNewCustomColumn.Result.COLUMN_EDITED:
                    show_restart_dialog()
        elif action.startswith('align_'):
            alignment = action.partition('_')[-1]
            self._model.change_alignment(column, alignment)
        elif action.startswith('font_'):
            self._model.change_column_font(column, action[len('font_'):])
        elif action == 'quickview':
            from calibre.gui2.actions.show_quickview import get_quickview_action_plugin
            qv = get_quickview_action_plugin()
            if qv:
                rows = self.selectionModel().selectedRows()
                if len(rows) > 0:
                    current_row = rows[0].row()
                    current_col = self.column_map.index(column)
                    index = self.model().index(current_row, current_col)
                    qv.change_quickview_column(index)
        elif action == 'remember_ondevice_width':
            gprefs.set('ondevice_column_width', self.columnWidth(idx))
        elif action == 'reset_ondevice_width':
            gprefs.set('ondevice_column_width', 0)
            self.resizeColumnToContents(idx)
        elif action == 'edit_enum':
            EnumValuesEdit(self, self._model.db, column).exec()
        elif action == 'tt_template':
            db = self._model.db
            rows = self.selectionModel().selectedRows()
            mi = []
            tt_dict = db.new_api.pref('column_tooltip_templates', {})
            for i in range(min(len(rows), 10)):
                mi.append(db.new_api.get_proxy_metadata(db.data.index_to_id(i)))
            template = tt_dict.get(column, '')
            text_is_placeholder = False
            if not template:
                text_is_placeholder = True
                from calibre.gui2.actions.column_tooltips import column_template_placeholder_text
                template = column_template_placeholder_text()
            d = TemplateDialog(self, template, mi=mi, text_is_placeholder=text_is_placeholder)
            if d.exec():
                tt_dict[column] = d.rule[1]
                db.new_api.set_pref('column_tooltip_templates', tt_dict)
        self.save_state()

    def create_context_menu(self, col, name, view):
        ans = QMenu(view)
        handler = partial(self.column_header_context_handler, view=view, column=col)
        m = ans.addMenu(_('Sort on %s')  % name)
        m.setIcon(QIcon.ic('sort.png'))
        a = m.addAction(_('Ascending'), partial(handler, action='ascending'))
        d = m.addAction(_('Descending'), partial(handler, action='descending'))
        if self._model.sorted_on[0] == col:
            ac = a if self._model.sorted_on[1] else d
            ac.setCheckable(True)
            ac.setChecked(True)
        if col not in ('ondevice', 'inlibrary') and \
                (not self.model().is_custom_column(col) or
                 (self._model.custom_columns[col]['datatype'] != 'bool' or
                  self._model.custom_columns[col]['display'].get('bools_show_text', False))):
            m = ans.addMenu(_('Change text alignment for %s') % name)
            m.setIcon(QIcon.ic('format-justify-center.png'))
            al = self._model.alignment_map.get(col, 'left')
            for x, t in (('left', _('Left')), ('right', _('Right')), ('center', _('Center'))):
                a = m.addAction(QIcon.ic(f'format-justify-{x}.png'), t, partial(handler, action='align_'+x))
                if al == x:
                    a.setCheckable(True)
                    a.setChecked(True)
            if not isinstance(view, DeviceBooksView):
                col_font = self._model.styled_columns.get(col)
                m = ans.addMenu(_('Change font style for %s') % name)
                m.setIcon(QIcon.ic('format-text-bold.png'))
                for x, t, f in (
                        ('normal', _('Normal font'), None), ('bold', _('Bold font'), self._model.bold_font),
                        ('italic', _('Italic font'), self._model.italic_font), ('bi', _('Bold and Italic font'), self._model.bi_font),
                ):
                    a = m.addAction(t, partial(handler, action='font_' + x))
                    if x in ('bold', 'italic'):
                        a.setIcon(QIcon.ic(f'format-text-{x}.png'))
                    if f is col_font:
                        a.setCheckable(True)
                        a.setChecked(True)
                ans.addAction(QIcon.ic('width.png'), _('Adjust width of column {0}').format(name),
                          partial(self.manually_adjust_column_size, view, col, name))

        if not isinstance(view, DeviceBooksView):
            col_manager = CreateNewCustomColumn(self.gui)
            if self.can_add_columns and self.model().is_custom_column(col):
                act = ans.addAction(QIcon.ic('edit_input.png'), _('Edit column definition for %s') % name,
                                    partial(handler, action='editcustcol'))
                if col_manager.must_restart():
                    act.setEnabled(False)
            db = self._model.db.new_api
            tt_prefs = db.pref('column_tooltip_templates', {})
            ans.addAction(QIcon.ic('edit_input.png'),
                          (_('Define tooltip template for "%s"') if col not in tt_prefs
                            else _('Edit tooltip template for "%s"')) % name,
                          partial(handler, action='tt_template', ))
        if self.is_library_view:
            if self._model.db.field_metadata[col]['datatype'] == 'enumeration':
                ans.addAction(QIcon.ic('edit_input.png'), _('Edit permissible values for %s') % name,
                              partial(handler, action='edit_enum'))
            if self._model.db.field_metadata[col]['is_category']:
                ans.addSeparator()
                act = ans.addAction(QIcon.ic('quickview.png'), _('Quickview column %s') % name,
                                    partial(handler, action='quickview'))
                rows = self.selectionModel().selectedRows()
                if len(rows) > 1:
                    act.setEnabled(False)

        hidden_cols = {self.column_map[i]: i for i in range(view.column_header.count())
                       if view.column_header.isSectionHidden(i) and self.column_map[i] not in ('ondevice', 'inlibrary')}

        ans.addSeparator()
        if col not in ('ondevice', 'inlibrary'):
            ans.addAction(QIcon.ic('minus.png'), _('Hide column %s') % name, partial(handler, action='hide'))
        if hidden_cols:
            m = ans.addMenu(_('Show column'))
            m.setIcon(QIcon.ic('plus.png'))
            hcols = [(hcol, str(self.model().headerData(hidx, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole) or ''))
                     for hcol, hidx in hidden_cols.items()]
            name_counts = Counter(hname for _, hname in hcols)
            hcols.sort(key=lambda x: primary_sort_key(x[1]))
            for hcol, hname in hcols:
                display = f'{hname} [{hcol}]' if name_counts[hname] > 1 else hname
                m.addAction(display.replace('&', '&&'), partial(handler, action='show', column=hcol))
        ans.addSeparator()
        if col == 'ondevice':
            ans.addAction(_('Remember On Device column width'),
                partial(handler, action='remember_ondevice_width'))
            ans.addAction(_('Reset On Device column width to default'),
                partial(handler, action='reset_ondevice_width'))
        ans.addAction(_('Restore default layout'), partial(handler, action='defaults'))
        if self.can_add_columns:
            act = ans.addAction(QIcon.ic('column.png'), _('Add your own columns'),
                                partial(handler, action='addcustcol'))
            col_manager = CreateNewCustomColumn(self.gui)
            act.setEnabled(not col_manager.must_restart())
        return ans

    def show_row_header_context_menu(self, pos):
        menu = QMenu(self)
        # Even when hidden, row numbers show if any marks show, which is why it makes
        # sense to offer "show row numbers" here. Saves having to go to Preferences
        # Look & feel, assuming you know the trick.
        if gprefs['row_numbers_in_book_list']:
            menu.addAction(_('Hide row numbers'), partial(self.hide_row_numbers, show=False))
        else:
            menu.addAction(_('Show row numbers'), partial(self.hide_row_numbers, show=True))
        db = self._model.db
        row = self.row_header.logicalIndexAt(pos)
        if row >= 0 and row < len(db.data):
            book_id_col = db.field_metadata['id']['rec_index']
            book_id = db.data[row][book_id_col]
            m = menu.addAction(_('Toggle mark for book'), lambda: db.data.toggle_marked_ids({book_id,}))
            ic = QIcon.cached_icon('marked.png')
            m.setIcon(ic)
            from calibre.gui2.actions.mark_books import mark_books_with_text
            m = menu.addAction(_('Mark book with text label'), partial(mark_books_with_text, {book_id,}))
            m.setIcon(ic)
        menu.popup(self.mapToGlobal(pos))

    # Probably should change the method name, but leave it for compatibility
    def hide_row_numbers(self, show=False):
        gprefs['row_numbers_in_book_list'] = show
        self.set_row_header_visibility()

    def show_column_header_context_menu_from_action(self):
        if self.is_library_view:
            if self.hasFocus():
                p = QPoint(self.column_header.sectionViewportPosition(self.currentIndex().column()), 10)
                self.show_column_header_context_menu(p, view=self)
            elif self.pin_view.hasFocus():
                p = QPoint(self.pin_view.horizontalHeader().sectionViewportPosition(self.pin_view.currentIndex().column()), 10)
                self.show_column_header_context_menu(p, view=self.pin_view)
            # else some other widget has the focus, such as the tag browser or quickview

    def show_column_header_context_menu(self, pos, view=None):
        view = view or self
        idx = view.column_header.logicalIndexAt(pos)
        col = None
        if idx > -1 and idx < len(self.column_map):
            col = self.column_map[idx]
            name = str(self.model().headerData(idx, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole) or '')
            view.column_header_context_menu = self.create_context_menu(col, name, view)
        has_context_menu = hasattr(view, 'column_header_context_menu')
        if self.is_library_view and has_context_menu:
            m = view.column_header_context_menu
            m.addSeparator()
            if not hasattr(m, 'bl_split_action'):
                m.bl_split_action = m.addAction(QIcon.ic('split.png'), 'xxx',
                            partial(self.column_header_context_handler, action='split', column='title'))
            ac = m.bl_split_action
            if self.pin_view.isVisible():
                ac.setText(_('Un-split the book list'))
            else:
                ac.setText(_('Split the book list'))

            ac = getattr(m, 'column_mouse_move_action', None)
            if ac is None:
                ac = m.column_mouse_move_action = m.addAction(_('Allow moving columns with the mouse'),
                          partial(self.column_header_context_handler, action='lock', column=col, view=view))
                ac.setCheckable(True)
            ac.setChecked(view.column_header.sectionsMovable())
        if has_context_menu:
            view.column_header_context_menu.popup(view.column_header.mapToGlobal(pos))
    # }}}

    # Sorting {{{

    def set_sort_indicator(self, logical_idx, ascending):
        views = [self, self.pin_view] if self.is_library_view else [self]
        for v in views:
            ch = v.column_header
            ch.blockSignals(True)
            ch.setSortIndicator(logical_idx, Qt.SortOrder.AscendingOrder if ascending else Qt.SortOrder.DescendingOrder)
            ch.blockSignals(False)

    def sort_by_column_and_order(self, col, ascending):
        order = Qt.SortOrder.AscendingOrder if ascending else Qt.SortOrder.DescendingOrder
        self.column_header.blockSignals(True)
        self.column_header.setSortIndicator(col, order)
        self.column_header.blockSignals(False)
        with self.preserve_state(preserve_vpos=False, require_selected_ids=False):
            self.model().sort(col, order)
        if self.is_library_view:
            self.set_sort_indicator(col, ascending)

    def user_sort_requested(self, col, order=Qt.SortOrder.AscendingOrder):
        if 0 <= col < len(self.column_map):
            field = self.column_map[col]
            self.intelligent_sort(field, order == Qt.SortOrder.AscendingOrder)

    def pin_view_user_sort_requested(self, col, order=Qt.SortOrder.AscendingOrder):
        if col < len(self.column_map) and col >= 0:
            field = self.column_map[col]
            self.intelligent_sort(field, order == Qt.SortOrder.AscendingOrder)

    def intelligent_sort(self, field, ascending):
        if isinstance(ascending, Qt.SortOrder):
            ascending = ascending == Qt.SortOrder.AscendingOrder
        m = self.model()
        pname = 'previous_sort_order_' + self.__class__.__name__
        previous = gprefs.get(pname, {})
        if field == m.sorted_on[0] or field not in previous:
            self.sort_by_named_field(field, ascending)
            previous[field] = ascending
            gprefs[pname] = previous
            return
        previous[m.sorted_on[0]] = m.sorted_on[1]
        gprefs[pname] = previous
        self.sort_by_named_field(field, previous[field])

    def keyboardSearch(self, search):
        if gprefs.get('allow_keyboard_search_in_library_views', True):
            super().keyboardSearch(search)

    def sort_by_named_field(self, field, order, reset=True):
        if isinstance(order, Qt.SortOrder):
            order = order == Qt.SortOrder.AscendingOrder
        if field in self.column_map:
            idx = self.column_map.index(field)
            self.sort_by_column_and_order(idx, order)
        else:
            with self.preserve_state(preserve_vpos=False, require_selected_ids=False):
                self._model.sort_by_named_field(field, order, reset)
            self.set_sort_indicator(-1, True)

    def multisort(self, fields, reset=True, only_if_different=False):
        if len(fields) == 0:
            return
        sh = self.cleanup_sort_history(self._model.sort_history,
                                       ignore_column_map=True)
        if only_if_different and len(sh) >= len(fields):
            ret=True
            for i,t in enumerate(fields):
                if t[0] != sh[i][0]:
                    ret = False
                    break
            if ret:
                return

        for n,d in reversed(fields):
            if n in list(self._model.db.field_metadata.keys()):
                sh.insert(0, (n, d))
        sh = self.cleanup_sort_history(sh, ignore_column_map=True)
        self._model.sort_history = [tuple(x) for x in sh]
        with self.preserve_state(preserve_vpos=False, require_selected_ids=False):
            self._model.resort(reset=reset)
        col = fields[0][0]
        ascending = fields[0][1]
        try:
            idx = self.column_map.index(col)
        except Exception:
            idx = -1
        self.set_sort_indicator(idx, ascending)

    def resort(self):
        with self.preserve_state(preserve_vpos=False, require_selected_ids=False):
            self._model.resort(reset=True)

    def reverse_sort(self):
        m = self.model()
        try:
            sort_col, order = m.sorted_on
        except TypeError:
            sort_col, order = 'date', True
        self.sort_by_named_field(sort_col, not order)
    # }}}

    # Ondevice column {{{
    def set_ondevice_column_visibility(self):
        col = self._model.column_map.index('ondevice')
        self.column_header.setSectionHidden(col, not self._model.device_connected)
        w = gprefs.get('ondevice_column_width', 0)
        if w > 0:
            self.setColumnWidth(col, w)
        if self.is_library_view:
            self.pin_view.column_header.setSectionHidden(col, True)

    def set_device_connected(self, is_connected):
        self._model.set_device_connected(is_connected)
        self.set_ondevice_column_visibility()
    # }}}

    # Save/Restore State {{{
    def get_state(self):
        h = self.column_header
        cm = self.column_map
        state = {}
        state['hidden_columns'] = [cm[i] for i in range(h.count())
                if h.isSectionHidden(i) and cm[i] != 'ondevice']
        for f in ('last_modified', 'languages', 'formats', 'id', 'path', 'pages'):
            state[f+'_injected'] = True
        state['sort_history'] = \
            self.cleanup_sort_history(self.model().sort_history, ignore_column_map=self.is_library_view)
        state['column_positions'] = {}
        state['column_sizes'] = {}
        state['column_alignment'] = self._model.alignment_map
        for i in range(h.count()):
            name = cm[i]
            state['column_positions'][name] = h.visualIndex(i)
            if name != 'ondevice':
                state['column_sizes'][name] = h.sectionSize(i)
        return state

    def write_state(self, state):
        db = getattr(self.model(), 'db', None)
        name = str(self.objectName())
        if name and db is not None:
            db.new_api.set_pref(name + ' books view state', state)

    def save_state(self):
        # Only save if we have been initialized (set_database called)
        if len(self.column_map) > 0 and self.was_restored and self.allow_save_state:
            state = self.get_state()
            self.write_state(state)
            if self.is_library_view:
                self.pin_view.save_state()

    def cleanup_sort_history(self, sort_history, ignore_column_map=False):
        history = []

        for col, order in sort_history:
            if not isinstance(order, bool):
                continue
            col = {'date':'timestamp', 'sort':'title'}.get(col, col)
            if ignore_column_map or col in self.column_map:
                if (not history or history[-1][0] != col):
                    history.append([col, order])
        return history

    def apply_sort_history(self, saved_history, max_sort_levels=3):
        if not saved_history:
            return
        if self.is_library_view:
            for col, order in reversed(self.cleanup_sort_history(
                    saved_history, ignore_column_map=True)[:max_sort_levels]):
                try:
                    self.sort_by_named_field(col, order)
                except KeyError:
                    pass
        else:
            for col, order in reversed(self.cleanup_sort_history(
                    saved_history)[:max_sort_levels]):
                self.sort_by_column_and_order(self.column_map.index(col), order)

    def apply_state(self, state, max_sort_levels=3, save_state=True):
        # set save_state=False if you will save the state yourself after calling
        # this method.
        h = self.column_header
        cmap = {}
        hidden = state.get('hidden_columns', [])
        for i, c in enumerate(self.column_map):
            cmap[c] = i
            if c != 'ondevice':
                h.setSectionHidden(i, c in hidden)
        if db := getattr(self.model().db, 'new_api', None):
            pages_shown_in_book_details = False
            if dbf := db.new_api.pref('book_display_fields'):
                for fname, shown in dbf:
                    if fname == 'pages':
                        pages_shown_in_book_details = shown
                        break
            if pages_shown_in_book_details or 'pages' not in hidden:
                db.new_api.queue_pages_scan()

        positions = state.get('column_positions', {})
        pmap = {}
        for col, pos in positions.items():
            if col in cmap:
                pmap[pos] = col
        need_save_state = False
        # Resetting column positions triggers a save state. There can be a lot
        # of these. Batch them up and do it at the end.
        # Can't use blockSignals() because that prevents needed processing somewhere
        self.allow_save_state = False
        for pos in sorted(pmap.keys()):
            col = pmap[pos]
            idx = cmap[col]
            current_pos = h.visualIndex(idx)
            if current_pos != pos:
                need_save_state = True
                h.moveSection(current_pos, pos)
        self.allow_save_state = True
        if need_save_state and save_state:
            self.save_state()
        # Because of a bug in Qt 5 we have to ensure that the header is actually
        # relaid out by changing this value, without this sometimes ghost
        # columns remain visible when changing libraries
        for i in range(h.count()):
            val = h.isSectionHidden(i)
            h.setSectionHidden(i, not val)
            h.setSectionHidden(i, val)

        sizes = state.get('column_sizes', {})
        for col, size in sizes.items():
            if col in cmap:
                sz = size
                if sz < 3:
                    sz = h.sectionSizeHint(cmap[col])
                h.resizeSection(cmap[col], sz)

        self.apply_sort_history(state.get('sort_history', None),
                max_sort_levels=max_sort_levels)

        for col, alignment in state.get('column_alignment', {}).items():
            self._model.change_alignment(col, alignment)

        for i in range(h.count()):
            if not h.isSectionHidden(i) and h.sectionSize(i) < 3:
                sz = h.sectionSizeHint(i)
                h.resizeSection(i, sz)

    def get_default_state(self):
        old_state = {
                'hidden_columns': ['last_modified', 'languages', 'formats', 'id', 'path', 'pages'],
                'sort_history':[DEFAULT_SORT],
                'column_positions': {},
                'column_sizes': {},
                'column_alignment': {
                    'size':'center',
                    'timestamp':'center',
                    'pubdate':'center'},
                }
        for f in ('last_modified', 'languages', 'formats', 'id', 'path', 'pages'):
            old_state[f+'_injected'] = True
        h = self.column_header
        cm = self.column_map
        for i in range(h.count()):
            name = cm[i]
            old_state['column_positions'][name] = i
            if name != 'ondevice':
                old_state['column_sizes'][name] = \
                    min(350, max(self.sizeHintForColumn(i),
                        h.sectionSizeHint(i)))
                if name in ('timestamp', 'last_modified'):
                    old_state['column_sizes'][name] += 12
        return old_state

    def get_old_state(self):
        ans = None
        name = str(self.objectName())
        if name:
            name += ' books view state'
            db = getattr(self.model(), 'db', None)
            if db is not None:
                ans = db.new_api.pref(name)
                if ans is None:
                    ans = gprefs.get(name, None)
                    try:
                        del gprefs[name]
                    except Exception:
                        pass
                    if ans is not None:
                        db.new_api.set_pref(name, ans)
                else:
                    injected = False
                    for f in ('last_modified', 'languages', 'formats', 'id', 'path', 'pages'):
                        if not ans.get(f+'_injected', False):
                            injected = True
                            ans[f+'_injected'] = True
                            hc = ans.get('hidden_columns', [])
                            if f not in hc:
                                hc.append(f)
                    if injected:
                        db.new_api.set_pref(name, ans)
        return ans

    def restore_state(self):
        old_state = self.get_old_state()
        if old_state is None:
            old_state = self.get_default_state()
        max_levels = 3

        if tweaks['sort_columns_at_startup'] is not None:
            sh = []
            try:
                for c,d in tweaks['sort_columns_at_startup']:
                    if not isinstance(d, bool):
                        d = True if d == 0 else False
                    sh.append((c, d))
            except Exception:
                # Ignore invalid tweak values as users seem to often get them
                # wrong
                print('Ignoring invalid sort_columns_at_startup tweak, with error:')
                import traceback
                traceback.print_exc()
            old_state['sort_history'] = sh
            max_levels = max(3, len(sh))

        if self.is_library_view:
            self.pin_view.restore_state()

        self.column_header.blockSignals(True)
        self.apply_state(old_state, max_sort_levels=max_levels)
        self.column_header.blockSignals(False)

        self.do_row_sizing()

        self.was_restored = True

    def refresh_row_sizing(self):
        self.row_sizing_done = False
        self.do_row_sizing()

    def refresh_grid(self):
        for wv in self, self.pin_view:
            wv.setShowGrid(bool(gprefs['booklist_grid']))

    def do_row_sizing(self):
        # Resize all rows to have the correct height
        if not self.row_sizing_done and self.model().rowCount(QModelIndex()) > 0:
            vh = self.verticalHeader()
            h = max(vh.minimumSectionSize(), self.default_row_height + gprefs['book_list_extra_row_spacing'])
            vh.setDefaultSectionSize(h)
            if self.is_library_view:
                self.pin_view.verticalHeader().setDefaultSectionSize(h)
            self._model.set_row_height(self.rowHeight(0))
            self.row_sizing_done = True

    def manually_adjust_column_size(self, view, column, name):
        col = self.column_map.index(column)
        AdjustColumnSize(view, col, name).exec_()

    def column_resized(self, col, old_size, new_size):
        restrict_column_width(self, col, old_size, new_size)

    def pin_view_column_resized(self, col, old_size, new_size):
        restrict_column_width(self.pin_view, col, old_size, new_size)

    # }}}

    # Initialization/Delegate Setup {{{

    def set_database(self, db):
        self.alternate_views.set_database(db)
        self.save_state()
        self._model.set_database(db)
        self.alternate_views.set_database(db, stage=1)

    def marked_changed(self, old_marked, current_marked):
        self.alternate_views.marked_changed(old_marked, current_marked)
        if bool(old_marked) == bool(current_marked):
            changed = old_marked | current_marked
            i = self.model().db.data.id_to_index

            def f(x):
                try:
                    return i(x)
                except ValueError:
                    pass
            sections = tuple(x for x in map(f, changed) if x is not None)
            if sections:
                self.row_header.headerDataChanged(Qt.Orientation.Vertical, min(sections), max(sections))
                # This is needed otherwise Qt does not always update the
                # viewport correctly. See https://bugs.launchpad.net/bugs/1404697
                self.row_header.viewport().update()
                # refresh the rows because there might be a composite that uses marked_books()
                self.model().refresh_rows(sections)
        else:
            # Marked items have either appeared or all been removed
            self.model().set_row_decoration(current_marked)
            self.row_header.headerDataChanged(Qt.Orientation.Vertical, 0, self.row_header.count()-1)
            self.row_header.geometriesChanged.emit()
            self.set_row_header_visibility()
            # refresh rows for the ids because there might be a composite that uses marked_books()
            self.model().refresh_ids(current_marked)

    def set_row_header_visibility(self):
        visible = self.model().row_decoration is not None or gprefs['row_numbers_in_book_list']
        self.row_header.setVisible(visible)

    def database_changed(self, db):
        db.data.add_marked_listener(self.marked_changed_listener)
        for i in range(self.model().columnCount(None)):
            for vw in self, self.pin_view:
                if vw.itemDelegateForColumn(i) in (
                        self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate,
                        self.last_modified_delegate, self.languages_delegate, self.half_rating_delegate):
                    vw.setItemDelegateForColumn(i, vw.itemDelegate())

        self.set_delegates()
        self.pin_view.set_delegates()
        self.restore_state()
        self.set_ondevice_column_visibility()
        # in case there were marked books
        self.model().set_row_decoration(set())
        self.row_header.headerDataChanged(Qt.Orientation.Vertical, 0, self.row_header.count()-1)
        self.row_header.geometriesChanged.emit()
        # }}}

    # Context Menu {{{
    def set_context_menu(self, menu, edit_collections_action):
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
        self.context_menu = menu
        self.alternate_views.set_context_menu(menu)
        self.edit_collections_action = edit_collections_action

    def show_context_menu(self, menu, event):
        from calibre.gui2.main_window import clone_menu
        m = clone_menu(menu) if islinux else menu
        m.popup(event.globalPos())
        event.accept()

    def contextMenuEvent(self, event):
        self.show_context_menu(self.context_menu, event)
    # }}}

    def handle_mouse_press_event(self, ev):
        m = ev.modifiers()
        b = ev.button()
        if (
            m == Qt.KeyboardModifier.NoModifier and b == Qt.MouseButton.LeftButton and
            self.editTriggers() & QAbstractItemView.EditTrigger.SelectedClicked
        ):
            index = self.indexAt(ev.pos())
            self.last_mouse_press_on_row = index.row()

        if m & Qt.KeyboardModifier.ShiftModifier:
            # Shift-Click in QTableView is badly behaved.
            index = self.indexAt(ev.pos())
            if not index.isValid():
                return QTableView.mousePressEvent(self, ev)
            ci = self.currentIndex()
            if not ci.isValid():
                return QTableView.mousePressEvent(self, ev)
            clicked_row = index.row()
            current_row = ci.row()
            sm = self.selectionModel()
            if clicked_row == current_row:
                sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.NoUpdate)
                return
            sr = sm.selectedRows()
            if not len(sr):
                sm.select(
                    index,
                    QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Clear |
                    QItemSelectionModel.SelectionFlag.Current | QItemSelectionModel.SelectionFlag.Rows)
                return

            m = self.model()

            def new_selection(upper, lower):
                top_left = m.index(upper, 0)
                bottom_right = m.index(lower, m.columnCount(None) - 1)
                return QItemSelection(top_left, bottom_right)

            currently_selected = tuple(x.row() for x in sr)
            min_row = min(currently_selected)
            max_row = max(currently_selected)
            outside_current_selection = clicked_row < min_row or clicked_row > max_row
            existing_selection = sm.selection()
            if outside_current_selection:
                # We simply extend the current selection
                if clicked_row < min_row:
                    upper, lower = clicked_row, min_row
                else:
                    upper, lower = max_row, clicked_row
                existing_selection.merge(new_selection(upper, lower), QItemSelectionModel.SelectionFlag.Select)
            else:
                if current_row < clicked_row:
                    upper, lower = current_row, clicked_row
                else:
                    upper, lower  = clicked_row, current_row
                existing_selection.merge(new_selection(upper, lower), QItemSelectionModel.SelectionFlag.Toggle)
            sm.select(existing_selection, QItemSelectionModel.SelectionFlag.ClearAndSelect)
            sm.setCurrentIndex(
                # ensure clicked row is always selected
                index, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows)
        else:
            QTableView.mousePressEvent(self, ev)

    def handle_mouse_release_event(self, ev):
        if (
            ev.modifiers() == Qt.KeyboardModifier.NoModifier and ev.button() == Qt.MouseButton.LeftButton and
            self.editTriggers() & QAbstractItemView.EditTrigger.SelectedClicked
        ):
            # As of Qt 6, Qt does not clear a multi-row selection when the
            # edit triggers contain SelectedClicked and the clicked row is
            # already selected, so do it ourselves
            index = self.indexAt(ev.pos())
            last_press, self.last_mouse_press_on_row = getattr(self, 'last_mouse_press_on_row', -111), -112
            if index.row() == last_press:
                sm = self.selectionModel()
                if index.isValid() and sm.isSelected(index):
                    self.select_rows((index,), using_ids=False, change_current=False, scroll=False)
        QTableView.mouseReleaseEvent(self, ev)

    @property
    def column_map(self):
        return self._model.column_map

    @property
    def visible_columns(self):
        h = self.horizontalHeader()
        return (key for lidx,key in enumerate(self.column_map) if not h.isSectionHidden(lidx))

    def refresh_book_details(self, force=False):
        idx = self.currentIndex()
        if not idx.isValid() and force:
            idx = self.model().index(0, 0)
        if idx.isValid():
            self._model.current_changed(idx, idx)
            return True
        return False

    def rows_for_merge(self, resolved=False):
        if not resolved:
            return self.alternate_views.current_view.rows_for_merge(resolved=True)
        ans = []
        seen = set()
        for idx in self.selectionModel().selectedRows():
            row = idx.row()
            if row not in seen:
                seen.add(row)
                ans.append(row)
        return ans

    def scrollContentsBy(self, dx, dy):
        # Needed as Qt bug causes headerview to not always update when scrolling
        QTableView.scrollContentsBy(self, dx, dy)
        if dy != 0:
            self.column_header.update()

    def scroll_to_row(self, row):
        row = min(row, self.model().rowCount(QModelIndex())-1)
        if row > -1:
            # taken from Qt implementation of scrollTo but this ensured horizontal position is not affected
            vh = self.verticalHeader()
            viewport_height = self.viewport().height()
            vertical_offset = vh.offset()
            vertical_position = vh.sectionPosition(row)
            cell_height = vh.sectionSize(row)
            pos = 'center'
            if vertical_position - vertical_offset < 0 or cell_height > viewport_height:
                pos = 'top'
            elif vertical_position - vertical_offset + cell_height > viewport_height:
                pos = 'bottom'
            vsb = self.verticalScrollBar()

            if self.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel:
                if pos == 'top':
                    vsb.setValue(vertical_position)
                elif pos == 'bottom':
                    vsb.setValue(vertical_position - viewport_height + cell_height)
                else:
                    vsb.setValue(vertical_position - ((viewport_height - cell_height) // 2))
            else:
                vertical_index = vh.visualIndex(row)
                if pos in ('bottom', 'center'):
                    h = (viewport_height // 2) if pos == 'center' else viewport_height
                    y = cell_height
                    while vertical_index > 0:
                        y += vh.sectionSize(vh.logicalIndex(vertical_index -1))
                        if y > h:
                            break
                        vertical_index -= 1
                if vh.sectionsHidden():
                    for s in range(vertical_index - 1, -1, -1):
                        if vh.isSectionHidden(vh.logicalIndex(s)):
                            vertical_index -= 1
                vsb.setValue(vertical_index)

    @property
    def current_book(self):
        ci = self.currentIndex()
        if ci.isValid():
            try:
                return self.model().db.data.index_to_id(ci.row())
            except (IndexError, ValueError, KeyError, TypeError, AttributeError):
                pass

    def current_book_state(self):
        return self.current_book, self.horizontalScrollBar().value(), self.pin_view.horizontalScrollBar().value()

    def restore_current_book_state(self, state):
        book_id, hpos, pv_hpos = state
        try:
            row = self.model().db.data.id_to_index(book_id)
        except (IndexError, ValueError, KeyError, TypeError, AttributeError):
            return
        self.set_current_row(row)
        self.scroll_to_row(row)
        self.horizontalScrollBar().setValue(hpos)
        if self.pin_view.isVisible():
            self.pin_view.horizontalScrollBar().setValue(pv_hpos)

    def set_current_row(self, row=0, select=True, for_sync=False, book_id=None):
        if book_id is not None:
            row = self.model().db.data.id_to_index(book_id)
        if row > -1 and row < self.model().rowCount(QModelIndex()):
            h = self.horizontalHeader()
            hpos = self.horizontalScrollBar().value()
            logical_indices = list(range(h.count()))
            logical_indices = [x for x in logical_indices if not
                    h.isSectionHidden(x)]
            pairs = [(x, h.visualIndex(x)) for x in logical_indices if
                    h.visualIndex(x) > -1]
            if not pairs:
                pairs = [(0, 0)]
            pairs.sort(key=lambda x: x[1])
            i = pairs[0][0]
            index = self.model().index(row, i)
            ci = self.currentIndex()
            if ci.isValid():
                index = self.model().index(row, ci.column())
            if for_sync:
                sm = self.selectionModel()
                sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.NoUpdate)
            else:
                self.setCurrentIndex(index)
                if select:
                    sm = self.selectionModel()
                    sm.select(index, QItemSelectionModel.SelectionFlag.ClearAndSelect|QItemSelectionModel.SelectionFlag.Rows)
            self.horizontalScrollBar().setValue(hpos)

    def select_cell(self, row_number=0, logical_column=0):
        if row_number > -1 and row_number < self.model().rowCount(QModelIndex()):
            index = self.model().index(row_number, logical_column)
            self.setCurrentIndex(index)
            sm = self.selectionModel()
            sm.select(index, QItemSelectionModel.SelectionFlag.ClearAndSelect|QItemSelectionModel.SelectionFlag.Rows)
            sm.select(index, QItemSelectionModel.SelectionFlag.Current)
            self.clicked.emit(index)

    def row_at_top(self):
        pos = 0
        while pos < 100:
            ans = self.rowAt(pos)
            if ans > -1:
                return ans
            pos += 5

    def row_at_bottom(self):
        pos = self.viewport().height()
        limit = pos - 100
        while pos > limit:
            ans = self.rowAt(pos)
            if ans > -1:
                return ans
            pos -= 5

    def moveCursor(self, action, modifiers):
        orig = self.currentIndex()
        action = QAbstractItemView.CursorAction(action)
        index = QTableView.moveCursor(self, action, modifiers)
        if action == QAbstractItemView.CursorAction.MovePageDown:
            moved = index.row() - orig.row()
            try:
                rows = self.row_at_bottom() - self.row_at_top()
            except TypeError:
                rows = moved
            if moved > rows:
                index = self.model().index(orig.row() + rows, index.column())
        elif action == QAbstractItemView.CursorAction.MovePageUp:
            moved = orig.row() - index.row()
            try:
                rows = self.row_at_bottom() - self.row_at_top()
            except TypeError:
                rows = moved
            if moved > rows:
                index = self.model().index(orig.row() - rows, index.column())
        elif action == QAbstractItemView.CursorAction.MoveHome and modifiers & Qt.KeyboardModifier.ControlModifier:
            return self.model().index(0, orig.column())
        elif action == QAbstractItemView.CursorAction.MoveEnd and modifiers & Qt.KeyboardModifier.ControlModifier:
            return self.model().index(self.model().rowCount(QModelIndex()) - 1, orig.column())
        return index

    def selectionCommand(self, index, event):
        if event and event.type() == QEvent.Type.KeyPress:
            key = event.key()
            mods = event.modifiers() & (
                Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier)
            if key in (Qt.Key.Key_Home, Qt.Key.Key_End) and mods == Qt.KeyboardModifier.ControlModifier:
                return QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows
            if key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and mods == Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier:
                return QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows
        return super().selectionCommand(index, event)

    def ids_to_rows(self, ids):
        row_map = OrderedDict()
        ids = frozenset(ids)
        m = self.model()
        for row in range(m.rowCount(QModelIndex())):
            if len(row_map) >= len(ids):
                break
            c = m.id(row)
            if c in ids:
                row_map[c] = row
        return row_map

    def select_rows(self, identifiers, using_ids=True, change_current=True,
            scroll=True):
        '''
        Select rows identified by identifiers. identifiers can be a set of ids,
        row numbers or QModelIndexes.
        '''
        rows = {x.row() if hasattr(x, 'row') else x for x in
            identifiers}
        if using_ids:
            rows = set()
            identifiers = set(identifiers)
            m = self.model()
            for row in range(m.rowCount(QModelIndex())):
                if m.id(row) in identifiers:
                    rows.add(row)
        rows = list(sorted(rows))
        if rows:
            row = rows[0]
            if change_current:
                self.set_current_row(row, select=False)
            if scroll:
                self.scroll_to_row(row)
        sm = self.selectionModel()
        sel = QItemSelection()
        m = self.model()
        max_col = m.columnCount(QModelIndex()) - 1
        # Create a range based selector for each set of contiguous rows
        # as supplying selectors for each individual row causes very poor
        # performance if a large number of rows has to be selected.
        for k, g in itertools.groupby(enumerate(rows), lambda i_x:i_x[0]-i_x[1]):
            group = list(map(operator.itemgetter(1), g))
            sel.merge(QItemSelection(m.index(min(group), 0),
                m.index(max(group), max_col)), QItemSelectionModel.SelectionFlag.Select)
        sm.select(sel, QItemSelectionModel.SelectionFlag.ClearAndSelect)
        return rows

    def get_selected_ids(self, as_set=False):
        ans = []
        seen = set()
        m = self.model()
        for idx in self.selectedIndexes():
            r = idx.row()
            i = m.id(r)
            if i not in seen and i is not None:
                ans.append(i)
                seen.add(i)
        return seen if as_set else ans

    @property
    def current_id(self):
        try:
            return self.model().id(self.currentIndex())
        except Exception:
            pass
        return None

    @current_id.setter
    def current_id(self, val):
        if val is None:
            return
        m = self.model()
        for row in range(m.rowCount(QModelIndex())):
            if m.id(row) == val:
                self.set_current_row(row, select=False)
                break

    def show_next_book(self):
        ci = self.currentIndex()
        if not ci.isValid():
            self.set_current_row()
            return
        n = (ci.row() + 1) % self.model().rowCount(QModelIndex())
        self.set_current_row(n)

    @property
    def next_id(self):
        '''
        Return the id of the 'next' row (i.e. the first unselected row after
        the current row).
        '''
        ci = self.currentIndex()
        if not ci.isValid():
            return None
        selected_rows = frozenset(i.row() for i in self.selectedIndexes() if
            i.isValid())
        column = ci.column()

        for i in range(ci.row()+1, self.row_count()):
            if i in selected_rows:
                continue
            try:
                return self.model().id(self.model().index(i, column))
            except Exception:
                pass

        # No unselected rows after the current row, look before
        for i in range(ci.row()-1, -1, -1):
            if i in selected_rows:
                continue
            try:
                return self.model().id(self.model().index(i, column))
            except Exception:
                pass
        return None

    def close(self):
        self._model.close()

    def on_current_row_change(self, current, previous):
        self._model.current_changed(current, previous)

    def set_editable(self, editable, supports_backloading):
        self._model.set_editable(editable)

    def move_highlighted_row(self, forward):
        rows = self.selectionModel().selectedRows()
        if len(rows) > 0:
            current_row = rows[0].row()
        else:
            current_row = None
        id_to_select = self._model.get_next_highlighted_id(current_row, forward)
        if id_to_select is not None:
            self.select_rows([id_to_select], using_ids=True)

    def search_proxy(self, txt):
        if self.is_library_view:
            # Save the current book before doing the search, after the search
            # is completed, this book will become the current book and be
            # scrolled to if it is present in the search results
            self.alternate_views.save_current_book_state()
        self._model.search(txt)
        id_to_select = self._model.get_current_highlighted_id()
        if id_to_select is not None:
            self.select_rows([id_to_select], using_ids=True)
        elif self._model.highlight_only:
            self.clearSelection()
        if self.isVisible() and getattr(txt, 'as_you_type', False) is not True:
            self.setFocus(Qt.FocusReason.OtherFocusReason)

    def connect_to_search_box(self, sb, search_done):
        sb.search.connect(self.search_proxy)
        self._search_done = search_done
        self._model.searched.connect(self.search_done)
        if self.is_library_view:
            self._model.search_done.connect(self.alternate_views.restore_current_book_state)

    def connect_to_book_display(self, bd):
        self.book_display_callback = bd
        self.connect_to_book_display_timer = t = QTimer(self)
        t.setSingleShot(True)
        t.timeout.connect(self._debounce_book_display)
        t.setInterval(BOOK_DETAILS_DISPLAY_DEBOUNCE_DELAY)
        self._model.new_bookdisplay_data.connect(self._timed_connect_to_book_display)

    def _timed_connect_to_book_display(self, data):
        self._book_display_data = data
        self.connect_to_book_display_timer.start()

    def _debounce_book_display(self):
        data, self._book_display_data = self._book_display_data, None
        self.book_display_callback(data)

    def search_done(self, ok):
        self._search_done(self, ok)

    def row_count(self):
        return self._model.count()

# }}}


class DeviceBooksView(BooksView):  # {{{

    is_library_view = False

    def __init__(self, parent):
        BooksView.__init__(self, parent, DeviceBooksModel,
                           use_edit_metadata_dialog=False)
        self._model.resize_rows.connect(self.do_row_sizing,
                                                 type=Qt.ConnectionType.QueuedConnection)
        self.can_add_columns = False
        self.resize_on_select = False
        self.rating_delegate = None
        self.half_rating_delegate = None
        for i in range(10):
            self.setItemDelegateForColumn(i, TextDelegate(self))
        self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
        self.setAcceptDrops(False)
        self.set_row_header_visibility()

    def set_row_header_visibility(self):
        self.row_header.setVisible(gprefs['row_numbers_in_book_list'])

    def drag_data(self):
        m = self.model()
        rows = self.selectionModel().selectedRows()
        paths = [force_unicode(p, enc=filesystem_encoding) for p in m.paths(rows) if p]
        md = QMimeData()
        md.setData('application/calibre+from_device', b'dummy')
        from calibre.gui2.dnd import set_urls_from_local_file_paths
        set_urls_from_local_file_paths(md, *paths)
        drag = QDrag(self)
        drag.setMimeData(md)
        cover = self.drag_icon(m.cover(self.currentIndex().row()), len(paths) > 1)
        drag.setHotSpot(QPoint(-15, -15))
        drag.setPixmap(cover)
        return drag

    def contextMenuEvent(self, event):
        edit_collections = callable(getattr(self._model.db, 'supports_collections', None)) and \
            self._model.db.supports_collections() and \
            prefs['manage_device_metadata'] == 'manual'

        self.edit_collections_action.setVisible(edit_collections)
        self.context_menu.popup(event.globalPos())
        event.accept()

    def get_old_state(self):
        ans = None
        name = str(self.objectName())
        if name:
            name += ' books view state'
            ans = gprefs.get(name, None)
        return ans

    def write_state(self, state):
        name = str(self.objectName())
        if name:
            gprefs.set(name + ' books view state', state)

    def set_database(self, db):
        self._model.set_database(db)
        self.restore_state()

    def connect_dirtied_signal(self, slot):
        self._model.booklist_dirtied.connect(slot)

    def connect_upload_collections_signal(self, func=None, oncard=None):
        self._model.upload_collections.connect(partial(func, view=self, oncard=oncard))

    def dropEvent(self, *args):
        error_dialog(self, _('Not allowed'),
        _('Dropping onto a device is not supported. First add the book to the calibre library.')).exec()

    def set_editable(self, editable, supports_backloading):
        self._model.set_editable(editable)
        self.drag_allowed = supports_backloading

    def resort(self):
        h = self.horizontalHeader()
        self.model().sort(h.sortIndicatorSection(), h.sortIndicatorOrder())

    def reverse_sort(self):
        h = self.horizontalHeader()
        h.setSortIndicator(
            h.sortIndicatorSection(), Qt.SortOrder.AscendingOrder if h.sortIndicatorOrder() == Qt.SortOrder.DescendingOrder else Qt.SortOrder.DescendingOrder)
# }}}
