From 85bbba8f7c7a3f929104f34156c8b63c7b3531a4 Mon Sep 17 00:00:00 2001 From: un-pogaz <46523284+un-pogaz@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:15:08 +0100 Subject: [PATCH] refactoring bookshelf view --- src/calibre/gui2/central.py | 80 +- src/calibre/gui2/init.py | 108 +- src/calibre/gui2/library/bookshelf_view.py | 3790 ++++++++------------ src/calibre/gui2/ui.py | 1 + 4 files changed, 1671 insertions(+), 2308 deletions(-) diff --git a/src/calibre/gui2/central.py b/src/calibre/gui2/central.py index 0b4ec122f2..691181ee90 100644 --- a/src/calibre/gui2/central.py +++ b/src/calibre/gui2/central.py @@ -182,8 +182,14 @@ class LayoutButton(QToolButton): gui.iactions['Preferences'].do_config(initial_plugin=('Interface', 'Search'), close_after_initial=True) ev.accept() return - tab_name = {'book_details':'book_details', 'cover_grid':'cover_grid', 'cover_browser':'cover_browser', - 'tag_browser':'tag_browser', 'quick_view':'quickview'}.get(self.name) + tab_name = { + 'book_details':'book_details', + 'cover_grid':'cover_grid', + 'bookshelf_view':'bookshelf_view', + 'cover_browser':'cover_browser', + 'tag_browser':'tag_browser', + 'quick_view':'quickview', + }.get(self.name) if tab_name: if gui is not None: gui.iactions['Preferences'].do_config(initial_plugin=('Interface', 'Look & Feel', tab_name+'_tab'), close_after_initial=True) @@ -320,7 +326,6 @@ class Visibility: book_list: bool = True cover_browser: bool = False quick_view: bool = False - bookshelf: bool = False def serialize(self): return asdict(self) @@ -359,22 +364,18 @@ class CentralContainer(QWidget): self.cover_browser = Placeholder('cover browser', self) self.book_details = Placeholder('book details', self) self.quick_view = Placeholder('quick view', self) - self.bookshelf = Placeholder('bookshelf', self) else: self.tag_browser = QWidget(self) self.book_list = QWidget(self) self.cover_browser = QWidget(self) self.book_details = QWidget(self) self.quick_view = QWidget(self) - self.bookshelf = QWidget(self) - self.bookshelf.setMinimumSize(MIN_SIZE) self.cover_browser.setMinimumSize(MIN_SIZE) self.ignore_button_toggles = False self.tag_browser_button = LayoutButton('tag_browser', 'tags.png', _('Tag browser'), self, 'Shift+Alt+T') self.book_details_button = LayoutButton('book_details', 'book.png', _('Book details'), self, 'Shift+Alt+D') self.cover_browser_button = LayoutButton('cover_browser', 'cover_flow.png', _('Cover browser'), self, 'Shift+Alt+B') self.quick_view_button = LayoutButton('quick_view', 'quickview.png', _('Quickview'), self) - self.bookshelf_button = LayoutButton('bookshelf', 'bookshelf.png', _('Book Shelf'), self, 'Shift+Alt+S') self.setMinimumSize(MIN_SIZE + QSize(200, 100)) def h(orientation: Qt.Orientation = Qt.Orientation.Vertical): @@ -419,7 +420,6 @@ class CentralContainer(QWidget): self.book_details_button.initialize_with_gui(gui) self.cover_browser_button.initialize_with_gui(gui) self.quick_view_button.initialize_with_gui(gui) - self.bookshelf_button.initialize_with_gui(gui) self.set_widget('book_details', gui.book_details) self.set_widget('tag_browser', gui.tb_widget) self.set_widget('book_list', book_list_widget) @@ -475,33 +475,7 @@ class CentralContainer(QWidget): b = self.sender() if b.name == 'quick_view': return - # Mutual exclusivity: when bookshelf is shown, hide cover_browser - # Bookshelf is a layout panel (like Cover Browser), not an alternate view - # So it doesn't affect Cover Grid (which is an alternate view) - orig = self.ignore_button_toggles - self.ignore_button_toggles = True - try: - if b.name == 'bookshelf' and b.isChecked(): - # Hide cover_browser before showing bookshelf - if self.is_visible.cover_browser: - self.set_visibility_of('cover_browser', False) - self.set_visibility_of('bookshelf', True) - elif b.name == 'bookshelf' and not b.isChecked(): - self.set_visibility_of('bookshelf', False) - elif b.name == 'cover_browser' and b.isChecked(): - # Hide bookshelf before showing cover_browser - if self.is_visible.bookshelf: - self.set_visibility_of('bookshelf', False) - self.set_visibility_of('cover_browser', True) - elif b.name == 'cover_browser' and not b.isChecked(): - self.set_visibility_of('cover_browser', False) - else: - # For other buttons, just set visibility normally - self.set_visibility_of(b.name, b.isChecked()) - finally: - self.ignore_button_toggles = orig - # Update button text to reflect current state - b.update_text() + self.set_visibility_of(b.name, b.isChecked()) self.relayout() def unserialize_settings(self, s): @@ -552,6 +526,8 @@ class CentralContainer(QWidget): def set_visibility_of(self, which, visible): was_visible = getattr(self.is_visible, which) + if visible == was_visible: + return setattr(self.is_visible, which, visible) if not was_visible: if self.layout is Layout.wide: @@ -586,9 +562,6 @@ class CentralContainer(QWidget): self.book_details_button.setChecked(self.is_visible.book_details) self.cover_browser_button.setChecked(self.is_visible.cover_browser) self.quick_view_button.setChecked(self.is_visible.quick_view) - self.bookshelf_button.setChecked(self.is_visible.bookshelf) - # Update button text after setting checked state - self.bookshelf_button.update_text() finally: self.ignore_button_toggles = orig @@ -635,7 +608,6 @@ class CentralContainer(QWidget): self.cover_browser.setVisible(self.is_visible.cover_browser and not self.separate_cover_browser) self.book_list.setVisible(self.is_visible.book_list) self.quick_view.setVisible(self.is_visible.quick_view) - self.bookshelf.setVisible(self.is_visible.bookshelf) if self.layout is Layout.wide: self.right_handle.set_orientation(Qt.Orientation.Vertical) self.do_wide_layout() @@ -711,15 +683,14 @@ class CentralContainer(QWidget): h.resize(int(central_width), int(height)) available_height -= height - # Calculate height for cover_browser or bookshelf (they're mutually exclusive) - cb_height = max(self.cover_browser.minimumHeight(), int(self.wide_desires.cover_browser_height * self.height())) - if not (self.is_visible.cover_browser or self.is_visible.bookshelf) or self.separate_cover_browser: - cb_height = 0 + cb = max(self.cover_browser.minimumHeight(), int(self.wide_desires.cover_browser_height * self.height())) + if not self.is_visible.cover_browser or self.separate_cover_browser: + cb = 0 qv = bl = 0 - if cb_height >= available_height: - cb_height = available_height + if cb >= available_height: + cb = available_height else: - available_height -= cb_height + available_height -= cb min_bl_height = 50 if available_height <= min_bl_height: bl = available_height @@ -730,12 +701,9 @@ class CentralContainer(QWidget): bl = available_height - qv else: bl = available_height - # Position cover_browser or bookshelf in the same location if self.is_visible.cover_browser and not self.separate_cover_browser: - self.cover_browser.setGeometry(int(central_x), 0, int(central_width), int(cb_height)) - elif self.is_visible.bookshelf: - self.bookshelf.setGeometry(int(central_x), 0, int(central_width), int(cb_height)) - self.top_handle.move(central_x, cb_height) + self.cover_browser.setGeometry(int(central_x), 0, int(central_width), int(cb)) + self.top_handle.move(central_x, cb) if self.is_visible.book_list: self.book_list.setGeometry(int(central_x), int(self.top_handle.y() + self.top_handle.height()), int(central_width), int(bl)) self.bottom_handle.move(central_x, self.book_list.y() + self.book_list.height()) @@ -844,14 +812,11 @@ class CentralContainer(QWidget): central_x = self.left_handle.x() + self.left_handle.width() central_width = self.width() - central_x central_height -= self.right_handle.height() - # Calculate height for cover_browser or bookshelf (they're mutually exclusive) - cb = min(max(0, central_height - 80), int(self.height() * self.narrow_desires.cover_browser_width)) if (self.is_visible.cover_browser or self.is_visible.bookshelf) else 0 + cb = min(max(0, central_height - 80), int(self.height() * self.narrow_desires.cover_browser_width)) if self.is_visible.cover_browser else 0 if cb and cb < self.cover_browser.minimumHeight(): cb = min(self.cover_browser.minimumHeight(), central_height) if self.is_visible.cover_browser: self.cover_browser.setGeometry(central_x, 0, central_width, cb) - elif self.is_visible.bookshelf: - self.bookshelf.setGeometry(central_x, 0, central_width, cb) self.right_handle.resize(central_width, self.right_handle.height()) self.right_handle.move(central_x, cb) central_top = self.right_handle.y() + self.right_handle.height() @@ -896,9 +861,8 @@ class CentralContainer(QWidget): h.resize(int(width), int(central_height)) available_width -= width tb = int(self.narrow_desires.tag_browser_width * self.width()) if self.is_visible.tag_browser else 0 - # Calculate width for cover_browser or bookshelf (they're mutually exclusive) cb = max(self.cover_browser.minimumWidth(), - int(self.narrow_desires.cover_browser_width * self.width())) if (self.is_visible.cover_browser or self.is_visible.bookshelf) and not self.separate_cover_browser else 0 + int(self.narrow_desires.cover_browser_width * self.width())) if self.is_visible.cover_browser and not self.separate_cover_browser else 0 min_central_width = self.min_central_width_narrow() if tb + cb > max(0, available_width - min_central_width): width_to_share = max(0, available_width - min_central_width) @@ -915,8 +879,6 @@ class CentralContainer(QWidget): self.right_handle.move(tb + central_width + self.left_handle.width(), 0) if self.is_visible.cover_browser and not self.separate_cover_browser: self.cover_browser.setGeometry(int(self.right_handle.x() + self.right_handle.width()), 0, int(cb), int(central_height)) - elif self.is_visible.bookshelf: - self.bookshelf.setGeometry(int(self.right_handle.x() + self.right_handle.width()), 0, int(cb), int(central_height)) self.top_handle.resize(int(central_width), int(normal_handle_width if self.is_visible.quick_view else 0)) central_height -= self.top_handle.height() qv = 0 diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index c6bdafbf00..b73135559a 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -35,6 +35,7 @@ from calibre.gui2.book_details import BookDetails from calibre.gui2.central import CentralContainer, LayoutButton from calibre.gui2.layout_menu import LayoutMenu from calibre.gui2.library.alternate_views import GridView +from calibre.gui2.library.bookshelf_view import BookshelfView from calibre.gui2.library.views import BooksView, DeviceBooksView from calibre.gui2.notify import get_notifier from calibre.gui2.tag_browser.ui import TagBrowserWidget @@ -84,10 +85,6 @@ class LibraryViewMixin: # {{{ db.set_book_on_device_func(self.book_on_device) self.library_view.set_database(db) self.library_view.model().set_book_on_device_func(self.book_on_device) - # Set model for bookshelf view (it's a layout panel, not an alternate view) - if hasattr(self, 'bookshelf_view'): - self.bookshelf_view.setModel(self.library_view._model) - self.bookshelf_view.set_database(db, stage=0) prefs['library_path'] = self.library_path for view in ('library', 'memory', 'card_a', 'card_b'): @@ -246,19 +243,32 @@ class StatusBar(QStatusBar): # {{{ # }}} -class GridViewButton(LayoutButton): # {{{ +class AlternateViewsButtons(LayoutButton): # {{{ - def __init__(self, gui): - sc = 'Alt+Shift+G' - LayoutButton.__init__(self, 'cover_grid', 'grid.png', _('Cover grid'), gui, shortcut=sc) + buttons = set() + ignore_toggles = False + + def __init__(self, name: str, icon: str, label: str, view_name: str, gui: CentralContainer, shortcut=None, config_key=None): + LayoutButton.__init__(self, name, icon, label, gui, shortcut=shortcut) self.set_state_to_show() self.action_toggle = QAction(self.icon(), _('Toggle') + ' ' + self.label, self) - gui.addAction(self.action_toggle) - gui.keyboard.register_shortcut('grid view toggle' + self.label, str(self.action_toggle.text()), - default_keys=(sc,), action=self.action_toggle, group=_('Main window layout')) + self.gui = gui + self.gui.addAction(self.action_toggle) + self.ck = config_key or name + self.view_name = view_name + if shortcut: + self.gui.keyboard.register_shortcut( + f'{self.ck} toggle {self.label}', + str(self.action_toggle.text()), + default_keys=(shortcut,), + action=self.action_toggle, + group=_('Main window layout'), + ) self.action_toggle.triggered.connect(self.toggle) self.action_toggle.changed.connect(self.update_shortcut) self.toggled.connect(self.update_state) + self.toggled.connect(self.toggle_view) + self.buttons.add(self) @property def is_visible(self): @@ -271,11 +281,54 @@ class GridViewButton(LayoutButton): # {{{ self.set_state_to_show() def save_state(self): - gprefs['grid view visible'] = bool(self.isChecked()) + gprefs[f'{self.ck} visible'] = bool(self.isChecked()) def restore_state(self): - if gprefs.get('grid view visible', False): + if gprefs.get(f'{self.ck} visible', False): self.toggle() + + def toggle_view(self, show): + if AlternateViewsButtons.ignore_toggles: + return + AlternateViewsButtons.ignore_toggles = True + for btn in self.buttons: + if btn == self: + continue + if btn.isChecked(): + btn.update_state(False) + self.gui.library_view.alternate_views.show_view(self.view_name if show else None) + self.gui.sort_button.setVisible(show) + AlternateViewsButtons.ignore_toggles = False +# }}} + + +class GridViewButton(AlternateViewsButtons): # {{{ + def __init__(self, gui): + AlternateViewsButtons.__init__( + self, + 'cover_grid', + 'grid.png', + _('Cover grid'), + 'grid', + gui, + shortcut='Alt+Shift+G', + config_key='grid view', + ) +# }}} + + +class BookshelfViewButton(AlternateViewsButtons): # {{{ + def __init__(self, gui): + AlternateViewsButtons.__init__( + self, + 'bookshelf_view', + 'bookshelf.png', + _('Bookshelf view'), + 'bookshelf', + gui, + shortcut='Alt+Shift+B', + config_key='bookshelf view', + ) # }}} @@ -535,11 +588,16 @@ class LayoutMixin: # {{{ for x in self.button_order: if x == 'gv': button = self.grid_view_button + elif x == 'bs': + button = self.bookshelf_view_button elif x == 'sb': button = self.search_bar_button else: button = self.layout_container.button_for({ - 'tb': 'tag_browser', 'bd': 'book_details', 'cb': 'cover_browser', 'qv': 'quick_view', 'bs': 'bookshelf' + 'tb': 'tag_browser', + 'bd': 'book_details', + 'cb': 'cover_browser', + 'qv': 'quick_view', }[x]) self.layout_buttons.append(button) button.setVisible(gprefs['show_layout_buttons']) @@ -568,15 +626,9 @@ class LayoutMixin: # {{{ self.grid_view = GridView(self) self.grid_view.setObjectName('grid_view') av.add_view('grid', self.grid_view) - from calibre.gui2.library.bookshelf_view import BookshelfView self.bookshelf_view = BookshelfView(self) self.bookshelf_view.setObjectName('bookshelf_view') - # Bookshelf is a layout panel (like Cover Browser), not an alternate view - # Set it in the layout container's bookshelf widget - self.layout_container.set_widget('bookshelf', self.bookshelf_view) - # Set the model for bookshelf view (will be updated when database is set) - if hasattr(self.library_view, '_model'): - self.bookshelf_view.setModel(self.library_view._model) + av.add_view('bookshelf', self.bookshelf_view) self.tb_widget = TagBrowserWidget(self) self.memory_view = DeviceBooksView(self) self.stack.addWidget(self.memory_view) @@ -599,8 +651,8 @@ class LayoutMixin: # {{{ self.tb_widget.set_pane_is_visible, Qt.ConnectionType.QueuedConnection) self.status_bar = StatusBar(self) self.grid_view_button = GridViewButton(self) + self.bookshelf_view_button = BookshelfViewButton(self) self.search_bar_button = SearchBarButton(self) - self.grid_view_button.toggled.connect(self.toggle_grid_view) self.search_bar_button.toggled.connect(self.toggle_search_bar) self.layout_button = b = QToolButton(self) @@ -718,16 +770,6 @@ class LayoutMixin: # {{{ tb.item_search.lineEdit().setText(field + ':=' + value) tb.do_find() - def toggle_grid_view(self, show): - self.library_view.alternate_views.show_view('grid' if show else None) - self.sort_button.setVisible(show) - # Mutual exclusivity: when grid view is shown, hide bookshelf - if show: - bookshelf_button = self.layout_container.button_for('bookshelf') - if bookshelf_button and bookshelf_button.isChecked(): - bookshelf_button.setChecked(False) - self.layout_container.set_visibility_of('bookshelf', False) - def toggle_search_bar(self, show): self.search_bar.setVisible(show) if show: @@ -805,6 +847,7 @@ class LayoutMixin: # {{{ getattr(self, x+'_view').save_state() self.layout_container.write_settings() self.grid_view_button.save_state() + self.bookshelf_view_button.save_state() self.search_bar_button.save_state() def read_layout_settings(self): @@ -813,6 +856,7 @@ class LayoutMixin: # {{{ self.book_details.change_layout(self.layout_container.is_wide) self.place_layout_buttons() self.grid_view_button.restore_state() + self.bookshelf_view_button.restore_state() self.search_bar_button.restore_state() def update_status_bar(self, *args): diff --git a/src/calibre/gui2/library/bookshelf_view.py b/src/calibre/gui2/library/bookshelf_view.py index 03bc79d96a..9b0edcdcb4 100644 --- a/src/calibre/gui2/library/bookshelf_view.py +++ b/src/calibre/gui2/library/bookshelf_view.py @@ -1,180 +1,151 @@ #!/usr/bin/env python +# License: GPLv3 +# Copyright: Andy C , un_pogaz , Kovid Goyal -__license__ = 'GPL v3' -__copyright__ = '2025, Kovid Goyal ' - +import hashlib +import math +import os from collections import defaultdict +from collections.abc import Callable, Iterable +from contextlib import suppress +from datetime import datetime +from functools import partial from io import BytesIO -from threading import Lock +from queue import LifoQueue +from threading import Thread from time import time +from typing import NamedTuple +from PIL import Image from qt.core import ( QAbstractScrollArea, QApplication, QBrush, QColor, + QContextMenuEvent, QEvent, QFont, QFontMetrics, - QImage, QItemSelection, QItemSelectionModel, QLinearGradient, + QMenu, QModelIndex, QPainter, + QPaintEvent, QPalette, QPen, + QPixmap, QPoint, QPointF, - QPixmap, QRect, - QSize, + QResizeEvent, Qt, QTimer, - QWidget, + QWheelEvent, pyqtSignal, qBlue, qGreen, qRed, ) -from calibre.gui2 import gprefs +from calibre.constants import islinux +from calibre.db.cache import Cache +from calibre.ebooks.metadata import rating_to_stars +from calibre.ebooks.metadata.book.base import Metadata +from calibre.gui2 import gprefs, resolve_bookshelf_color from calibre.gui2.library.alternate_views import setup_dnd_interface from calibre.gui2.library.caches import ThumbnailCache -from calibre.utils.localization import _ +from calibre.gui2.library.models import BooksModel +from calibre.utils import join_with_timeout +from calibre.utils.date import is_date_undefined +from calibre.utils.icu import numeric_sort_key +from calibre.utils.img import convert_PIL_image_to_pixmap -try: - from PIL import Image - PIL_AVAILABLE = True -except ImportError: - PIL_AVAILABLE = False - - -# Color cache to avoid re-extracting colors for the same covers -_color_cache = {} -_color_cache_lock = Lock() - -# Thumbnail cache for spine thumbnails -_thumbnail_cache = {} -_thumbnail_cache_lock = Lock() +DEFAULT_SPINE_COLOR = QColor('#8B4513') # Brown, will be recalculated later +DEFAULT_COVER = Image.open(I('default_cover.png')) +TEMPLATE_ERROR_COLOR = QColor('#9C27B0') +TEMPLATE_ERROR = _('TEMPLATE ERROR') +CACHE_FORMAT = 'PPM' # Utility functions {{{ -def extract_dominant_color(image_data, fallback_color='#8B4513'): +def get_reading_statue(book_id, db, mi=None) -> str: ''' - Extract the dominant color from an image. Returns a QColor. + Determine reading statue for a book based on + the last read position (if available) + + Returns: 'unread', 'reading', or 'finished' ''' - if image_data is None: - return QColor(fallback_color) + if not mi: + mi = db.new_api.get_proxy_metadata(book_id) - # Convert to PIL Image if needed - pil_image = None - if PIL_AVAILABLE: - if isinstance(image_data, (QImage, QPixmap)): - # Convert QImage/QPixmap to PIL Image - if isinstance(image_data, QPixmap): - qimg = image_data.toImage() - else: - qimg = image_data - # Convert to bytes - buffer = BytesIO() - qimg.save(buffer, 'PNG') - buffer.seek(0) - pil_image = Image.open(buffer) - elif isinstance(image_data, Image.Image): - pil_image = image_data - elif isinstance(image_data, bytes): - pil_image = Image.open(BytesIO(image_data)) - else: - # Fallback: use QImage directly - if isinstance(image_data, QPixmap): - qimg = image_data.toImage() - elif isinstance(image_data, QImage): - qimg = image_data - elif isinstance(image_data, bytes): - qimg = QImage() - qimg.loadFromData(image_data) - else: - return QColor(fallback_color) + formats = mi.get('formats') or [] + if formats: + # Check if any format has a last read position + for fmt in formats: + positions = db.new_api.get_last_read_positions(book_id, fmt, '_') + if positions: + # Has reading progress + for pos in positions: + pos_frac = pos.get('pos_frac', 0) + if pos_frac >= 0.95: # 95% or more = finished + return 'finished' + elif pos_frac > 0.01: # More than 1% = reading + return 'reading' - if qimg.isNull(): - return QColor(fallback_color) + return 'unread' - # Improved color extraction from QImage - # Resize to larger size for better color accuracy - small = qimg.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - if small.isNull(): - return QColor(fallback_color) - # Get pixel data with saturation preference - width = small.width() - height = small.height() - color_counts = defaultdict(int) +def size_to_page_count(size_bytes: int) -> int: + '''Estimate page count from file size.''' + # Average ebook: ~1-2KB per page, so estimate pages from size + if size_bytes and size_bytes > 0: + # Estimate: ~1500 bytes per page (conservative) + estimated_pages = max(50, size_bytes // 1500) + # Cap at reasonable max + return min(estimated_pages, 2000) + return None - for y in range(height): - for x in range(width): - pixel = small.pixel(x, y) - r = qRed(pixel) - g = qGreen(pixel) - b = qBlue(pixel) - # Quantize to 32 levels (8 levels per channel) - r_q = (r // 8) * 8 - g_q = (g // 8) * 8 - b_q = (b // 8) * 8 - color_counts[(r_q, g_q, b_q)] += 1 - if not color_counts: - return QColor(fallback_color) +def pseudo_random(book_id: int, maximum) -> int: + '''Use book_id to create a pseudo-random but consistent value per book.''' + val = str(book_id or 0).encode() + hash_val = int(hashlib.md5(val).hexdigest()[:8], 16) + return hash_val % maximum - # Find most common color with saturation preference - def color_score(item): - (r, g, b), count = item - max_val = max(r, g, b) - min_val = min(r, g, b) - if max_val == 0: - saturation = 0 - else: - saturation = (max_val - min_val) / max_val - return (count, saturation * 100) - sorted_colors = sorted(color_counts.items(), key=color_score, reverse=True) - dominant_color = sorted_colors[0][0] +def elapsed_time(ref_time: float) -> float: + '''Get elapsed time, in milliseconds.''' + return (time() - ref_time) * 1000 - # Prefer saturated over gray colors - r, g, b = dominant_color - max_val = max(r, g, b) - min_val = min(r, g, b) - saturation = (max_val - min_val) / max_val if max_val > 0 else 0 - if saturation < 0.2 and len(sorted_colors) > 1: - total_pixels = width * height - for (r2, g2, b2), count in sorted_colors[1:5]: - max_val2 = max(r2, g2, b2) - min_val2 = min(r2, g2, b2) - sat2 = (max_val2 - min_val2) / max_val2 if max_val2 > 0 else 0 - if sat2 > 0.3 and count > total_pixels * 0.05: - dominant_color = (r2, g2, b2) - break +# }}} - return QColor(dominant_color[0], dominant_color[1], dominant_color[2]) - if pil_image is None: - return QColor(fallback_color) +# Cover functions {{{ + +def extract_dominant_color(image: Image) -> QColor: + ''' + Extract the dominant color from an image. + ''' + if not image: + return None # Resize for performance and color accuracy - pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS) + image.thumbnail((100, 100)) # Convert to RGB if needed - if pil_image.mode != 'RGB': - pil_image = pil_image.convert('RGB') + if image.mode != 'RGB': + image = image.convert('RGB') # Extract dominant color using improved algorithm # Use less aggressive quantization # Quantize to 32 levels per channel color_counts = defaultdict(int) - pixels = pil_image.getdata() + pixels = image.getdata() for pixel in pixels: r, g, b = pixel @@ -186,7 +157,7 @@ def extract_dominant_color(image_data, fallback_color='#8B4513'): color_counts[(r_q, g_q, b_q)] += 1 if not color_counts: - return QColor(fallback_color) + return None # Find most common color, prefer saturated colors # Sort by frequency, then by saturation @@ -228,589 +199,400 @@ def extract_dominant_color(image_data, fallback_color='#8B4513'): return QColor(dominant_color[0], dominant_color[1], dominant_color[2]) -def get_cover_color(book_id, db, cache_key=None): +def get_cover_color(book_id, db) -> QColor: ''' - Get the dominant color from a book's cover, with caching. Returns a QColor. + Get the dominant color from a book's cover. Returns a QColor. ''' - if cache_key is None: - cache_key = book_id - - # Check cache first - with _color_cache_lock: - if cache_key in _color_cache: - cached = _color_cache[cache_key] - if cached is not None and cached.isValid(): - return cached - # Remove invalid cached color - _color_cache.pop(cache_key, None) - - # Load cover - try multiple methods - # Try multiple cover loading methods color = None - cover_data = None + has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0, as_what='pil_image') + if has_cover and cdata: + color = extract_dominant_color(cdata) - # Preferred method: new_api.cover_or_cache - try: - if hasattr(db, 'new_api') and hasattr(db.new_api, 'cover_or_cache'): - has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0, as_what='pil_image') - if has_cover and cdata is not None: - # cdata is a PIL Image when as_what='pil_image' - color = extract_dominant_color(cdata) - if color is not None and color.isValid() and color != QColor('#8B4513'): - pass - else: - color = None # Try other methods - except Exception: - pass + if color and color.isValid(): + return color - # Try as_image with index_is_id - if color is None or not color.isValid(): - try: - try: - cover_data = db.cover(book_id, index_is_id=True, as_image=True) - except (TypeError, AttributeError): - cover_data = db.cover(book_id, as_image=True) - if cover_data is not None and not cover_data.isNull(): - color = extract_dominant_color(cover_data) - except Exception: - pass - - # Method 3: If that failed, try as_pixmap - if color is None or not color.isValid(): - try: - try: - cover_data = db.cover(book_id, index_is_id=True, as_pixmap=True) - except (TypeError, AttributeError): - cover_data = db.cover(book_id, as_pixmap=True) - if cover_data is not None and not cover_data.isNull(): - color = extract_dominant_color(cover_data) - except Exception: - pass - - # Method 4: If still failed, try loading from bytes - if color is None or not color.isValid(): - try: - try: - cover_bytes = db.cover(book_id, index_is_id=True) - except (TypeError, AttributeError): - cover_bytes = db.cover(book_id) - if cover_bytes: - qimg = QImage() - if qimg.loadFromData(cover_bytes): - if not qimg.isNull(): - color = extract_dominant_color(qimg) - except Exception: - pass - - # Method 5: If still failed, try as_path - if color is None or not color.isValid(): - try: - try: - cover_path = db.cover(book_id, index_is_id=True, as_path=True) - except (TypeError, AttributeError): - cover_path = db.cover(book_id, as_path=True) - if cover_path: - qimg = QImage(cover_path) - if not qimg.isNull(): - color = extract_dominant_color(qimg) - except Exception: - pass - - # Use default if extraction failed - if color is None or not color.isValid(): - return QColor('#8B4513') # Default brown (not cached) - - # Cache extracted colors - if color.rgb() != QColor('#8B4513').rgb(): - with _color_cache_lock: - _color_cache[cache_key] = color - # Limit cache size to 1000 entries - if len(_color_cache) > 1000: - # Remove oldest entries (simple FIFO) - keys_to_remove = list(_color_cache.keys())[:100] - for key in keys_to_remove: - _color_cache.pop(key, None) - - return color + return DEFAULT_SPINE_COLOR -def generate_spine_thumbnail(cover_data, target_height=20): +def generate_spine_thumbnail(image: Image, width: int, height: int) -> Image: ''' - Generate a small thumbnail for display on the spine. Returns a QPixmap or None. + Generate a thumbnail for display on the spine. Returns a PIL.Image. ''' - if cover_data is None: + if not image: return None - # Convert to QImage - qimg = None - if isinstance(cover_data, QPixmap): - qimg = cover_data.toImage() - elif isinstance(cover_data, QImage): - qimg = cover_data - elif isinstance(cover_data, bytes): - qimg = QImage() - if not qimg.loadFromData(cover_data): - return None - elif PIL_AVAILABLE and isinstance(cover_data, Image.Image): - # Convert PIL Image to QImage - from calibre.utils.img import convert_PIL_image_to_pixmap - pixmap = convert_PIL_image_to_pixmap(cover_data) - qimg = pixmap.toImage() - else: - return None - - if qimg.isNull(): - return None - - # Maintain aspect ratio while scaling - original_width = qimg.width() - original_height = qimg.height() - if original_height == 0: - return None - - aspect_ratio = original_width / original_height - target_width = int(target_height * aspect_ratio) - # Scale the image - scaled = qimg.scaled( - target_width, target_height, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - - if scaled.isNull(): - return None - - return QPixmap.fromImage(scaled) - - -def get_spine_thumbnail(book_id, db, target_height=20, cache_key=None): - ''' - Get a thumbnail for display on the spine, with caching. Returns a QPixmap or None. - ''' - if cache_key is None: - cache_key = (book_id, target_height) - - # Check cache first - with _thumbnail_cache_lock: - if cache_key in _thumbnail_cache: - return _thumbnail_cache[cache_key] - - # Load cover - try: - cover_data = db.cover(book_id, as_image=True) - if cover_data is None or cover_data.isNull(): - thumbnail = None - else: - thumbnail = generate_spine_thumbnail(cover_data, target_height) - except Exception: - thumbnail = None - - # Cache the result - if thumbnail is not None: - with _thumbnail_cache_lock: - _thumbnail_cache[cache_key] = thumbnail - # Limit cache size to 500 entries - if len(_thumbnail_cache) > 500: - # Remove oldest entries (simple FIFO) - keys_to_remove = list(_thumbnail_cache.keys())[:50] - for key in keys_to_remove: - _thumbnail_cache.pop(key, None) - - return thumbnail - - -def get_cover_texture_edge(cover_data, edge_width=10, opacity=0.3): - ''' - Extract the left edge of a cover image for use as a texture overlay. Returns a QPixmap or None. - ''' - if cover_data is None: - return None - - # Convert to QImage - qimg = None - if isinstance(cover_data, QPixmap): - qimg = cover_data.toImage() - elif isinstance(cover_data, QImage): - qimg = cover_data - elif isinstance(cover_data, bytes): - qimg = QImage() - if not qimg.loadFromData(cover_data): - return None - elif PIL_AVAILABLE and isinstance(cover_data, Image.Image): - from calibre.utils.img import convert_PIL_image_to_pixmap - pixmap = convert_PIL_image_to_pixmap(cover_data) - qimg = pixmap.toImage() - else: - return None - - if qimg.isNull(): - return None - - # Extract left edge - height = qimg.height() - edge_rect = QRect(0, 0, min(edge_width, qimg.width()), height) - edge_image = qimg.copy(edge_rect) - - # Apply opacity - if opacity < 1.0: - # Create a new image with alpha channel - rgba_image = QImage(edge_image.size(), QImage.Format.Format_ARGB32) - rgba_image.fill(QColor(0, 0, 0, 0)) - painter = QPainter(rgba_image) - painter.setOpacity(opacity) - painter.drawImage(0, 0, edge_image) - painter.end() - edge_image = rgba_image - - return QPixmap.fromImage(edge_image) - - -def invalidate_caches(book_ids=None): - ''' - Invalidate cached colors and thumbnails for specified books, or all if book_ids is None. - ''' - global _color_cache, _thumbnail_cache - - with _color_cache_lock: - if book_ids is None: - _color_cache.clear() - else: - # Remove matching entries - keys_to_remove = [] - for key in _color_cache.keys(): - if isinstance(key, tuple): - if key[0] in book_ids: - keys_to_remove.append(key) - elif key in book_ids: - keys_to_remove.append(key) - for key in keys_to_remove: - _color_cache.pop(key, None) - - with _thumbnail_cache_lock: - if book_ids is None: - _thumbnail_cache.clear() - else: - # Remove entries matching any book_id - keys_to_remove = [] - for key in _thumbnail_cache.keys(): - if isinstance(key, tuple): - if key[0] in book_ids: - keys_to_remove.append(key) - elif isinstance(key, (int, str)) and key in book_ids: - keys_to_remove.append(key) - for key in keys_to_remove: - _thumbnail_cache.pop(key, None) - - -def group_books_by_author(rows, model): - ''' - Group books by author. Returns list of (group_name, row_indices) tuples. - ''' - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('No Author')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - authors = getattr(mi, 'authors', []) or [] - - if authors: - # Use first author - author = authors[0] - groups[author].append(row) - else: - groups[_('No Author')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('No Author')].append(row) - - # Sort groups by name - sorted_groups = [] - for group_name in sorted(groups.keys(), key=lambda x: (x == _('No Author'), x.lower())): - group_rows = groups[group_name] - sorted_groups.append((group_name, group_rows)) - - return sorted_groups - - -def group_books_by_publisher(rows, model): - ''' - Group books by publisher. Returns list of (group_name, row_indices) tuples. - ''' - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('No Publisher')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - publisher = getattr(mi, 'publisher', None) - - if publisher: - groups[publisher].append(row) - else: - groups[_('No Publisher')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('No Publisher')].append(row) - - # Sort groups by name - sorted_groups = [] - for group_name in sorted(groups.keys(), key=lambda x: (x == _('No Publisher'), x.lower())): - group_rows = groups[group_name] - sorted_groups.append((group_name, group_rows)) - - return sorted_groups - - -def group_books_by_rating(rows, model): - ''' - Group books by rating (star rating). Returns list of (group_name, row_indices) tuples. - ''' - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('No Rating')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - rating = getattr(mi, 'rating', None) - - if rating and rating > 0: - # Calibre ratings are 0-10, display as stars (0-5) - stars = int(rating / 2) - group_name = '★' * stars if stars > 0 else _('No Rating') - groups[group_name].append(row) - else: - groups[_('No Rating')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('No Rating')].append(row) - - # Sort groups by rating (descending) - def rating_sort_key(group_name): - if group_name == _('No Rating'): - return (1, 0) # Put unrated at end - try: - stars = len(group_name) # Count star characters - return (0, -stars) # Negative for descending - except (ValueError, AttributeError): - return (1, 0) - - sorted_groups = [] - for group_name in sorted(groups.keys(), key=rating_sort_key): - group_rows = groups[group_name] - sorted_groups.append((group_name, group_rows)) - - return sorted_groups - - -def group_books_by_language(rows, model): - ''' - Group books by language. Returns list of (group_name, row_indices) tuples. - ''' - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('No Language')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - languages = getattr(mi, 'languages', []) or [] - - if languages: - # Use first language - lang = languages[0] - groups[lang].append(row) - else: - groups[_('No Language')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('No Language')].append(row) - - # Sort groups by name - sorted_groups = [] - for group_name in sorted(groups.keys(), key=lambda x: (x == _('No Language'), x.lower())): - group_rows = groups[group_name] - sorted_groups.append((group_name, group_rows)) - - return sorted_groups - - -def group_books_by_series(rows, model): - ''' - Group books by series name. Returns list of (group_name, row_indices) tuples. - ''' - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('No Series')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - series = getattr(mi, 'series', None) - - if series: - groups[series].append(row) - else: - groups[_('No Series')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('No Series')].append(row) - - # Sort groups by name, preserve model order within groups - sorted_groups = [] - for group_name in sorted(groups.keys(), key=lambda x: (x == _('No Series'), x.lower())): - group_rows = groups[group_name] - # Preserve model sort order - # Model already sorted per user's preference - sorted_groups.append((group_name, group_rows)) - - return sorted_groups - - -def group_books_by_genre(rows, model): - ''' - Group books by first tag (genre). Returns list of (group_name, row_indices) tuples. - ''' - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('No Genre')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - tags = getattr(mi, 'tags', []) or [] - - if tags: - # Use first tag as genre - genre = tags[0] - groups[genre].append(row) - else: - groups[_('No Genre')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('No Genre')].append(row) - - # Sort groups by name - sorted_groups = [] - for group_name in sorted(groups.keys(), key=lambda x: (x == _('No Genre'), x.lower())): - sorted_groups.append((group_name, groups[group_name])) - - return sorted_groups - - -def group_books_by_time_period(rows, model): - ''' - Group books by publication decade. Returns list of (group_name, row_indices) tuples. - ''' - from calibre.utils.date import parse_date - - groups = defaultdict(list) - - for row in rows: - try: - index = model.index(row, 0) - if not index.isValid(): - groups[_('Unknown Date')].append(row) - continue - - book_id = model.id(index) - mi = model.db.get_metadata(book_id, index_is_id=True) - pubdate = getattr(mi, 'pubdate', None) - - if pubdate: - try: - # Parse date and extract year - if hasattr(pubdate, 'year'): - year = pubdate.year - else: - dt = parse_date(str(pubdate)) - year = dt.year if dt else None - - if year: - # Group by decade (e.g., 2020-2029 -> "2020s") - decade = (year // 10) * 10 - group_name = f'{decade}s' - groups[group_name].append(row) - else: - groups[_('Unknown Date')].append(row) - except (ValueError, AttributeError, TypeError): - groups[_('Unknown Date')].append(row) - else: - groups[_('Unknown Date')].append(row) - except (AttributeError, IndexError, KeyError, TypeError): - groups[_('Unknown Date')].append(row) - - # Sort groups by decade (numerically) - def decade_sort_key(group_name): - if group_name == _('Unknown Date'): - return (1, 0) # Put unknown at end - try: - decade = int(group_name.replace('s', '')) - return (0, decade) - except (ValueError, AttributeError): - return (1, 0) - - sorted_groups = [] - for group_name in sorted(groups.keys(), key=decade_sort_key): - sorted_groups.append((group_name, groups[group_name])) - - return sorted_groups - - -def group_books(rows, model, grouping_mode): - ''' - Group books according to the specified grouping mode. - Returns list of (group_name, row_indices) tuples. - ''' - if grouping_mode == 'none' or not grouping_mode: - # No grouping - return single group with all rows - return [('', rows)] - elif grouping_mode == 'author': - return group_books_by_author(rows, model) - elif grouping_mode == 'series': - return group_books_by_series(rows, model) - elif grouping_mode == 'genre': - return group_books_by_genre(rows, model) - elif grouping_mode == 'publisher': - return group_books_by_publisher(rows, model) - elif grouping_mode == 'rating': - return group_books_by_rating(rows, model) - elif grouping_mode == 'language': - return group_books_by_language(rows, model) - elif grouping_mode == 'time_period': - return group_books_by_time_period(rows, model) - else: - # Unknown mode - return no grouping - return [('', rows)] + image.thumbnail((image.width, height)) + # Crops the image + image = image.crop((0, 0, width, height)) + # Convert to RGB to sanitize the data + if image.mode != 'RGB': + image = image.convert('RGB') + return image # }}} -@setup_dnd_interface -class BookshelfView(QAbstractScrollArea): # {{{ +# Groupings functions {{{ +def _group_sort_key(unknown: str, val: str) -> tuple[bool, str]: + # Put the unknown/default value at the end + return (val == unknown, numeric_sort_key(val)) + + +def _group_books_for_string(rows: Iterable[int], model: BooksModel, field: str, unknown: str) -> list[tuple[str, int]]: + ''' + Group books for a string field. Returns list of (group_name, row_indices) tuples. + ''' + groups = defaultdict(list) + for row in rows: + try: + index = model.index(row, 0) + if not index.isValid(): + groups[unknown].append(row) + continue + + book_id = model.id(index) + mi = model.db.new_api.get_proxy_metadata(book_id) + value = mi.get(field) + + if value: + groups[value].append(row) + else: + groups[unknown].append(row) + except (AttributeError, IndexError, KeyError, TypeError): + groups[unknown].append(row) + + # Sort groups by name + sorted_groups = list(groups.items()) + sorted_groups.sort(key=lambda x: _group_sort_key(unknown, x[0])) + return sorted_groups + + +def _group_books_for_list(rows: Iterable[int], model: BooksModel, field: str, unknown: str) -> list[tuple[str, int]]: + ''' + Group books for a list field, use only the first value. Returns list of (group_name, row_indices) tuples. + ''' + groups = defaultdict(list) + for row in rows: + try: + index = model.index(row, 0) + if not index.isValid(): + groups[unknown].append(row) + continue + + book_id = model.id(index) + mi = model.db.new_api.get_proxy_metadata(book_id) + values = mi.get(field) or [] + + if values: + # Use first value + value = values[0] + groups[value].append(row) + else: + groups[unknown].append(row) + except (AttributeError, IndexError, KeyError, TypeError): + groups[unknown].append(row) + + # Sort groups by name + sorted_groups = list(groups.items()) + sorted_groups.sort(key=lambda x: _group_sort_key(unknown, x[0])) + return sorted_groups + + +def _group_books_for_datetime(rows: Iterable[int], model: BooksModel, field: str, unknown: str, formatter: Callable[[datetime], str]) -> list[tuple[str, int]]: + ''' + Group books for a datetime field, formatter to convert to string. Returns list of (group_name, row_indices) tuples. + ''' + groups = defaultdict(list) + for row in rows: + try: + index = model.index(row, 0) + if not index.isValid(): + groups[unknown].append(row) + continue + + book_id = model.id(index) + mi = model.db.new_api.get_proxy_metadata(book_id) + date = mi.get(field) + + if not is_date_undefined(date): + groups[formatter(date)].append(row) + else: + groups[unknown].append(row) + except (AttributeError, IndexError, KeyError, TypeError): + groups[unknown].append(row) + + # Sort groups by name + sorted_groups = list(groups.items()) + sorted_groups.sort(key=lambda x: _group_sort_key(unknown, x[0])) + return sorted_groups + + +def group_books_by_author(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by author. Returns list of (group_name, row_indices) tuples. + ''' + return _group_books_for_list(rows, model, 'authors', _('No Author')) + + +def group_books_by_publisher(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by publisher. Returns list of (group_name, row_indices) tuples. + ''' + return _group_books_for_string(rows, model, 'publisher', _('No Publisher')) + + +def group_books_by_language(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by language. Returns list of (group_name, row_indices) tuples. + ''' + return _group_books_for_list(rows, model, 'languages', _('No Language')) + + +def group_books_by_series(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by series name. Returns list of (group_name, row_indices) tuples. + ''' + return _group_books_for_string(rows, model, 'series', _('No Series')) + + +def group_books_by_genre(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by first tag (genre). Returns list of (group_name, row_indices) tuples. + ''' + return _group_books_for_list(rows, model, 'tags', _('No Genre')) + + +def group_books_by_pubdate(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by publication decade. Returns list of (group_name, row_indices) tuples. + ''' + def formatter(datetime): + # Group by decade (e.g., 2020-2029 -> "2020s") + decade = (datetime.year // 10) * 10 + return f'{decade}s' + return _group_books_for_datetime(rows, model, 'pubdate', _('Unknown Date'), formatter) + + +def group_books_by_timestamp(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by month addition. Returns list of (group_name, row_indices) tuples. + ''' + def formatter(datetime): + # Group by month (e.g. "2020/05") + return f'{datetime.year}/{datetime.month:02}' + return _group_books_for_datetime(rows, model, 'timestamp', _('Unknown Date'), formatter) + + +def group_books_by_rating(rows: Iterable[int], model: BooksModel) -> list[tuple[str, int]]: + ''' + Group books by rating (star rating). Returns list of (group_name, row_indices) tuples. + ''' + groups = defaultdict(list) + unknown = _('No Rating') + + for row in rows: + try: + index = model.index(row, 0) + if not index.isValid(): + groups[unknown].append(row) + continue + + book_id = model.id(index) + mi = model.db.new_api.get_proxy_metadata(book_id) + rating = mi.get('rating') + + if rating and rating > 0: + groups[rating_to_stars(rating)].append(row) + else: + groups[unknown].append(row) + except (AttributeError, IndexError, KeyError, TypeError): + groups[unknown].append(row) + + # Sort groups by rating (descending) + def sort_key(group_name): + if group_name == unknown: + return (1, 0) # Put unrated at end + stars = len(group_name) # Count star characters + return (0, -stars) # Negative for descending + + sorted_groups = list(groups.items()) + sorted_groups.sort(key=lambda x: sort_key(x[0])) + return sorted_groups + + +def group_books(rows: Iterable[int], model: BooksModel, grouping_mode: str): + ''' + Group books according to the specified grouping mode. + Returns list of (group_name, row_indices) tuples. + ''' + mode = GROUPINGS.get(grouping_mode) + if mode: + func = mode.get('func') + if func: + return func(rows, model) + # No grouping - return single group with all rows + return [('', rows)] + +# }}} + + +GROUPINGS = { + 'none': {'name': _('None'), 'func': None}, + '1': None, # separator + 'author': {'name': _('Author'), 'func': group_books_by_author}, + 'series': {'name': _('Series'), 'func': group_books_by_series}, + 'genre': {'name': _('Genre'), 'func': group_books_by_genre}, + 'publisher': {'name': _('Publisher'), 'func': group_books_by_publisher}, + 'pubdate': {'name': _('Published'), 'func': group_books_by_pubdate}, + 'timestamp': {'name': _('Date'), 'func': group_books_by_timestamp}, + 'rating': {'name': _('Rating'), 'func': group_books_by_rating}, + 'language': {'name': _('Language'), 'func': group_books_by_language}, +} + +# recalculate DEFAULT_SPINE_COLOR from the DEFAULT_COVER +DEFAULT_SPINE_COLOR = extract_dominant_color(DEFAULT_COVER.copy()) + + +class CoverTuple(NamedTuple): + book_id: int + has_cover: bool + cache_valid: bool + cdata: Image + timestamp: int + + +class ShelfTuple(NamedTuple): + items: list['SpineTuple | DividerTuple'] + rows: set[int] + book_ids: set[int] + start_x: int + start_y: int + width_spines: int + width_total: int + + +class ShelfItemTuple(NamedTuple): + '''intermediate type for build ShelfTuple''' + spine: bool = None + divider: bool = None + pos_x: int = None + width: int = None + row: int = None + book_id: int = None + group_name: str = None + is_star: bool = None + + +class SpineTuple(NamedTuple): + start_x: int + start_y: int + width: int + row: int + book_id: int + shelf: ShelfTuple + + +class DividerTuple(NamedTuple): + start_x: int + start_y: int + width: int + group_name: str + is_star: bool + + +class HoveredCover: + '''Simple class to store the data related to the current hovered cover.''' + + OPACITY_START = 0.3 + FADE_TIME = 200 # Duration of the animation, in milliseconds + + def __init__(self): + self.row = -1 # Currently hovered book row + self.book_id = -1 # Currently hovered book id + self.pixmap: QPixmap = None # Scaled cover for hover popup + self.progress = 0.0 # Animation progress (0.0 to 1.0) + self.opacity = self.OPACITY_START # Current opacity (0.3 to 1.0) + self.shift = 0.0 # Current state of the shift animation (0.0 to 1.0) + self.width = -1 # Current width + self.height = -1 # Current height + self.width_max = -1 # Maximum width + self.height_end = -1 # Final height + self.height_modifier = -1 # Height modifier + self.base_x_pos = 0 # Base x position + self.base_y_pos = 0 # Base y position + self.spine_width = -1 # Spine width of this book + self.spine_height = -1 # Spine height of this book + self.dominant_color = DEFAULT_SPINE_COLOR # Dominant color of this cover + self.start_time = None # Start time of fade-in animation + + def is_valid(self) -> bool: + '''Test if the HoveredCover is valid.''' + return bool(self.row >= 0) and self.has_pixmap() + + def has_pixmap(self) -> bool: + '''Test if contain a valid pixmap.''' + return bool(self.pixmap) and not self.pixmap.isNull() + + def is_row(self, row: int) -> bool: + '''Test if the given row is the one of the hovered cover.''' + return self.is_valid() and row == self.row + + def rect(self) -> QRect: + '''Return the current QRect of the hover popup.''' + offset_y = self.spine_height - self.height + rslt = QRect(self.base_x_pos, self.base_y_pos + offset_y, self.width, self.height) + if self.height_end < self.spine_height: + modifier = self.height_modifier - round(self.height_modifier * self.shift) + rslt.adjust(0, modifier, 0, 0) + else: + rslt.adjust(0, self.height_modifier, 0, 0) + return rslt + + def spine_rect(self) -> QRect: + '''Return the book spine QRect.''' + rslt = QRect(self.base_x_pos, self.base_y_pos, self.spine_width, self.spine_height) + rslt.adjust(0, self.height_modifier, 0, 0) + return rslt + + def update(self): + '''Update hover cover fade-in animation and shift progress.''' + if not self.start_time: + return + + elapse = elapsed_time(self.start_time) + if elapse >= self.FADE_TIME: + self.progress = 1.0 + self.opacity = 1.0 + self.shift = 1.0 + self.width = max(self.width_max, self.spine_width) + self.height = self.height_end + self.start_time = None + else: + self.progress = progress = elapse / self.FADE_TIME + # Cubic ease-out curve (similar to JSX mock) + # Ease-out cubic: 1 - (1 - t)^3 + self.shift = cubic = 1.0 - (1.0 - progress) ** 3 + # Interpolate opacity from 0.3 (start) to 1.0 (end) + self.opacity = self.OPACITY_START + (1.0 - self.OPACITY_START) * cubic + + # Start the animation at the same width of the spine + if self.width_max > self.spine_width: + self.width = self.spine_width + math.ceil((self.width_max - self.spine_width) * cubic) + else: + # In the rare case when the spine is bigger than the cover + self.width = self.spine_width + + # Scale also the height to smooth the animation + if self.height_end < self.spine_height: + self.height = self.spine_height - math.ceil((self.spine_height - self.height_end) * cubic) + else: + self.height = self.spine_height + + +@setup_dnd_interface +class BookshelfView(QAbstractScrollArea): ''' Enhanced bookshelf view displaying books as spines on shelves. @@ -818,104 +600,228 @@ class BookshelfView(QAbstractScrollArea): # {{{ and grouping capabilities. ''' + update_cover = pyqtSignal() files_dropped = pyqtSignal(object) books_dropped = pyqtSignal(object) - # Spine dimensions + # Dimensions SPINE_HEIGHT = 150 - SPINE_MIN_WIDTH = 15 # Minimum for very short articles/documents - SPINE_MAX_WIDTH = 60 # Maximum for very long books - SHELF_DEPTH = 20 - SHELF_SPACING = 180 # Space between shelves (spine height + gap) + SPINE_WIDTH_MIN = 15 # Minimum for very short books + SPINE_WIDTH_MAX = 60 # Maximum for very long books + SPINE_WIDTH_DEFAULT = 40 # Default for error or fix width + SHELF_HEIGHT = 20 # Height of a shelf + SHELF_GAP = 20 # Gap space between shelves + SHELF_CONTENT_HEIGHT = SPINE_HEIGHT + SHELF_HEIGHT # Height of a shelf and it content + THUMBNAIL_WIDTH = 10 # Thumbnail size for spine + HOVER_EXPANDED_WIDTH = 110 # Max expanded width on hover + DIVIDER_WIDTH = 30 # Width of divider element + DIVIDER_LINE_WIDTH = 2 # Width of the gradient line in divider + ITEMS_GAP = 2 # Gap space between the row items # Colors SHELF_COLOR_START = QColor('#4a3728') SHELF_COLOR_END = QColor('#3d2e20') - BACKGROUND_COLOR = QColor('#0d0d18') TEXT_COLOR = QColor('#eee') TEXT_COLOR_DARK = QColor('#222') # Dark text for light backgrounds - DEFAULT_SPINE_COLOR = QColor('#8B4513') # Brown fallback + SELECTION_HIGHLIGHT_COLOR = QColor('#ff0') + DIVIDER_TEXT_COLOR = QColor('#b0b5c0') + DIVIDER_LINE_COLOR = QColor('#4a4a6a') + DIVIDER_GRADIENT_LINE_1 = DIVIDER_LINE_COLOR.toRgb() + DIVIDER_GRADIENT_LINE_2 = DIVIDER_LINE_COLOR.toRgb() + DIVIDER_GRADIENT_LINE_1.setAlphaF(0.0) # Transparent at top/bottom + DIVIDER_GRADIENT_LINE_2.setAlphaF(0.75) # Visible in middle def __init__(self, parent): QAbstractScrollArea.__init__(self, parent) - self.gui = parent - self._model = None - self.dbref = lambda: None - self.context_menu = None - self.setBackgroundRole(QPalette.ColorRole.Base) - self.setAutoFillBackground(True) + from calibre.gui2.ui import get_gui + self.gui = get_gui() + self._model: BooksModel = None + self.context_menu: QMenu = None + self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + QApplication.instance().palette_changed.connect(self.set_color) + # Ensure viewport receives mouse events self.viewport().setMouseTracking(True) self.viewport().setAttribute(Qt.WidgetAttribute.WA_MouseTracking, True) - # Selection tracking - self._selected_rows = set() - self._current_row = -1 - self._selection_model = None - self._syncing_from_main = False # Flag to prevent feedback loops - - # Set background on viewport - viewport = self.viewport() - viewport.setAutoFillBackground(True) - palette = viewport.palette() - palette.setColor(QPalette.ColorRole.Base, self.BACKGROUND_COLOR) - viewport.setPalette(palette) - # Initialize drag and drop # so we set the attributes manually self.drag_allowed = True self.drag_start_pos = None + # Selection tracking + self._selected_rows: set[int] = set() + self._current_row = -1 + self._selection_model: QItemSelectionModel = None + self._syncing_from_main = False # Flag to prevent feedback loops + self._current_shelf_layouts: list[ShelfTuple] = [] + # Cover loading and caching - self._cover_colors = {} # Cache for cover colors (book_id -> QColor) - self._spine_thumbnails = {} # Cache for spine thumbnails (book_id -> QPixmap) - self._hovered_row = -1 # Currently hovered book row - self._hover_cover_pixmap = None # Full cover for hover popup - self._hover_cover_row = -1 # Row for which hover cover is loaded - self._hover_cover_opacity = 1.0 # Opacity for hover cover (for smooth fade-in) - self._hover_shift_progress = 0.0 # Progress of shift animation (0.0 to 1.0) + self._pages_widths: dict[int, int] = {} # Cache for spine widths (pages -> width) + self._height_modifiers: dict[int, int] = {} # Cache for height modifiers (book_id -> height) + self._hovered = HoveredCover() # Currently hovered book self._hover_fade_timer = QTimer(self) # Timer for fade-in animation self._hover_fade_timer.setSingleShot(False) self._hover_fade_timer.timeout.connect(self._update_hover_fade) - self._hover_fade_start_time = None # Start time for fade animation - - # Thumbnail size for spine - self.THUMBNAIL_HEIGHT = 20 - - # Hover cover popup size - self.HOVER_COVER_WIDTH = 100 - # Max expanded width on hover - self.HOVER_EXPANDED_WIDTH = 105 - - # Timer for lazy loading covers - self._load_covers_timer = QTimer(self) - self._load_covers_timer.setSingleShot(True) - self._load_covers_timer.timeout.connect(self._load_visible_covers) + self._hover_buffer_timer = QTimer(self) # Timer for buffer the hover animation + self._hover_buffer_timer.setSingleShot(False) + self._hover_buffer_timer.timeout.connect(self._delayed_hover_load) + self._hover_buffer_row = -1 + self._hover_buffer_time = None # Up the version number if anything changes in how images are stored in the cache. self.thumbnail_cache = ThumbnailCache( name='bookshelf-thumbnail-cache', max_size=gprefs['bookshelf_view_cache_size'], - thumbnail_size=(self.SPINE_MAX_WIDTH, self.THUMBNAIL_HEIGHT), + thumbnail_size=(self.THUMBNAIL_WIDTH, self.SPINE_HEIGHT), version=1, ) + self.fetch_thread = None + self.render_queue = LifoQueue() + self.update_cover.connect(self.update_viewport) + self.color_cache: dict[int, QColor] = {} # Cache for cover colors (book_id -> QColor) - # Grouping configuration - self._grouping_mode = gprefs.get('bookshelf_grouping_mode', 'none') + # Configuration + self._grouping_mode = 'none' self.refresh_settings() + # Cover template caching + self.template_inited = False + self.template_cache = {} + self.template_title_error_reported = False + self.template_statue_error_reported = False + self.template_pages_error_reported = False + self.template_title = '' + self.template_statue = '' + self.template_pages = '' + self.pages_use_book_size = False + self.template_title_is_empty = True + self.template_statue_is_empty = True + self.template_pages_is_empty = True + + # Templates rendering methods + + def init_template(self, db): + '''Initialize templates and database settings.''' + if not db: + return + if self.template_inited and self.dbref() == db.new_api: + return + + def db_pref(key): + prefs = db.new_api.backend.prefs + return prefs.get(key, prefs.defaults.get(key)) + + self.template_cache = {} + self.template_title_error_reported = False + self.template_statue_error_reported = False + self.template_pages_error_reported = False + self.rules_color = db_pref('bookshelf_color_rules') or [] + self.template_title = db_pref('bookshelf_title_template') or '' + self.template_pages = db_pref('bookshelf_pages_template') or '' + self.pages_use_book_size = bool(db_pref('bookshelf_use_book_size')) + self.template_title_is_title = self.template_title == '{title}' + self.template_title_is_empty = not self.template_title.strip() + self.template_pages_is_empty = not self.template_pages.strip() + self.template_inited = True + + def render_template_title(self, book_id: int, mi=None) -> str: + '''Return the title generate for this book.''' + self.init_template(self.dbref()) + if self.template_title_is_empty: + return '' + if not mi: + mi = self.dbref().get_proxy_metadata(book_id) + if self.template_title_is_title: + return mi.title + rslt = mi.formatter.safe_format(self.template_title, mi, TEMPLATE_ERROR, mi, column_name='title', template_cache=self.template_cache) + if rslt: + return rslt + return _('Unknown') + + def render_template_pages(self, book_id: int, mi: Metadata=None) -> int: + '''Return the pages count generate for this book.''' + self.init_template(self.dbref()) + if self.template_pages_is_empty: + return None + if not mi: + mi = self.dbref().get_proxy_metadata(book_id) + rslt = mi.formatter.safe_format(self.template_pages, mi, TEMPLATE_ERROR, mi, column_name='pages', template_cache=self.template_cache) + if rslt.startswith(TEMPLATE_ERROR): + print(rslt) + return -1 + if rslt: + with suppress(Exception): + rslt = int(rslt) + if rslt >= 0: + return rslt + return -1 + return None + + def render_color_indicator(self, book_id: int, mi: Metadata=None) -> QColor: + '''Return the statue indicator color generate for this book.''' + self.init_template(self.dbref()) + if not mi: + mi = self.dbref().get_proxy_metadata(book_id) + for i, (kind, column, rule) in enumerate(self.rules_color): + rslt = QColor(mi.formatter.safe_format(rule, mi, TEMPLATE_ERROR, mi, column_name=f'color:{i}', template_cache=self.template_cache)) + if rslt.isValid(): + return rslt + return None + + # Miscellaneous methods + def refresh_settings(self): - pass + '''Refresh the gui and render settings.''' + self._enable_shadow = gprefs['bookshelf_shadow'] + self._enable_thumbnail = gprefs['bookshelf_thumbnail'] + self._enable_centered = gprefs['bookshelf_centered'] + self._enable_variable_height = gprefs['bookshelf_variable_height'] + self._hover_shift = gprefs['bookshelf_hover_shift'] + HoveredCover.FADE_TIME = gprefs['bookshelf_fade_time'] + self.thumbnail_cache.set_size(gprefs['bookshelf_view_cache_size']) + self.set_color() + + def set_color(self): + resolve_bookshelf_color() + r, g, b = resolve_bookshelf_color() + tex = resolve_bookshelf_color(which='texture') + pal = self.palette() + bgcol = QColor(r, g, b) + pal.setColor(QPalette.ColorRole.Base, bgcol) + self.setPalette(pal) + ss = f'background-color: {bgcol.name()}; border: 0px solid {bgcol.name()};' + if tex: + from calibre.gui2.preferences.texture_chooser import texture_path + path = texture_path(tex) + if path: + path = os.path.abspath(path).replace(os.sep, '/') + ss += f'background-image: url({path});' + ss += 'background-attachment: fixed;' + pm = QPixmap(path) + if not pm.isNull(): + val = pm.scaled(1, 1).toImage().pixel(0, 0) + r, g, b = qRed(val), qGreen(val), qBlue(val) + self.setStyleSheet(f'QAbstractScrollArea {{ {ss} }}') + + def view_is_visible(self) -> bool: + '''Return if the bookshelf view is visible.''' + return self.gui.bookshelf_view_button.is_visible + + def shutdown(self): + self.thumbnail_cache.shutdown() + self.render_queue.put(None) + self.thumbnail_cache.shutdown() def setModel(self, model): '''Set the model for this view.''' - if self._model is not None: + if self._model: # Disconnect old model signals if needed pass self._model = model - if model is not None: + if model: # Create selection model for sync self._selection_model = QItemSelectionModel(model, self) model.dataChanged.connect(self._model_data_changed) @@ -925,204 +831,59 @@ class BookshelfView(QAbstractScrollArea): # {{{ else: self._selection_model = None - # Connect to main library view's selection model - self._connect_to_main_view_selection() - - self._update_scrollbar_ranges() - self.viewport().update() - - def selectionModel(self): - '''Return the selection model (required for AlternateViews integration).''' - return self._selection_model - - def _model_data_changed(self, topLeft, bottomRight, roles): - '''Handle model data changes.''' - self.viewport().update() - - def _model_rows_changed(self, parent, first, last): - '''Handle model row changes.''' - # Invalidate caches for removed books - # Simplified cache invalidation - self._update_scrollbar_ranges() - self.viewport().update() - - def _model_reset(self): - '''Handle model reset.''' - self._update_scrollbar_ranges() - self.viewport().update() - - def model(self): + def model(self) -> BooksModel: '''Return the model.''' return self._model - def _update_scrollbar_ranges(self): - '''Update scrollbar ranges based on number of books.''' - if self._model is None: - self.verticalScrollBar().setRange(0, 0) - return + def selectionModel(self) -> QItemSelectionModel: + '''Return the selection model (required for AlternateViews integration).''' + return self._selection_model - row_count = self._model.rowCount(QModelIndex()) - if row_count == 0: - self.verticalScrollBar().setRange(0, 0) - return + def _model_data_changed(self, top_left, bottom_right, roles): + '''Handle model data changes.''' + self._update_current_shelf_layouts() - # Calculate total height with inline grouping - viewport_width = self.viewport().width() - x_pos = 10 # Start position (no label area - dividers are inline) - shelf_y = 10 - db = self._model.db + def _model_rows_changed(self, parent, first, last): + '''Handle model row changes.''' + self._update_current_shelf_layouts() - # Get all rows and group them, then flatten for inline rendering - all_rows = list(range(row_count)) - groups = group_books(all_rows, self._model, self._grouping_mode) + def _model_reset(self): + '''Handle model reset.''' + self._update_current_shelf_layouts() - # Flatten groups into a list of (row, group_name) tuples - flattened_items = [] - for group_name, group_rows in groups: - for row in group_rows: - flattened_items.append((row, group_name)) + def dbref(self) -> Cache: + '''Return the current database.''' + return self._model.db.new_api - last_group_name = None - for row, group_name in flattened_items: - # Divider when group changes - if self._grouping_mode != 'none' and group_name != last_group_name and last_group_name is not None: - x_pos += 32 # DIVIDER_WIDTH (30) + gap (2) - last_group_name = group_name + def book_id_from_row(self, row: int) -> int: + '''Return the book id at this row.''' + index = self._model.index(row, 0) + if not index.isValid(): + return None + return self._model.id(index) - # Calculate spine width - try: - index = self._model.index(row, 0) - if index.isValid(): - book_id = self._model.id(index) - mi = db.get_metadata(book_id, index_is_id=True) - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - else: - spine_width = 40 - except (AttributeError, IndexError, KeyError, TypeError): - spine_width = 40 - - x_pos += int(spine_width) + 2 - - # If we've filled the shelf, move to next shelf - if x_pos + spine_width > viewport_width - 10: - x_pos = 10 # Reset to start position - shelf_y += self.SHELF_SPACING - last_group_name = None # Reset group tracking for new shelf - - # Add one more shelf for the last row - total_height = shelf_y + self.SHELF_SPACING - viewport_height = self.viewport().height() - max_scroll = max(0, total_height - viewport_height) - self.verticalScrollBar().setRange(0, max_scroll) - self.verticalScrollBar().setPageStep(viewport_height) - - def resizeEvent(self, ev): + def resizeEvent(self, ev: QResizeEvent): '''Handle resize events.''' super().resizeEvent(ev) - self._update_scrollbar_ranges() - self.viewport().update() + self._update_current_shelf_layouts() - def _calculate_shelf_layouts(self, flattened_items, viewport_width): - ''' - Pre-calculate which books go on which shelf, accounting for: - 1. Hover expansion space (reserve space on right for expansion) - 2. Left-aligned books with proper margins - - Returns a list of shelf dictionaries with: - - 'items': list of (row, group_name) tuples - - 'start_x': starting x position (left-aligned with margin) - - 'start_row': first row on this shelf - ''' - if not flattened_items: - return [] - - db = self._model.db - shelves = [] - current_shelf = [] - shelf_width = 0 - shelf_start_row = 0 - last_group_name = None + def _get_left_margin(self): + '''Get left margin for the shelf layouts.''' + # Remove left margin when books are grouped (replaced by divider) + return 2 if self._grouping_mode != 'none' else 12 + def _get_available_width(self): + '''Get the maximum available width for the shelf layouts.''' # Reserve space for hover expansion - # Reserve space for expansion - # Reserve space for hover expansion - LEFT_MARGIN = 20 - RIGHT_MARGIN = (self.HOVER_EXPANDED_WIDTH - 40) + 20 - available_width = viewport_width - LEFT_MARGIN - RIGHT_MARGIN - - for row, group_name in flattened_items: - # Account for divider when group changes - divider_width = 0 - if self._grouping_mode != 'none' and group_name != last_group_name and last_group_name is not None: - divider_width = 32 # DIVIDER_WIDTH (30) + gap (2) - - # Calculate spine width - try: - index = self._model.index(row, 0) - if index.isValid(): - book_id = self._model.id(index) - mi = db.get_metadata(book_id, index_is_id=True) - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - else: - spine_width = 40 - except (AttributeError, IndexError, KeyError, TypeError): - spine_width = 40 - - item_width = divider_width + spine_width + 2 # spine + gap - - # Check for shelf overflow - if shelf_width + item_width > available_width and current_shelf: - # Finish current shelf - left-aligned with margin - shelves.append({ - 'items': current_shelf, - 'start_x': LEFT_MARGIN, - 'start_row': shelf_start_row - }) - # Start new shelf - current_shelf = [] - shelf_width = 0 - shelf_start_row = row - last_group_name = None # Reset for new shelf - - # Add item to current shelf - current_shelf.append((row, group_name)) - shelf_width += item_width - last_group_name = group_name - - # Add final shelf - if current_shelf: - shelves.append({ - 'items': current_shelf, - 'start_x': LEFT_MARGIN, - 'start_row': shelf_start_row - }) - - return shelves - - def paintEvent(self, ev): - '''Paint the bookshelf view.''' - if self._model is None: - return - - painter = QPainter(self.viewport()) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get visible area - scroll_y = self.verticalScrollBar().value() - viewport_rect = self.viewport().rect() - visible_rect = QRect( - viewport_rect.x(), - viewport_rect.y() + scroll_y, - viewport_rect.width(), - viewport_rect.height() - ) + right_margin = self.HOVER_EXPANDED_WIDTH + 10 + return self.viewport().rect().width() - self._get_left_margin() - right_margin + def _get_flattened_items(self) -> list[tuple[str, int]]: + '''Get a list (row, group_name) tuples of the items.''' # Get all rows and group them, then flatten for inline rendering row_count = self._model.rowCount(QModelIndex()) if row_count == 0: - return + return [] all_rows = list(range(row_count)) groups = group_books(all_rows, self._model, self._grouping_mode) @@ -1133,517 +894,423 @@ class BookshelfView(QAbstractScrollArea): # {{{ for row in group_rows: flattened_items.append((row, group_name)) - # Track hovered book info for shift calculation - hover_spine_width = 0 - if self._hovered_row >= 0: - try: - hover_index = self._model.index(self._hovered_row, 0) - if hover_index.isValid(): - db = self._model.db - hover_book_id = self._model.id(hover_index) - hover_mi = db.get_metadata(hover_book_id, index_is_id=True) - hover_pages = self._get_page_count(hover_book_id, db, hover_mi) - hover_spine_width = self._calculate_spine_width(hover_pages) - except (AttributeError, IndexError, KeyError, TypeError): - pass + return flattened_items - # Pre-calculate shelf layouts - # Calculate shelf positions - shelf_layouts = self._calculate_shelf_layouts(flattened_items, viewport_rect.width()) - - # Draw books with inline dividers (like JSX) - # Hovered item expands in place, items to right shift - shelf_y = 10 + scroll_y - db = self._model.db - shelf_started = False - current_shelf_idx = 0 - - # Pre-calculate hover shift amount once - hover_shift_amount = 0 - if self._hovered_row >= 0: - if self._hover_cover_pixmap is not None and not self._hover_cover_pixmap.isNull(): - expanded_width = min(self._hover_cover_pixmap.width(), self.HOVER_EXPANDED_WIDTH) - else: - expanded_width = self.HOVER_EXPANDED_WIDTH - hover_shift_amount = max(0, expanded_width - hover_spine_width) * self._hover_shift_progress - - for shelf_idx, shelf_items in enumerate(shelf_layouts): - # Get starting x position for this shelf (centered) - shelf_start_x = shelf_items['start_x'] - base_x_pos = shelf_start_x - shelf_start_row = shelf_items['start_row'] - last_group_name = None - cumulative_shift = 0 - - for row, group_name in shelf_items['items']: - if row >= row_count: - break - - # Draw inline divider when group changes - if self._grouping_mode != 'none' and group_name != last_group_name and last_group_name is not None: - # Divider drawn at current position - divider_width = self._draw_inline_divider(painter, group_name, base_x_pos, shelf_y, visible_rect) - base_x_pos += divider_width + 2 # Update base position - - last_group_name = group_name - - # Calculate spine width from page count - try: - index = self._model.index(row, 0) - if index.isValid(): - book_id = self._model.id(index) - mi = db.get_metadata(book_id, index_is_id=True) - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - else: - spine_width = 40 - except (AttributeError, IndexError, KeyError, TypeError): - spine_width = 40 - - # Check if this book is hovered - is_hovered = row == self._hovered_row - - # Determine if we should apply shift to this book - # Shift applied after hovered book - if is_hovered: - # This is the hovered book - it expands in place - if self._hover_cover_pixmap is not None and not self._hover_cover_pixmap.isNull(): - cover_display_width = min(self._hover_cover_pixmap.width(), self.HOVER_EXPANDED_WIDTH) - else: - cover_display_width = self.HOVER_EXPANDED_WIDTH - current_x = base_x_pos - display_width = int(cover_display_width) - # Increase base_x_pos by expanded width - width_to_add = cover_display_width - # Increase cumulative shift for subsequent items - cumulative_shift += hover_shift_amount - else: - # Draw at base position - current_x = base_x_pos - display_width = int(spine_width) - # Add only spine width to base position - width_to_add = spine_width - - # Draw shelf before first book on this shelf - if not shelf_started: - if shelf_y + self.SPINE_HEIGHT >= visible_rect.top() and shelf_y <= visible_rect.bottom(): - self._draw_shelf(painter, shelf_y, visible_rect) - shelf_started = True - - # Check if spine is visible - clamped_x = max(visible_rect.left(), int(current_x)) - if clamped_x != int(current_x): - display_width = max(1, display_width - (clamped_x - int(current_x))) - - spine_rect = QRect(clamped_x, shelf_y, display_width, self.SPINE_HEIGHT) - - if spine_rect.bottom() >= visible_rect.top() and spine_rect.top() <= visible_rect.bottom(): - # Pre-load color if not cached - try: - book_id = self._model.id(self._model.index(row, 0)) - if book_id not in self._cover_colors: - self._get_spine_color(book_id, db) - except (AttributeError, IndexError, KeyError, TypeError): - pass - self._draw_spine(painter, row, spine_rect) - - # Update position for next book - base_x_pos += int(width_to_add) + 2 # Small gap between spines - - # Move to next shelf - shelf_y += self.SHELF_SPACING - shelf_started = False - - # Trigger lazy loading of visible covers - if not self._load_covers_timer.isActive(): - self._load_covers_timer.start(100) # Delay 100ms to avoid loading during scroll - - def _draw_inline_divider(self, painter, group_name, x_pos, shelf_y, visible_rect): - '''Draw an inline group divider (small vertical element like JSX). - - Like the JSX Divider component: small vertical element that sits alongside books, - with a label at the bottom and a gradient line going upward. The divider is the - same height as books (150px) and bottom-aligned. - - :param painter: QPainter instance - :param group_name: Name of the group - :param x_pos: X position where divider should be drawn - :param shelf_y: Current shelf y position (top of books) - :param visible_rect: Visible rectangle - :return: Width of the divider (for positioning next item) + def _get_shelf_layouts(self) -> list[ShelfTuple]: ''' - if not group_name: - return 0 + Get the shelf layouts showing which books go on which shelf. + ''' + # Calculate shelf layouts + return self._calculate_shelf_layouts(self._get_flattened_items()) - # Divider dimensions - # Divider positioning - DIVIDER_WIDTH = 30 # Width of divider element - DIVIDER_LINE_WIDTH = 2 # Width of vertical line - LABEL_MARGIN_BOTTOM = 10 + def _calculate_shelf_layouts(self, flattened_items: list[tuple[str, int]]) -> list[ShelfTuple]: + ''' + Calculate which books go on which shelf, accounting for: + 1. Hover expansion space (reserve space on right for expansion) + 2. Left-aligned books with proper margins + ''' + if not flattened_items: + return [] - # Calculate label position from top - book_bottom = shelf_y + self.SPINE_HEIGHT # Bottom of book container (where books sit on shelf) - label_y = shelf_y + (self.SPINE_HEIGHT - LABEL_MARGIN_BOTTOM) # Label position: 10px from bottom of container + left_margin = self._get_left_margin() + available_width = self._get_available_width() + viewport_width = self.viewport().rect().width() - # Only draw if visible - if book_bottom < visible_rect.top() or shelf_y > visible_rect.bottom(): - return DIVIDER_WIDTH + def iter_shelf_items(shelf: ShelfTuple, items: Iterable[ShelfItemTuple]): + for item in items: + if item.spine: + yield SpineTuple( + start_x=shelf.start_x + item.pos_x, + start_y=shelf.start_y, + width=item.width, + row=item.row, + book_id=item.book_id, + shelf=shelf, + ) + if item.divider: + yield DividerTuple( + start_x=shelf.start_x + item.pos_x, + start_y=shelf.start_y, + width=item.width, + group_name=item.group_name, + is_star=item.is_star, + ) - # Adjust font size for better visibility - text_length = len(group_name) - if text_length > 25: - font_size = 12 # Slightly larger for longer text - elif text_length > 15: - font_size = 11 # Standard size - else: - font_size = 11 # Standard size + def is_spine(x: ShelfItemTuple): + return x.spine - font = QFont() - font.setPointSize(font_size) - font.setBold(True) - fm = QFontMetrics(font) - - # Measure and truncate text - available_vertical_space = self.SPINE_HEIGHT - LABEL_MARGIN_BOTTOM - 5 # Leave 5px margin at top - max_text_width = min(available_vertical_space, 300) # Allow up to 300px for longer text - - text_width = fm.horizontalAdvance(group_name) - if text_width > max_text_width: - elided_name = fm.elidedText(group_name, Qt.TextElideMode.ElideRight, max_text_width) - text_width = fm.horizontalAdvance(elided_name) - else: - elided_name = group_name - - # Calculate text dimensions - label_x = x_pos + DIVIDER_WIDTH / 2 # Center of divider horizontally - - # After -90 rotation, text extends upward from label_y - # Text height after rotation - text_top_y = label_y - text_width # Top of text (where text ends) - - # Line starts at top of text and extends to top of books - line_x = label_x - DIVIDER_LINE_WIDTH / 2 # Center line in divider - line_bottom = text_top_y # Start at top of text - line_top = shelf_y + 5 # Extend to top of books (with 5px margin) - line_height = max(0, line_bottom - line_top) # Auto-size based on text - - # Draw vertical gradient line - if line_height > 0: - painter.save() - gradient = QLinearGradient( - QPointF(line_x, line_top), - QPointF(line_x, line_bottom) + def create_shelf(start_x, start_y, items: Iterable[ShelfItemTuple]): + shelf = ShelfTuple( + items=[], + rows={x.row for x in filter(is_spine, items)}, + book_ids={x.book_id for x in filter(is_spine, items)}, + width_spines=sum(x.width for x in filter(is_spine, items)), + width_total=sum(x.width for x in items) + (self.SHELF_GAP * len(items)-1), + start_x=start_x, + start_y=start_y, ) - gradient.setColorAt(0, QColor(74, 74, 106, 0)) # Transparent at top - gradient.setColorAt(0.5, QColor(74, 74, 106, 200)) # Visible in middle - gradient.setColorAt(1, QColor(74, 74, 106, 0)) # Transparent at bottom + shelf.items.extend(iter_shelf_items(shelf, items)) + return shelf - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(gradient)) - painter.drawRect(int(line_x), int(line_top), DIVIDER_LINE_WIDTH, int(line_height)) - painter.restore() + def get_start_x(shelf_width): + if not self._enable_centered: + return left_margin + margin = viewport_width - shelf_width - 20 + return max(0, margin // 2) - # Draw label text (rotated -90 degrees, reads upward) - # Use exact same approach as _draw_spine for consistency - painter.save() - painter.setFont(font) - # Use a brighter color for better visibility, especially for longer text - text_color = QColor('#b0b5c0') # Brighter grey for better visibility - painter.setPen(text_color) + shelves = [] + current_shelf = [] + shelf_width = 0 + last_group_name = None + shelf_y = self.SHELF_GAP + for row, group_name in flattened_items: + # Account for divider when group changes + offset = 0 + divider = None + if self._grouping_mode != 'none' and group_name != last_group_name: + divider = ShelfItemTuple( + divider=True, pos_x=shelf_width + offset, width=self.DIVIDER_WIDTH, group_name=group_name, is_star=self._grouping_mode=='rating', + ) + offset = divider.width + self.ITEMS_GAP - # Translate to label position (like JSX: marginBottom: '10px' from bottom of 150px container) - # label_y is calculated as shelf_y + (SPINE_HEIGHT - 10), which is 10px from bottom - # This is the bottom edge of where the text should be positioned - painter.translate(label_x, label_y) - painter.rotate(-90) # Rotate -90 degrees (text reads upward, like JSX rotate(180deg) with vertical-rl) + # Get spine width + book_id = self.book_id_from_row(row) + spine_width = self._get_spine_width(book_id) + spine = ShelfItemTuple( + spine=True, pos_x=shelf_width + offset, width=spine_width, row=row, book_id=book_id, + ) + item_width = offset + spine.width + self.ITEMS_GAP - # After -90 rotation: coordinate system changes - # x-axis becomes vertical (up/down), y-axis becomes horizontal (left/right) - # We're at (0,0) which is the label position (bottom of text, center horizontally) - # Text should extend upward (positive x direction) and be centered horizontally (y=0) + # Check for shelf overflow + if shelf_width + item_width > available_width and current_shelf: + # Finish current shelf - left-aligned with margin + shelves.append(create_shelf( + start_x=get_start_x(shelf_width), + start_y=shelf_y, + items=current_shelf, + )) + # Start new shelf + current_shelf = [] + shelf_y += self.SHELF_CONTENT_HEIGHT + self.SHELF_GAP + # Reset for new shelf + shelf_width = 0 + item_width = 0 + if group_name: + divider = ShelfItemTuple( + divider=True, pos_x=shelf_width, width=self.DIVIDER_WIDTH, group_name=group_name, is_star=self._grouping_mode=='rating', + ) + item_width = divider.width + self.ITEMS_GAP + data = spine._asdict() + data['pos_x'] = item_width + spine = ShelfItemTuple(**data) + item_width += spine.width + self.ITEMS_GAP - # Create text rect - text extends upward from (0,0) and is centered horizontally - # After -90 rotation: x controls vertical, y controls horizontal - # Text starts at x=0 (bottom) and extends to x=text_width (top) - # Text is centered at y=0 (horizontally) - text_rect = QRect( - 0, # x: start at bottom (x=0, text extends upward) - int(-text_width / 2), # y: center horizontally (y centered around 0) - int(text_width), # width: vertical extent (text extends this far upward) - int(text_width) # height: horizontal extent (wide enough to center text) - ) + # Add item to current shelf + if divider: + current_shelf.append(divider) + current_shelf.append(spine) + shelf_width += item_width + last_group_name = group_name - # Draw text with center alignment - painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_name) - painter.restore() + # Add final shelf + if current_shelf: + shelves.append(create_shelf( + start_x=get_start_x(shelf_width), + start_y=shelf_y, + items=current_shelf, + )) + return shelves - return DIVIDER_WIDTH + def _update_current_shelf_layouts(self): + '''Update current shelf layouts.''' + if not self.view_is_visible(): + return + self._current_shelf_layouts = self._get_shelf_layouts() + self._update_scrollbar_ranges() + self.update_viewport() + def _update_scrollbar_ranges(self): + '''Update scrollbar ranges based on the current shelf layouts.''' + if not self.view_is_visible(): + return + if not self._current_shelf_layouts: + self.verticalScrollBar().setRange(0, 0) + return - def _draw_shelf(self, painter, shelf_y, visible_rect): + # Add the shelf spacing to have the real height + total_height = self._current_shelf_layouts[-1].start_y + self.SHELF_CONTENT_HEIGHT + viewport_height = self.viewport().height() + max_scroll = max(0, total_height - viewport_height) + self.verticalScrollBar().setRange(0, max_scroll) + self.verticalScrollBar().setPageStep(viewport_height) + + # Paint and Drawing methods + + def shown(self): + '''Called when this view becomes active.''' + if self.fetch_thread is None: + self.fetch_thread = Thread(target=self._fetch_thumbnails_cache) + self.fetch_thread.daemon = True + self.fetch_thread.start() + self._update_current_shelf_layouts() + + def update_viewport(self): + '''Update viewport only if the bookshelf view is visible.''' + if not self.view_is_visible(): + return + self.viewport().update() + + def paintEvent(self, ev: QPaintEvent): + '''Paint the bookshelf view.''' + if not self.view_is_visible(): + return + + painter = QPainter(self.viewport()) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get visible area + scroll_y = self.verticalScrollBar().value() + viewport_rect = self.viewport().rect() + visible_rect = viewport_rect.translated(0, scroll_y) + + if not self._current_shelf_layouts: + self._update_current_shelf_layouts() + + for shelf in self._current_shelf_layouts: + # Early exit if we've scrolled past the point + if shelf.start_y > visible_rect.bottom(): + break + + # Check if shelf is visible + if shelf.start_y + self.SHELF_CONTENT_HEIGHT < visible_rect.top() + 1: + continue + + # Draw the shelf + self._draw_shelf(painter, shelf, scroll_y, visible_rect.width()) + + # Draw books and inline dividers on it + offset_x = 0 + for item in shelf.items: + if isinstance(item, DividerTuple): + self._draw_inline_divider(painter, item, scroll_y, offset_x) + continue + + if isinstance(item, SpineTuple): + # Determine if we should apply shift to this shelf + if self._hovered.is_row(item.row) and self._hover_shift: + offset_x = self._hovered.width - item.width + + if self._hovered.is_row(item.row): + # This is the hovered book - it draw later + # Position cover at spine position - left edge aligned with spine left edge + # The cover replaces the spine, so left edge stays at original spine position + self._hovered.base_x_pos = item.start_x + self._hovered.base_y_pos = item.start_y + else: + # Draw a book spine at this position + self._draw_spine(painter, item, scroll_y, offset_x) + + # Draw the hover cover of the hovered book + if self._hovered.is_valid(): + self._draw_hover_cover(painter, self._hovered, scroll_y) + + def _draw_shelf(self, painter: QPainter, shelf: ShelfTuple, scroll_y: int, width: int): '''Draw the shelf background at the given y position.''' + # Shelf surface (where books sit) - shelf_surface_y = shelf_y + self.SPINE_HEIGHT - shelf_depth = self.SHELF_DEPTH + shelf_rect = QRect( + 0, + shelf.start_y + self.SPINE_HEIGHT, + width, + self.SHELF_HEIGHT, + ) + shelf_rect.translate(0, -scroll_y) # Create gradient for shelf surface (horizontal gradient for wood grain effect) gradient = QLinearGradient( - QPointF(visible_rect.left(), shelf_surface_y), - QPointF(visible_rect.right(), shelf_surface_y) + QPointF(shelf_rect.left(), shelf_rect.top()), + QPointF(shelf_rect.width(), shelf_rect.top()), ) gradient.setColorAt(0, self.SHELF_COLOR_START) gradient.setColorAt(0.5, self.SHELF_COLOR_END.lighter(105)) gradient.setColorAt(1, self.SHELF_COLOR_START) # Draw shelf surface - shelf_rect = QRect( - visible_rect.left(), - shelf_surface_y, - visible_rect.width(), - shelf_depth - ) painter.fillRect(shelf_rect, QBrush(gradient)) # Draw shelf front edge (3D effect - darker shadow) edge_rect = QRect( - visible_rect.left(), - shelf_surface_y, - visible_rect.width(), - 3 + shelf_rect.left(), + shelf_rect.top(), + shelf_rect.width(), + 3, ) painter.fillRect(edge_rect, self.SHELF_COLOR_END.darker(130)) # Draw shelf back edge (lighter highlight for 3D depth) back_edge_rect = QRect( - visible_rect.left(), - shelf_surface_y + shelf_depth - 2, - visible_rect.width(), - 2 + shelf_rect.left(), + shelf_rect.top() + self.SHELF_HEIGHT - 2, + shelf_rect.width(), + 2, ) painter.fillRect(back_edge_rect, self.SHELF_COLOR_START.lighter(110)) # Draw subtle wood grain lines painter.setPen(QPen(self.SHELF_COLOR_END.darker(110), 1)) - for i in range(0, visible_rect.width(), 20): - line_y = shelf_surface_y + shelf_depth // 2 + for i in range(0, shelf_rect.width(), 20): + line_pos = shelf_rect.top() + self.SHELF_HEIGHT // 2 painter.drawLine( - visible_rect.left() + i, - line_y, - visible_rect.left() + i + 10, - line_y + shelf_rect.left() + i, + line_pos, + shelf_rect.left() + i + 10, + line_pos, ) - def _get_page_count(self, book_id, db, mi): - '''Get page count for a book, trying multiple methods. + def _draw_selection_highlight(self, painter: QPainter, spine_rect: QRect): + '''Draw the selection highlight.''' + painter.save() + painter.setPen(self.SELECTION_HIGHLIGHT_COLOR) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setOpacity(1.0) + painter.drawRect(spine_rect.adjusted(1, 1, -1, -1)) + painter.restore() - Calibre stores pages as an arbitrary attribute on the Metadata object. - If not available, we can estimate from file size or use a default. + def _draw_statue_indicator(self, painter: QPainter, spine_rect: QRect, book_id: int, mi: Metadata=None) -> bool: + '''Draw reading statue indicator.''' + statue_color = self.render_color_indicator(book_id, mi) + if isinstance(statue_color, QColor) and statue_color.isValid(): + painter.save() + painter.setOpacity(1.0) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + dot_radius = 4 + dot_x = spine_rect.x() + spine_rect.width() // 2 + dot_y = spine_rect.y() + spine_rect.height() - dot_radius - 10 + painter.setBrush(QBrush(statue_color)) + painter.setPen(QPen(QColor(255, 255, 255, 120), 1.5)) + painter.drawEllipse(QPoint(dot_x, dot_y), dot_radius, dot_radius) + painter.restore() + return True + return False - Returns: - int: Page count, or estimated value if not available - ''' - # Method 1: Try metadata object attribute (most common) - # Pages is stored as an arbitrary attribute on Metadata objects - try: - pages = getattr(mi, 'pages', None) - if pages is not None: - try: - pages = int(pages) - if pages > 0: - return pages - except (ValueError, TypeError): - pass - except AttributeError: - pass + def _get_sized_text(self, text: str, max_width: int, start: float, stop: float) -> tuple[str, QFont, QRect]: + '''Return a text, a QFont and a QRect that fit into the max_width.''' + font = QFont() + for minus in range(round(start - stop) * 2): + minus = minus / 2 + font.setPointSizeF(start - minus) + fm = QFontMetrics(font) + size = fm.boundingRect(text) + offset = min(0, size.top() // 2) + size.adjust(offset, 0, 0, 0) + if size.width() <= max_width: + break + size.adjust(0, 0, offset - min(0, size.left()), 0) + rslt = fm.elidedText(text, Qt.TextElideMode.ElideRight, max_width) + return rslt, font, size - # Method 2: Try accessing via _data dict (Metadata internal storage) - try: - if hasattr(mi, '_data'): - pages = mi._data.get('pages', None) - if pages is not None: - try: - pages = int(pages) - if pages > 0: - return pages - except (ValueError, TypeError): - pass - except (AttributeError, KeyError): - pass + def _draw_inline_divider(self, painter: QPainter, divider: DividerTuple, scroll_y: int, offset_x: int): + '''Draw an inline group divider with it group name write vertically and a gradient line.''' + rect = QRect( + divider.start_x + offset_x, + divider.start_y - scroll_y, + divider.width, + self.SPINE_HEIGHT, + ) + divider_rect = QRect( + -rect.height() // 2, + -rect.width() // 2, + rect.height(), + rect.width(), + ) - # Method 3: Try new_api field_for - try: - if hasattr(db, 'new_api'): - pages = db.new_api.field_for('pages', book_id, default_value=None) - if pages is not None: - try: - pages = int(pages) - if pages > 0: - return pages - except (ValueError, TypeError): - pass - except (AttributeError, KeyError, TypeError): - pass + def rotate(): + painter.translate(rect.left() + rect.width() // 2, rect.top() + rect.height() // 2) + painter.rotate(-90) - # Method 4: Try custom column #pages - try: - if hasattr(db, 'field_for'): - pages = db.field_for('#pages', book_id, default_value=None) - if pages is not None: - try: - pages = int(pages) - if pages > 0: - return pages - except (ValueError, TypeError): - pass - except (AttributeError, KeyError, TypeError): - pass + # Bottom margin + text_rect = divider_rect.adjusted(8, 0, 0, 0) + elided_text, font, sized_rect = self._get_sized_text(divider.group_name, text_rect.width(), 12, 8) + font.setBold(True) - # Method 5: Estimate from file size as fallback - # Average ebook: ~1-2KB per page, so estimate pages from size - try: - if hasattr(db, 'new_api'): - formats = db.new_api.formats(book_id, verify_formats=False) - if formats: - # Get size of first available format - for fmt in formats: - try: - fmt_meta = db.new_api.format_metadata(book_id, fmt) - size_bytes = fmt_meta.get('size', 0) - if size_bytes and size_bytes > 0: - # Estimate: ~1500 bytes per page (conservative) - estimated_pages = max(50, int(size_bytes / 1500)) - # Cap at reasonable max - return min(estimated_pages, 2000) - except (AttributeError, KeyError, TypeError): - continue - except (AttributeError, KeyError, TypeError): - pass + # Calculate line dimensions + line_rect = text_rect.adjusted(sized_rect.width(), 0, 0, 0) + overflow = (line_rect.height() - self.DIVIDER_LINE_WIDTH) // 2 + line_rect.adjust(0, overflow, 0, -overflow) - # Default fallback: vary based on book_id to ensure visual differences - # This ensures books have different widths even without page data - # Use book_id to create a pseudo-random but consistent value per book - # Range: 50-400 pages (covers tiers 2-5 for visual variety) - import hashlib - book_id_str = str(book_id) if book_id else '0' - hash_val = int(hashlib.md5(book_id_str.encode()).hexdigest()[:8], 16) - # Map hash to 50-400 page range - default_pages = 50 + (hash_val % 350) - return default_pages + # Draw vertical gradient line if long enough + if line_rect.width() > 8: + painter.save() + rotate() + gradient = QLinearGradient( + QPointF(line_rect.left(), line_rect.left()), + QPointF(line_rect.left() + line_rect.width(), line_rect.left()), + ) + gradient.setColorAt(0, self.DIVIDER_GRADIENT_LINE_1) + gradient.setColorAt(0.5, self.DIVIDER_GRADIENT_LINE_2) + gradient.setColorAt(1, self.DIVIDER_GRADIENT_LINE_1) - def _calculate_spine_width(self, pages): - '''Calculate spine width from page count with 8-tier granular scaling. + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(gradient)) + painter.drawRect(line_rect) + painter.restore() - Args: - pages: Number of pages in the book + painter.save() + rotate() + painter.setFont(font) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, elided_text) + painter.restore() - Returns: - Spine width in pixels (between SPINE_MIN_WIDTH and SPINE_MAX_WIDTH) - - Tier breakdown for clear visual differences (reduced scale): - Tier 1: 0-20 pages → 15-18px (very short articles) - Tier 2: 20-50 pages → 18-22px (short articles) - Tier 3: 50-100 pages → 22-28px (articles/short docs) - Tier 4: 100-200 pages → 28-35px (short books) - Tier 5: 200-350 pages → 35-43px (medium books) - Tier 6: 350-500 pages → 43-50px (long books) - Tier 7: 500-750 pages → 50-56px (very long books) - Tier 8: 750+ pages → 56-60px (epic books) - ''' - # Ensure pages is a valid number - try: - pages = float(pages) if pages else 0.0 - except (TypeError, ValueError): - pages = 0.0 - - if pages <= 0: - return float(self.SPINE_MIN_WIDTH) - elif pages < 20: - # Tier 1: Very short articles (0-20 pages) → 15-18px - # 10 pages = 16.5px, 20 pages = 18px - return 15.0 + (pages / 20.0) * 3.0 - elif pages < 50: - # Tier 2: Short articles (20-50 pages) → 18-22px - # 20 pages = 18px, 35 pages = 20px, 50 pages = 22px - return 18.0 + ((pages - 20) / 30.0) * 4.0 - elif pages < 100: - # Tier 3: Articles/short docs (50-100 pages) → 22-28px - # 50 pages = 22px, 75 pages = 25px, 100 pages = 28px - return 22.0 + ((pages - 50) / 50.0) * 6.0 - elif pages < 200: - # Tier 4: Short books (100-200 pages) → 28-35px - # 100 pages = 28px, 150 pages = 31.5px, 200 pages = 35px - return 28.0 + ((pages - 100) / 100.0) * 7.0 - elif pages < 350: - # Tier 5: Medium books (200-350 pages) → 35-43px - # 200 pages = 35px, 275 pages = 39px, 350 pages = 43px - return 35.0 + ((pages - 200) / 150.0) * 8.0 - elif pages < 500: - # Tier 6: Long books (350-500 pages) → 43-50px - # 350 pages = 43px, 425 pages = 46.5px, 500 pages = 50px - return 43.0 + ((pages - 350) / 150.0) * 7.0 - elif pages < 750: - # Tier 7: Very long books (500-750 pages) → 50-56px - # 500 pages = 50px, 625 pages = 53px, 750 pages = 56px - return 50.0 + ((pages - 500) / 250.0) * 6.0 - else: - # Tier 8: Epic books (750+ pages) → 56-60px - # 750 pages = 56px, 1000 pages = 58px, 1500+ pages = 60px - base = 56.0 - additional = min(4.0, (pages - 750) / 187.5) # ~1px per 187 pages, max 4px - result = base + additional - # Ensure result is within bounds - return max(float(self.SPINE_MIN_WIDTH), min(float(self.SPINE_MAX_WIDTH), result)) - - def _draw_spine(self, painter, row, rect): + def _draw_spine(self, painter: QPainter, spine: SpineTuple, scroll_y: int, offset_x: int): '''Draw a book spine.''' - if self._model is None: - return + mi = self.dbref().get_proxy_metadata(spine.book_id) - index = self._model.index(row, 0) - if not index.isValid(): - return + # Determine if selected + is_selected = spine.row in self._selected_rows or spine.row == self._current_row - # Get book metadata - try: - book_id = self._model.id(index) - db = self._model.db - mi = db.get_metadata(book_id, index_is_id=True) - title = mi.title or _('Unknown') - # Calculate spine width from page count - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - # Update rect width if needed - if rect.width() != spine_width: - rect.setWidth(int(spine_width)) - except (AttributeError, IndexError, KeyError, TypeError): - title = _('Unknown') - spine_width = 40 - - # Determine if selected or hovered - is_selected = row in self._selected_rows or row == self._current_row - is_hovered = row == self._hovered_row - - # Get cover color (Phase 2) - spine_color = self._get_spine_color(book_id, db) + # Get cover color + spine_color = self._get_spine_color(spine.book_id) # Ensure we have a valid color - if spine_color is None or not spine_color.isValid(): - spine_color = self.DEFAULT_SPINE_COLOR + if not spine_color or not spine_color.isValid(): + spine_color = DEFAULT_SPINE_COLOR if is_selected: spine_color = spine_color.lighter(120) - elif is_hovered: - spine_color = spine_color.lighter(110) - # When hovered, make spine transparent (like JSX mock) - # The cover will be shown instead - spine_opacity = 0.0 if is_hovered else 1.0 + height_mod = self._get_height_modifier(spine.book_id) + spine_rect = QRect( + spine.start_x + offset_x, + spine.start_y - scroll_y, + spine.width, + self.SPINE_HEIGHT, + ) + spine_rect.adjust(0, height_mod, 0, 0) # Draw spine background with gradient (darker edges, lighter center) + self._draw_spine_background(painter, spine_rect, spine_color) + + # Draw cover thumbnail overlay + if self._enable_thumbnail: + self._draw_thumbnail_overlay(painter, spine_rect, spine.book_id) + + # Draw reading statue indicator at bottom + has_indicator = self._draw_statue_indicator(painter, spine_rect, spine.book_id, mi) + + # Draw title (rotated vertically) + title = self.render_template_title(spine.book_id, mi) + self._draw_spine_title(painter, spine_rect, spine_color, title, has_indicator) + + # Draw selection highlight around the spine + if is_selected: + self._draw_selection_highlight(painter, spine_rect) + + def _draw_spine_background(self, painter: QPainter, rect: QRect, spine_color: QColor): + '''Draw spine background with gradient (darker edges, lighter center).''' painter.save() - painter.setOpacity(spine_opacity) + painter.setOpacity(1.0) gradient = QLinearGradient( QPointF(rect.left(), rect.top()), - QPointF(rect.right(), rect.top()) + QPointF(rect.width(), rect.top()), ) gradient.setColorAt(0, spine_color.darker(115)) gradient.setColorAt(0.5, spine_color) @@ -1653,102 +1320,204 @@ class BookshelfView(QAbstractScrollArea): # {{{ # Add subtle vertical gradient for depth vertical_gradient = QLinearGradient( QPointF(rect.left(), rect.top()), - QPointF(rect.left(), rect.bottom()) + QPointF(rect.left(), rect.height()), ) vertical_gradient.setColorAt(0, QColor(255, 255, 255, 20)) # Slight highlight at top vertical_gradient.setColorAt(1, QColor(0, 0, 0, 30)) # Slight shadow at bottom painter.fillRect(rect, QBrush(vertical_gradient)) painter.restore() - # Draw cover texture overlay (Phase 2) - only if not hovered - if not is_hovered: - painter.save() - painter.setOpacity(spine_opacity) - self._draw_texture_overlay(painter, book_id, db, rect) - painter.restore() - - # Draw selection highlight - if is_selected: - painter.setPen(QColor('#ffff00')) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawRect(rect.adjusted(1, 1, -1, -1)) - - # Thumbnail drawing removed per user request - - # Draw title (rotated vertically) - only if not hovered - if not is_hovered: - painter.save() - painter.setOpacity(spine_opacity) - painter.translate(rect.left() + rect.width() / 2, rect.top() + rect.height() / 2) - painter.rotate(-90) - - # Determine text color based on spine background brightness - text_color = self._get_contrasting_text_color(spine_color) - painter.setPen(text_color) - - font = QFont() - font.setPointSize(11) # Increased from 9 - painter.setFont(font) - - # Truncate title to fit and leave space for status indicator - fm = QFontMetrics(font) - max_width = rect.height() - 40 - elided_title = fm.elidedText(title, Qt.TextElideMode.ElideRight, max_width) - - # Shift text up to avoid overlapping status indicator - text_y_offset = -5 - text_rect = QRect(int(-rect.height() / 2), int(-rect.width() / 2 + text_y_offset), int(rect.height()), int(rect.width())) - painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_title) - painter.restore() - - # Draw hover cover if this book is hovered - if is_hovered and self._hover_cover_pixmap is not None: - self._draw_hover_cover(painter, rect) - - # Draw reading status indicator at bottom + def _draw_spine_title(self, painter: QPainter, rect: QRect, spine_color: QColor, title: str, has_indicator=False): + '''Draw vertically the title on the spine.''' + if not title: + return painter.save() - painter.setOpacity(1.0) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.translate(rect.left() + rect.width() // 2, rect.top() + rect.height() // 2) + painter.rotate(-90) - try: - reading_status = self._get_reading_status(book_id, db, mi) - - if reading_status == 'finished': - dot_color = QColor('#4CAF50') - elif reading_status == 'reading': - dot_color = QColor('#FFC107') - else: - dot_color = QColor('#F44336') - - dot_radius = 4 - dot_x = rect.x() + rect.width() // 2 - dot_y = rect.y() + rect.height() - dot_radius - 10 - - painter.setBrush(QBrush(dot_color)) - painter.setPen(QPen(QColor(255, 255, 255, 120), 1.5)) - painter.drawEllipse(QPoint(dot_x, dot_y), dot_radius, dot_radius) - - except Exception as e: - dot_color = QColor('#9C27B0') - dot_radius = 4 - dot_x = rect.x() + rect.width() // 2 - dot_y = rect.y() + rect.height() - dot_radius - 10 - painter.setBrush(QBrush(dot_color)) - painter.setPen(QPen(QColor(255, 255, 255, 120), 1.5)) - painter.drawEllipse(QPoint(dot_x, dot_y), dot_radius, dot_radius) + # Determine text color based on spine background brightness + text_color = self._get_contrasting_text_color(spine_color) + painter.setPen(text_color) + text_rect = QRect( + -rect.height() // 2, + -rect.width() // 2, + rect.height(), + rect.width(), + ) + # leave space for statue indicator and a margin with top of the spine + text_rect.adjust( + 22 if has_indicator else 6, + 0, + -4 if has_indicator else -6, + 0, + ) + elided_text, font, _rect = self._get_sized_text(title, text_rect.width(), 12, 8) + painter.setFont(font) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_text) painter.restore() + def _draw_thumbnail_overlay(self, painter: QPainter, rect: QRect, book_id: int): + '''Draw cover thumbnail overlay on spine.''' + thumbnail = self._get_spine_thumbnail(book_id) + if not thumbnail or thumbnail.isNull(): + return + + # Draw with opacity + painter.save() + painter.setOpacity(0.3) # 30% opacity + rect = rect.translated(0, 0) + rect.setWidth(thumbnail.width()) + painter.drawPixmap(rect, thumbnail) + painter.restore() + + def _draw_hover_cover(self, painter: QPainter, hovered: HoveredCover, scroll_y: int): + '''Draw the hover cover popup. + + The cover replaces the spine when hovered, appearing at the same position + with full spine height (150px) and smooth fade-in animation. + ''' + if not hovered.is_valid(): + return + + is_selected = hovered.row in self._selected_rows or hovered.row == self._current_row + cover_rect = hovered.rect() + cover_rect.translate(0, -scroll_y) + + if self._enable_shadow: + # Draw shadow with blur effect (like JSX mock: 6px 6px 18px rgba(0,0,0,0.45)) + # Qt doesn't have native blur, so we'll use a darker shadow + rect = cover_rect.translated(6, 6) + shadow_blur = 3 + # Draw multiple shadow layers for blur effect + for i in range(shadow_blur): + alpha = int(115 * (1 - i / shadow_blur)) # 0.45 opacity = ~115 alpha + shadow_color = QColor(0, 0, 0, alpha) + shadow_layer = rect.translated(i, i) + painter.fillRect(shadow_layer, shadow_color) + + # Draw the dominant cover color as background to not fade-in from white + painter.fillRect(cover_rect, hovered.dominant_color) + # Draw cover with smooth fade-in opacity transition + painter.save() + painter.setOpacity(hovered.opacity) + painter.drawPixmap(cover_rect, hovered.pixmap) + painter.restore() + + # Add subtle gradient overlay (like JSX mock: linear-gradient(135deg, rgba(255,255,255,0.12) 0%, transparent 50%)) + painter.save() + overlay_gradient = QLinearGradient( + QPointF(cover_rect.left(), cover_rect.top()), + QPointF(cover_rect.width(), cover_rect.height()), + ) + overlay_gradient.setColorAt(0, QColor(255, 255, 255, 31)) # 0.12 opacity = ~31 alpha + overlay_gradient.setColorAt(0.5, QColor(255, 255, 255, 0)) + overlay_gradient.setColorAt(1, QColor(255, 255, 255, 0)) + painter.fillRect(cover_rect, QBrush(overlay_gradient)) + painter.restore() + + # Draw reading statue indicator at same position that for the spine + spine_rect = hovered.spine_rect() + spine_rect.translate(0, -scroll_y) + self._draw_statue_indicator(painter, spine_rect, hovered.book_id) + + if is_selected: + # Draw selection highlight around the hovered cover + self._draw_selection_highlight(painter, cover_rect) + # Cover integration methods - def _get_contrasting_text_color(self, background_color): + def _update_hover_fade(self): + '''Update hover cover fade-in animation and shift progress.''' + self._hovered.update() + self.update_viewport() + if not self._hovered.start_time: + self._hover_fade_timer.stop() + + def _load_hover_cover(self): + '''Load the cover and scale it for hover popup.''' + try: + book_id = self._hovered.book_id + has_cover, cdata, timestamp = self.dbref().cover_or_cache(book_id, 0, as_what='pil_image') + + if not has_cover or not cdata: + cdata = DEFAULT_COVER.copy() + + # Scale to hover size - resize to the spine height or a reasonable max width + height_modifier = self._get_height_modifier(book_id) + cdata.thumbnail((self.HOVER_EXPANDED_WIDTH, self.SPINE_HEIGHT)) + + self._hovered.pixmap = pixmap = convert_PIL_image_to_pixmap(cdata) + self._hovered.dominant_color = extract_dominant_color(cdata) or DEFAULT_SPINE_COLOR + self._hovered.spine_width = spine_width = self._get_spine_width(book_id) + self._hovered.spine_height = spine_height = self.SPINE_HEIGHT + self._hovered.height_modifier = height_modifier + self._hovered.width = spine_width # ensure that the animation start at the spine width + self._hovered.height = spine_height # ensure that the animation start at the spine height + self._hovered.width_max = pixmap.width() + self._hovered.height_end = pixmap.height() + + if self._hovered.FADE_TIME <= 0: + # Fade-in animation is disable + self._hovered.progress = 1.0 + self._hovered.opacity = 1.0 + self._hovered.shift = 1.0 + self._hovered.height = self._hovered.height_end + self._hovered.width = max(self._hovered.width_max, self._hovered.spine_width) + else: + # Start timer for smooth fade-in animation + self._hovered.start_time = time() + if not self._hover_fade_timer.isActive(): + self._hover_fade_timer.start(16) # ~60fps updates + + # Trigger immediate repaint to show the cover + self.update_viewport() + except Exception: + import traceback + traceback.print_exc() + self._hovered = HoveredCover() + + def _delayed_hover_load(self): + ''' + Load the buffered row only after a short delay. + + When the mouse move, several rows are request but many of them are probably not desired + since their are on the path of the cursor but are not the one on which the cusor end it path. + This can lead to load too many and useless cover, which impact the performance. + ''' + if self._hover_buffer_time: + # Test if is too early to load a new hovered cover + # 20ms of delay, unoticable but avoid the loading of unrelevant covers + if elapsed_time(self._hover_buffer_time) < 20: + return + + # Avoid concurrent load of the same cover + if self._hovered.row == self._hover_buffer_row: + self._hover_buffer_timer.stop() + return + + # Delay passed, start load new hover cover + self._hover_fade_timer.stop() + self._hovered = HoveredCover() + + # Load hover cover if hovering over a book + if self._hover_buffer_row >= 0: + self._hovered.row = self._hover_buffer_row + self._hovered.book_id = self.book_id_from_row(self._hover_buffer_row) + self._load_hover_cover() + + self._hover_buffer_time = None + self._hover_buffer_timer.stop() + self.update_viewport() + + def _get_contrasting_text_color(self, background_color: QColor): ''' Calculate text color based on background brightness for optimal contrast. :param background_color: QColor of the spine background :return: QColor for text ''' - if background_color is None or not background_color.isValid(): + if not background_color or not background_color.isValid(): return self.TEXT_COLOR # Get RGB values @@ -1778,423 +1547,248 @@ class BookshelfView(QAbstractScrollArea): # {{{ return self.TEXT_COLOR_DARK else: return self.TEXT_COLOR + elif luminance > 0.5: + return self.TEXT_COLOR_DARK else: - if luminance > 0.5: - return self.TEXT_COLOR_DARK - else: - return self.TEXT_COLOR + return self.TEXT_COLOR - def _get_spine_color(self, book_id, db): + def _get_spine_color(self, book_id: int) -> QColor: '''Get the spine color for a book from cache or by extraction.''' - if book_id in self._cover_colors: - cached_color = self._cover_colors[book_id] - if cached_color is not None and cached_color.isValid(): - return cached_color - self._cover_colors.pop(book_id, None) - - # Load color from cover - try: - color = get_cover_color(book_id, db) - if color is None or not color.isValid(): - color = self.DEFAULT_SPINE_COLOR - self._cover_colors[book_id] = color - return color - except Exception: - color = self.DEFAULT_SPINE_COLOR - self._cover_colors[book_id] = color + color = self.color_cache.get(book_id) + if color and color.isValid(): return color + self.color_cache.pop(book_id, None) - def _get_spine_thumbnail(self, book_id, db): - '''Get the spine thumbnail for a book from cache or by generation.''' - if book_id in self._spine_thumbnails: - thumb = self._spine_thumbnails[book_id] - if thumb is not None and not thumb.isNull(): - return thumb - self._spine_thumbnails.pop(book_id, None) + color = self._create_cover_color(book_id) - try: - thumbnail = get_spine_thumbnail(book_id, db, self.THUMBNAIL_HEIGHT) - if thumbnail is not None and not thumbnail.isNull(): - self._spine_thumbnails[book_id] = thumbnail - return thumbnail - except Exception: - pass - - try: - cover_data = db.cover(book_id, as_image=True) - if cover_data is not None and not cover_data.isNull(): - cover_width = cover_data.width() - cover_height = cover_data.height() - if cover_width > 0 and cover_height > 0: - aspect_ratio = cover_width / cover_height - thumb_width = int(self.THUMBNAIL_HEIGHT * aspect_ratio) - scaled = cover_data.scaled( - thumb_width, self.THUMBNAIL_HEIGHT, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - if not scaled.isNull(): - thumbnail = QPixmap.fromImage(scaled) - self._spine_thumbnails[book_id] = thumbnail - return thumbnail - except Exception: - pass + if not color or not color.isValid(): + color = DEFAULT_SPINE_COLOR + self.color_cache[book_id] = color + return color + def _get_spine_thumbnail(self, book_id: int) -> QPixmap: + '''Get the spine thumbnail for a book from cache.''' + cover_tuple = self._get_cached_thumbnail(book_id) + if cover_tuple.cache_valid: + return convert_PIL_image_to_pixmap(cover_tuple.cdata) + if cover_tuple.cdata: + self.render_queue.put(book_id) + self.color_cache.pop(book_id, None) return None - def _get_reading_status(self, book_id, db, mi=None): + def _get_cached_thumbnail(self, book_id: int) -> CoverTuple: ''' - Determine reading status for a book based on: - 1. Tags containing 'read', 'reading', 'finished', etc. - 2. Custom column '#read' if it exists - 3. Last read position (if available) + Fetches the cover from the cache if it exists, otherwise the cover.jpg stored in the library. - Returns: 'unread', 'reading', or 'finished' + Return a CoverTuple containing the following cover and cache data: + + book_id: The id of the book for which a cover is wanted. + has_cover: True if the book has an associated cover image file. + cdata: Cover data. Can be None (no cover data), or a rendered cover image. + cache_valid: True if the cache has correct data, False if a cover exists + but isn't in the cache, None if the cache has data but the + cover has been deleted. + timestamp: the cover file modtime if the cover came from the file system, + the timestamp in the cache if a valid cover is in the cache, + otherwise None. ''' - try: - if mi is None: - mi = db.get_metadata(book_id, index_is_id=True) - - # Method 1: Check tags for reading status (most common method) - tags = getattr(mi, 'tags', []) or [] - for tag in tags: - tag_lower = tag.lower() - if tag_lower in ('finished', 'read', 'completed'): - return 'finished' - elif tag_lower in ('reading', 'in-progress', 'currently-reading', 'in progress'): - return 'reading' - - # Method 2: Check custom column '#read' (common convention) - try: - read_status = getattr(mi, '#read', None) - if read_status: - status_lower = str(read_status).lower() - if 'finish' in status_lower or 'complete' in status_lower or status_lower == 'yes': - return 'finished' - elif 'progress' in status_lower or 'reading' in status_lower: - return 'reading' - except (AttributeError, TypeError): - pass - - # Method 3: Check last read position - try: - # Get all formats for this book - formats = getattr(mi, 'formats', []) or [] - if formats: - # Check if any format has a last read position - for fmt in formats: - positions = db.get_last_read_positions(book_id, fmt, '_') - if positions: - # Has reading progress - for pos in positions: - pos_frac = pos.get('pos_frac', 0) - if pos_frac >= 0.95: # 95% or more = finished - return 'finished' - elif pos_frac > 0.01: # More than 1% = reading - return 'reading' - except (AttributeError, TypeError, KeyError): - pass - - return 'unread' - except Exception: - return 'unread' - - def _draw_texture_overlay(self, painter, book_id, db, rect): - '''Draw cover texture overlay on spine.''' - try: - cover_data = db.cover(book_id, as_image=True) - if cover_data is None or cover_data.isNull(): - return - - # Extract left edge for texture (10px wide) - edge_width = min(10, rect.width()) - if cover_data.width() > 0: - # Get left edge of cover - edge_rect = QRect(0, 0, edge_width, cover_data.height()) - edge_image = cover_data.copy(edge_rect) - - # Scale to spine height - scaled_edge = edge_image.scaled( - edge_width, rect.height(), - Qt.AspectRatioMode.IgnoreAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - - # Draw with opacity - painter.save() - painter.setOpacity(0.3) # 30% opacity - painter.drawPixmap(rect.left(), rect.top(), QPixmap.fromImage(scaled_edge)) - painter.restore() - except Exception: - # Silently fail if texture overlay can't be drawn - pass - - def _draw_hover_cover(self, painter, spine_rect): - '''Draw the hover cover popup at the spine position (like JSX mock). - - The cover replaces the spine when hovered, appearing at the same position - with full spine height (150px) and smooth fade-in animation. - ''' - if self._hover_cover_pixmap is None or self._hover_cover_pixmap.isNull(): - return - - # Use the already-scaled pixmap dimensions (it was scaled in _load_hover_cover) - cover_width = self._hover_cover_pixmap.width() - cover_height = self._hover_cover_pixmap.height() - - # Position cover at spine position - left edge aligned with spine left edge - # The cover replaces the spine, so left edge stays at original spine position - popup_x = spine_rect.left() # Left edge aligned with original spine - popup_y = spine_rect.top() # Same vertical position as spine - - # Draw shadow with blur effect (like JSX mock: 6px 6px 18px rgba(0,0,0,0.45)) - # Qt doesn't have native blur, so we'll use a darker shadow - shadow_offset = 6 - shadow_blur = 3 - shadow_rect = QRect( - popup_x + shadow_offset, - popup_y + shadow_offset, - cover_width, - cover_height - ) - # Draw multiple shadow layers for blur effect - for i in range(shadow_blur): - alpha = int(115 * (1 - i / shadow_blur)) # 0.45 opacity = ~115 alpha - shadow_color = QColor(0, 0, 0, alpha) - shadow_layer = QRect( - shadow_rect.x() + i, - shadow_rect.y() + i, - shadow_rect.width(), - shadow_rect.height() - ) - painter.fillRect(shadow_layer, shadow_color) - - # Draw cover with smooth fade-in (opacity transition) - cover_rect = QRect(popup_x, popup_y, cover_width, cover_height) - painter.save() - painter.setOpacity(self._hover_cover_opacity) - painter.drawPixmap(cover_rect, self._hover_cover_pixmap) - painter.restore() - - # Add subtle gradient overlay (like JSX mock: linear-gradient(135deg, rgba(255,255,255,0.12) 0%, transparent 50%)) - painter.save() - overlay_gradient = QLinearGradient( - QPointF(cover_rect.left(), cover_rect.top()), - QPointF(cover_rect.right(), cover_rect.bottom()) - ) - overlay_gradient.setColorAt(0, QColor(255, 255, 255, 31)) # 0.12 opacity = ~31 alpha - overlay_gradient.setColorAt(0.5, QColor(255, 255, 255, 0)) - overlay_gradient.setColorAt(1, QColor(255, 255, 255, 0)) - painter.fillRect(cover_rect, QBrush(overlay_gradient)) - painter.restore() - - def _load_visible_covers(self): - '''Load covers for visible books (lazy loading).''' - if self._model is None: - return - - scroll_y = self.verticalScrollBar().value() - viewport_rect = self.viewport().rect() - visible_rect = QRect( - viewport_rect.x(), - viewport_rect.y() + scroll_y, - viewport_rect.width(), - viewport_rect.height() - ) - - row_count = self._model.rowCount(QModelIndex()) - if row_count == 0: - return - - db = self._model.db - x_pos = 10 # Start position (no label area - dividers are inline) - shelf_y = 10 - - # Load covers for visible books - # Use same layout logic as paintEvent (flattened items with grouping) - all_rows = list(range(row_count)) - groups = group_books(all_rows, self._model, self._grouping_mode) - - # Flatten groups into a list of (row, group_name) tuples - flattened_items = [] - for group_name, group_rows in groups: - for row in group_rows: - flattened_items.append((row, group_name)) - - last_group_name = None - for row, group_name in flattened_items: - # Account for inline divider when group changes - if self._grouping_mode != 'none' and group_name != last_group_name and last_group_name is not None: - x_pos += 32 # DIVIDER_WIDTH (30) + gap (2) - last_group_name = group_name - try: - index = self._model.index(row, 0) - if not index.isValid(): - continue - - book_id = self._model.id(index) - mi = db.get_metadata(book_id, index_is_id=True) - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - except (AttributeError, IndexError, KeyError, TypeError): - spine_width = 40 - - spine_rect = QRect(x_pos, shelf_y, int(spine_width), self.SPINE_HEIGHT) - - # Check if spine is visible - if spine_rect.bottom() >= visible_rect.top() and spine_rect.top() <= visible_rect.bottom(): - # Load color and thumbnail if not already cached - if book_id not in self._cover_colors: - self._get_spine_color(book_id, db) - # Always try to load thumbnail (will use cache if available) - thumb = self._get_spine_thumbnail(book_id, db) - # If thumbnail loaded, trigger repaint - if thumb is not None and book_id not in self._spine_thumbnails: - self.viewport().update() - - x_pos += int(spine_width) + 2 - - # Check if we need to move to next shelf - if x_pos + spine_width > viewport_rect.width() - 10: - x_pos = 10 # Reset to start position - shelf_y += self.SHELF_SPACING - # Early exit if we've scrolled past visible area - if shelf_y > visible_rect.bottom() + self.SPINE_HEIGHT: - break - - def _calculate_shelf_y_for_row(self, row, scroll_y, viewport_width): - '''Calculate which shelf y position a given row would be on.''' - if self._model is None or row < 0: - return 10 + scroll_y - - shelf_y = 10 + scroll_y - x_pos = 10 - db = self._model.db - - for r in range(row + 1): # Iterate up to and including the target row - try: - index = self._model.index(r, 0) - if index.isValid(): - book_id = self._model.id(index) - mi = db.get_metadata(book_id, index_is_id=True) - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - else: - spine_width = 40 - except (AttributeError, IndexError, KeyError, TypeError): - spine_width = 40 - - # Check if current book would overflow before adding it - if x_pos + int(spine_width) > viewport_width - 10: - # Move to next shelf - x_pos = 10 - shelf_y += self.SHELF_SPACING - - # Add the current book's width - x_pos += int(spine_width) + 2 - - return shelf_y - - def _update_hover_fade(self): - '''Update hover cover fade-in animation and shift progress.''' - if self._hover_fade_start_time is None: - self._hover_fade_timer.stop() - return - - elapsed = (time() - self._hover_fade_start_time) * 1000 # Convert to ms - duration = 200 # 200ms like JSX mock - - if elapsed >= duration: - self._hover_cover_opacity = 1.0 - self._hover_shift_progress = 1.0 - self._hover_fade_timer.stop() - self._hover_fade_start_time = None - else: - # Cubic ease-out curve (similar to JSX mock) - # Ease-out cubic: 1 - (1 - t)^3 - # Interpolate from 0.3 to 1.0 for opacity - t = elapsed / duration - progress = 1.0 - (1.0 - t) ** 3 - # Interpolate opacity from 0.3 (start) to 1.0 (end) - self._hover_cover_opacity = 0.3 + (1.0 - 0.3) * progress - self._hover_shift_progress = progress - - self.viewport().update() - - def _load_hover_cover(self, book_id, db): - '''Load full cover for hover popup with smooth fade-in animation.''' - # Always load fresh cover - don't check if already loaded - # This ensures we get the correct cover for the hovered book - try: - # Try multiple methods to get cover - cover_data = None - try: - cover_data = db.cover(book_id, index_is_id=True, as_image=True) - except (TypeError, AttributeError): - try: - cover_data = db.cover(book_id, as_image=True) - except Exception: - pass - - if cover_data is None or cover_data.isNull(): - self._hover_cover_pixmap = None - self._hover_cover_row = -1 - self._hover_cover_opacity = 1.0 - return - - # Scale to hover size - use full spine height, calculate width from aspect ratio - cover_height = self.SPINE_HEIGHT # Use full spine height (150px) - - # Calculate proper aspect ratio to determine width - if cover_data.width() > 0 and cover_data.height() > 0: - cover_aspect = cover_data.width() / cover_data.height() - # Calculate width to maintain aspect ratio with spine height - cover_width = int(cover_height * cover_aspect) - # But limit to reasonable max width (use HOVER_EXPANDED_WIDTH as max) - cover_width = min(cover_width, self.HOVER_EXPANDED_WIDTH) + db = self.dbref() + if db is None: + return None + tc = self.thumbnail_cache + cdata, timestamp = tc[book_id] # None, None if not cached. + if timestamp is None: + # Cover not in cache. Try to read the cover from the library. + has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0, as_what='pil_image') + if has_cover: + # There is a cover.jpg, already rendered as a pil_image + cache_valid = False else: - # Fallback dimensions - cover_width = self.HOVER_COVER_WIDTH - cover_height = self.SPINE_HEIGHT + # No cover.jpg + cache_valid = None + else: + # A cover is in the cache. Check whether it is up to date. + # Note that if tcdata is not None then it is already a PIL image. + has_cover, tcdata, timestamp = db.new_api.cover_or_cache(book_id, timestamp, as_what='pil_image') + if has_cover: + if tcdata is None: + # The cached cover is up-to-date + cache_valid = True + cdata = Image.open(BytesIO(cdata)) + else: + # The cached cover is stale + cache_valid = False + cdata = tcdata + if has_cover and cdata is None: + has_cover = False + cache_valid = None + return CoverTuple(book_id=book_id, has_cover=has_cover, cache_valid=cache_valid, cdata=cdata, timestamp=timestamp) - self._hover_cover_pixmap = QPixmap.fromImage(cover_data).scaled( - cover_width, cover_height, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - self._hover_cover_row = book_id + def _fetch_thumbnails_cache(self): + q = self.render_queue + while True: + book_id = q.get() + if book_id is None: + return - # Start fade-in animation (like JSX mock: 0.2s ease transition) - # Start with full opacity immediately for instant feedback - self._hover_cover_opacity = 1.0 - self._hover_shift_progress = 1.0 # Start at full expansion for instant feedback - # Still start timer for smooth animation if needed, but start at full - self._hover_fade_start_time = time() - if not self._hover_fade_timer.isActive(): - self._hover_fade_timer.start(16) # ~60fps updates + cover_tuple = self._get_cached_thumbnail(book_id) + if cover_tuple.cdata: + self._create_thumbnail_cache(book_id, cover_tuple) - # Trigger immediate repaint to show the cover - self.viewport().update() - except Exception: - self._hover_cover_pixmap = None - self._hover_cover_row = -1 - self._hover_cover_opacity = 1.0 + self.update_cover.emit() + q.task_done() + + def _create_cover_color(self, book_id: int): + db = self.dbref() + if db is None: + return + has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0, as_what='pil_image') + if has_cover and cdata: + color = extract_dominant_color(cdata) + if color and color.isValid(): + return color + return None + + def _create_thumbnail_cache(self, book_id: int, cover_tuple: CoverTuple): + '''Generate the thumbnail and cache it.''' + thumb = generate_spine_thumbnail(cover_tuple.cdata, self.THUMBNAIL_WIDTH, self.SPINE_HEIGHT) + if thumb: + tc = self.thumbnail_cache + try: + with BytesIO() as buf: + thumb.save(buf, format=CACHE_FORMAT) + # use getbuffer() instead of getvalue() to avoid a copy + tc.insert(book_id, cover_tuple.timestamp, buf.getbuffer()) + except Exception: + tc.invalidate((book_id,)) + self.color_cache.pop(book_id, None) + import traceback + traceback.print_exc() + + def _get_spine_width(self, book_id: int, mi:Metadata=None) -> int: + '''Get the spine width for a book from cache or by generation.''' + self.init_template(self.dbref()) + if self.template_pages_is_empty and not self.pages_use_book_size: + return self.SPINE_WIDTH_DEFAULT + + if not mi: + mi = self.dbref().get_proxy_metadata(book_id) + + if not self.template_pages_is_empty: + pages = self.render_template_pages(book_id, mi) + if isinstance(pages, int) and pages >= 0: + return self._calculate_spine_width(pages) + + if self.pages_use_book_size: + pages = size_to_page_count(mi.book_size) + if isinstance(pages, int) and pages >= 0: + return self._calculate_spine_width(pages) + + if self.template_pages_is_empty: + return self.SPINE_WIDTH_DEFAULT + # Range: 50-350 pages (covers tiers 3-5 for visual variety) + return self._calculate_spine_width(50 + pseudo_random(book_id, 300)) + + def _calculate_spine_width(self, pages: int) -> int: + '''Calculate spine width from page count with 8-tier granular scaling. + + Args: + pages: Number of pages in the book + + Returns: + Spine width in pixels (between SPINE_WIDTH_MIN and SPINE_WIDTH_MAX) + + Tier breakdown for clear visual differences (reduced scale): + Tier 1: 0-20 pages → 15-18px (very short articles) + Tier 2: 20-50 pages → 18-22px (short articles) + Tier 3: 50-100 pages → 22-28px (articles/short docs) + Tier 4: 100-200 pages → 28-35px (short books) + Tier 5: 200-350 pages → 35-43px (medium books) + Tier 6: 350-500 pages → 43-50px (long books) + Tier 7: 500-750 pages → 50-56px (very long books) + Tier 8: 750+ pages → 56-60px (epic books) + ''' + # Ensure pages is a valid number + if not pages: + return self.SPINE_WIDTH_DEFAULT + try: + pages = float(pages) + except (TypeError, ValueError): + return self.SPINE_WIDTH_DEFAULT + + # Ensure pages is within bounds + pages = max(0, min(1500, pages)) + + rslt = self._pages_widths.get(round(pages)) + if rslt: + return rslt + + if pages <= 0: + rslt = self.SPINE_WIDTH_MIN + elif pages < 20: + # Tier 1: Very short articles (0-20 pages) → 15-18px + # 10 pages = 16.5px, 20 pages = 18px + rslt = 15.0 + (pages / 20.0) * 3.0 + elif pages < 50: + # Tier 2: Short articles (20-50 pages) → 18-22px + # 20 pages = 18px, 35 pages = 20px, 50 pages = 22px + rslt = 18.0 + ((pages - 20) / 30.0) * 4.0 + elif pages < 100: + # Tier 3: Articles/short docs (50-100 pages) → 22-28px + # 50 pages = 22px, 75 pages = 25px, 100 pages = 28px + rslt = 22.0 + ((pages - 50) / 50.0) * 6.0 + elif pages < 200: + # Tier 4: Short books (100-200 pages) → 28-35px + # 100 pages = 28px, 150 pages = 31.5px, 200 pages = 35px + rslt = 28.0 + ((pages - 100) / 100.0) * 7.0 + elif pages < 350: + # Tier 5: Medium books (200-350 pages) → 35-43px + # 200 pages = 35px, 275 pages = 39px, 350 pages = 43px + rslt = 35.0 + ((pages - 200) / 150.0) * 8.0 + elif pages < 500: + # Tier 6: Long books (350-500 pages) → 43-50px + # 350 pages = 43px, 425 pages = 46.5px, 500 pages = 50px + rslt = 43.0 + ((pages - 350) / 150.0) * 7.0 + elif pages < 750: + # Tier 7: Very long books (500-750 pages) → 50-56px + # 500 pages = 50px, 625 pages = 53px, 750 pages = 56px + rslt = 50.0 + ((pages - 500) / 250.0) * 6.0 + else: + # Tier 8: Epic books (750+ pages) → 56-60px + # 750 pages = 56px, 1000 pages = 58px, 1500+ pages = 60px + rslt = 56.0 + (pages - 750) / 187.5 # ~1px per 187 pages, max 4px + + # Ensure result is within bounds + rslt = max(self.SPINE_WIDTH_MIN, min(self.SPINE_WIDTH_MAX, round(rslt))) + + self._pages_widths[round(pages)] = rslt + return rslt + + def _get_height_modifier(self, book_id: int) -> int: + ''' + Return a pseudo random number, to change the height of the spine. + Range: [-10, 10] + ''' + if not self._enable_variable_height: + return 0 + rslt = self._height_modifiers.get(book_id) + if rslt: + return rslt + rslt = 10 - pseudo_random(book_id, 20) + self._height_modifiers[book_id] = rslt + return rslt # Sort interface methods (required for SortByAction integration) - def sort_by_named_field(self, field, order, reset=True): + def sort_by_named_field(self, field: str, order: bool | Qt.SortOrder, reset=True): '''Sort by a named field.''' - if self._model is None: - return if isinstance(order, Qt.SortOrder): order = order == Qt.SortOrder.AscendingOrder self._model.sort_by_named_field(field, order, reset) - self.viewport().update() + self.update_viewport() def reverse_sort(self): '''Reverse the current sort order.''' - if self._model is None: - return m = self.model() try: sort_col, order = m.sorted_on @@ -2204,15 +1798,11 @@ class BookshelfView(QAbstractScrollArea): # {{{ def resort(self): '''Re-apply the current sort.''' - if self._model is None: - return self._model.resort(reset=True) - self.viewport().update() + self.update_viewport() - def intelligent_sort(self, field, ascending): + def intelligent_sort(self, field: str, ascending: bool | Qt.SortOrder): '''Smart sort that toggles if already sorted on that field.''' - if self._model is None: - return if isinstance(ascending, Qt.SortOrder): ascending = ascending == Qt.SortOrder.AscendingOrder m = self.model() @@ -2232,53 +1822,50 @@ class BookshelfView(QAbstractScrollArea): # {{{ gprefs[pname] = previous self.sort_by_named_field(field, previous.get(field, True)) - def multisort(self, fields, reset=True, only_if_different=False): + def multisort(self, fields: Iterable[str], reset=True, only_if_different=False): '''Sort on multiple columns.''' - if self._model is None or len(fields) == 0: + if not len(fields): return # Delegate to model's multisort capability # This is a simplified version - full implementation would match BooksView for field, ascending in reversed(fields): - if field in list(self._model.db.field_metadata.keys()): + if field in self.dbref().field_metadata.keys(): self.sort_by_named_field(field, ascending, reset=reset) reset = False # Only reset on first sort # Selection methods (required for AlternateViews integration) - def set_current_row(self, row): + def set_current_row(self, row: int): '''Set the current row.''' - if self._model is None or self._selection_model is None: + if not self._selection_model: return if 0 <= row < self._model.rowCount(QModelIndex()): self._current_row = row index = self._model.index(row, 0) if index.isValid(): self._selection_model.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.NoUpdate) - self.viewport().update() + self.update_viewport() # Scroll to make row visible self._scroll_to_row(row) - def select_rows(self, rows, using_ids=False): + def select_rows(self, rows: Iterable[int], using_ids=False): '''Select the specified rows. Args: rows: List of row indices or book IDs using_ids: If True, rows contains book IDs; if False, rows contains row indices ''' - if self._model is None or self._selection_model is None: + if not self._selection_model: return # Convert book IDs to row indices if needed if using_ids: row_indices = [] for book_id in rows: - try: - row = self._model.db.data.id_to_index(book_id) - if row >= 0: - row_indices.append(row) - except (AttributeError, KeyError, ValueError, TypeError): - pass + row = self._model.db.data.id_to_index(book_id) + if row >= 0: + row_indices.append(row) rows = row_indices self._selected_rows = set(rows) @@ -2298,235 +1885,135 @@ class BookshelfView(QAbstractScrollArea): # {{{ self._selection_model.setCurrentIndex(current_index, QItemSelectionModel.SelectionFlag.NoUpdate) else: self._current_row = -1 - self.viewport().update() + self.update_viewport() - def _scroll_to_row(self, row): + def _scroll_to_row(self, row: int): '''Scroll to make the specified row visible.''' - # Simplified scrolling - will improve in Phase 2 - # For now, just update viewport - self.viewport().update() + for shelf in self._current_shelf_layouts: + if row in shelf.rows: + scroll_y = shelf.start_y - self.viewport().rect().height() // 2 + self.verticalScrollBar().setValue(scroll_y) + self.update_viewport() # Database methods - def set_database(self, db, stage=0): + def set_database(self, newdb, stage=0): '''Set the database.''' - self.dbref = lambda: db - # Ensure connection to main view selection is established - # (in case it wasn't ready when setModel was called) - self._connect_to_main_view_selection() - if stage == 0 and self._model is not None: - # Model will be updated by AlternateViews + self._grouping_mode = newdb.new_api.pref('bookshelf_grouping_mode', 'none') + if stage == 0: # Clear caches when database changes - self._cover_colors.clear() - self._spine_thumbnails.clear() - invalidate_caches() - # Force repaint to reload colors - self.viewport().update() + self.color_cache.clear() + self.template_inited = False + with suppress(AttributeError): + self.model().db.new_api.remove_cover_cache(self.thumbnail_cache) + newdb.new_api.add_cover_cache(self.thumbnail_cache) + # This must be done here so the UUID in the cache is changed when libraries are switched. + self.thumbnail_cache.set_database(newdb) + try: + # Use a timeout so that if, for some reason, the render thread + # gets stuck, we don't deadlock, future covers won't get + # rendered, but this is better than a deadlock + join_with_timeout(self.render_queue) + except RuntimeError: + print('Cover rendering thread is stuck!') - def refresh_colors(self): - '''Force refresh of all spine colors.''' - self._cover_colors.clear() - invalidate_caches() - self.viewport().update() - - def shown(self): - '''Called when this view becomes active.''' - self.setFocus(Qt.FocusReason.OtherFocusReason) - self.viewport().update() - - def set_context_menu(self, menu): + def set_context_menu(self, menu: QMenu): '''Set the context menu.''' self.context_menu = menu - def contextMenuEvent(self, event): + def contextMenuEvent(self, ev: QContextMenuEvent): '''Handle context menu events.''' - from calibre.constants import islinux - from calibre.gui2.main_window import clone_menu - from qt.core import QMenu - # Create menu with grouping options m = QMenu(self) # Add grouping submenu grouping_menu = m.addMenu(_('Group by')) - # Group by None - none_action = grouping_menu.addAction(_('None')) - none_action.setCheckable(True) - none_action.setChecked(self._grouping_mode == 'none') - none_action.triggered.connect(lambda: self._set_grouping_mode('none')) + for k, v in GROUPINGS.items(): + if not v: + grouping_menu.addSeparator() + continue - grouping_menu.addSeparator() - - # Group by Author - author_action = grouping_menu.addAction(_('Author')) - author_action.setCheckable(True) - author_action.setChecked(self._grouping_mode == 'author') - author_action.triggered.connect(lambda: self._set_grouping_mode('author')) - - # Group by Series - series_action = grouping_menu.addAction(_('Series')) - series_action.setCheckable(True) - series_action.setChecked(self._grouping_mode == 'series') - series_action.triggered.connect(lambda: self._set_grouping_mode('series')) - - # Group by Genre - genre_action = grouping_menu.addAction(_('Genre')) - genre_action.setCheckable(True) - genre_action.setChecked(self._grouping_mode == 'genre') - genre_action.triggered.connect(lambda: self._set_grouping_mode('genre')) - - # Group by Publisher - publisher_action = grouping_menu.addAction(_('Publisher')) - publisher_action.setCheckable(True) - publisher_action.setChecked(self._grouping_mode == 'publisher') - publisher_action.triggered.connect(lambda: self._set_grouping_mode('publisher')) - - # Group by Rating - rating_action = grouping_menu.addAction(_('Rating')) - rating_action.setCheckable(True) - rating_action.setChecked(self._grouping_mode == 'rating') - rating_action.triggered.connect(lambda: self._set_grouping_mode('rating')) - - # Group by Language - language_action = grouping_menu.addAction(_('Language')) - language_action.setCheckable(True) - language_action.setChecked(self._grouping_mode == 'language') - language_action.triggered.connect(lambda: self._set_grouping_mode('language')) - - # Group by Time Period - time_action = grouping_menu.addAction(_('Time Period')) - time_action.setCheckable(True) - time_action.setChecked(self._grouping_mode == 'time_period') - time_action.triggered.connect(lambda: self._set_grouping_mode('time_period')) + action = grouping_menu.addAction(v['name']) + action.setCheckable(True) + action.setChecked(self._grouping_mode == k) + action.triggered.connect(partial(self._set_grouping_mode, k)) # Add standard context menu items if available - if self.context_menu is not None: + if self.context_menu: m.addSeparator() # Clone actions to avoid issues with menu ownership for action in self.context_menu.actions(): m.addAction(action) - # Show menu - if islinux: - m = clone_menu(m) - m.popup(event.globalPos()) - event.accept() + m.popup(ev.globalPos()) + ev.accept() - def _set_grouping_mode(self, mode): + def _set_grouping_mode(self, mode: str): '''Set the grouping mode and refresh display.''' self._grouping_mode = mode - gprefs['bookshelf_grouping_mode'] = mode - self._update_scrollbar_ranges() - self.viewport().update() + self.dbref().set_pref('bookshelf_grouping_mode', mode) + self._update_current_shelf_layouts() - def get_selected_ids(self): + def get_selected_ids(self) -> list[int]: '''Get selected book IDs.''' - if self._model is None: - return [] - return [self._model.id(self._model.index(row, 0)) for row in self._selected_rows] + return [self.book_id_from_row(r) for r in self._selected_rows] - def current_book_state(self): + def current_book_state(self) -> int: '''Get current book state for restoration.''' - if self._current_row >= 0 and self._model is not None: - try: - return self._model.id(self._model.index(self._current_row, 0)) - except (IndexError, ValueError, KeyError, TypeError, AttributeError): - pass + if self._current_row >= 0 and self._model: + return self.book_id_from_row(self._current_row) return None - def restore_current_book_state(self, state): + def restore_current_book_state(self, state: int): '''Restore current book state.''' - if state is None or self._model is None: + if not state: return book_id = state - try: - row = self._model.db.data.id_to_index(book_id) - self.set_current_row(row) - self.select_rows([row]) - except (IndexError, ValueError, KeyError, TypeError, AttributeError): - pass + row = self._model.db.data.id_to_index(book_id) + self.set_current_row(row) + self.select_rows([row]) - def marked_changed(self, old_marked, current_marked): + def marked_changed(self, old_marked: set[int], current_marked: set[int]): '''Handle marked books changes.''' # Refresh display if marked books changed - self.viewport().update() + self.update_viewport() def indices_for_merge(self, resolved=True): '''Get indices for merge operations.''' - if self._model is None: - return [] return [self._model.index(row, 0) for row in self._selected_rows] # Mouse and keyboard events - def viewportEvent(self, event): + def viewportEvent(self, ev: QEvent) -> bool: '''Handle viewport events - this is where mouse events on QAbstractScrollArea go.''' - if event.type() == QEvent.Type.MouseButtonPress: - handled = self._handle_mouse_press(event) + if ev.type() == QEvent.Type.MouseButtonPress: + handled = self._handle_mouse_press(ev) if handled: return True - elif event.type() == QEvent.Type.MouseButtonDblClick: - handled = self._handle_mouse_double_click(event) + elif ev.type() == QEvent.Type.MouseButtonDblClick: + handled = self._handle_mouse_double_click(ev) if handled: return True - elif event.type() == QEvent.Type.MouseMove: - # Handle hover detection - self._handle_mouse_move(event) - elif event.type() == QEvent.Type.Leave: - # Clear hover when mouse leaves viewport - if self._hovered_row >= 0: - self._hovered_row = -1 - self._hover_cover_pixmap = None - self._hover_cover_row = -1 - self._hover_cover_opacity = 1.0 - self._hover_shift_progress = 0.0 - self._hover_fade_timer.stop() - self._hover_fade_start_time = None - self.viewport().update() - return super().viewportEvent(event) + elif ev.type() == QEvent.Type.MouseMove: + self._handle_mouse_move(ev) + elif ev.type() == QEvent.Type.Leave: + self._handle_mouse_leave(ev) + return super().viewportEvent(ev) - def _handle_mouse_move(self, ev): + def _handle_mouse_move(self, ev: QEvent): '''Handle mouse move events for hover detection.''' - if self._model is None: - return - pos = ev.pos() row = self._book_at_position(pos.x(), pos.y()) + if row != self._hovered.row: + # Hover changed + self._hover_buffer_row = row + self._hover_buffer_time = time() + if not self._hover_buffer_timer.isActive(): + self._hover_buffer_timer.start(10) - if row != self._hovered_row: - # Hover changed - clear old cover first - old_hovered = self._hovered_row - self._hovered_row = row - - # Clear old hover cover when changing hover - self._hover_cover_pixmap = None - self._hover_cover_row = -1 - self._hover_cover_opacity = 1.0 - self._hover_shift_progress = 0.0 - self._hover_fade_timer.stop() - self._hover_fade_start_time = None - - # Load hover cover if hovering over a book - if row >= 0: - try: - index = self._model.index(row, 0) - if index.isValid(): - book_id = self._model.id(index) - db = self._model.db - self._load_hover_cover(book_id, db) - except (AttributeError, IndexError, KeyError, TypeError): - pass - - # Update viewport to show/hide hover cover - if old_hovered != row: - self.viewport().update() - - def _handle_mouse_press(self, ev): + def _handle_mouse_press(self, ev: QEvent) -> bool: '''Handle mouse press events on the viewport.''' - if self._model is None: - return False - # Get position in viewport coordinates pos = ev.pos() @@ -2584,263 +2071,122 @@ class BookshelfView(QAbstractScrollArea): # {{{ # Sync selection with main library view self._sync_selection_to_main_view() - self.viewport().update() + self.update_viewport() ev.accept() return True # No book was clicked return False - def _handle_mouse_double_click(self, ev): + def _handle_mouse_double_click(self, ev: QEvent) -> bool: '''Handle mouse double-click events on the viewport.''' - if self._model is None: - return False - pos = ev.pos() row = self._book_at_position(pos.x(), pos.y()) if row >= 0: # Set as current row first self._current_row = row # Open the book - try: - from calibre.gui2.ui import get_gui - book_id = self._model.id(self._model.index(row, 0)) - gui = get_gui() - if gui: - gui.iactions['View'].view_triggered(book_id) - return True - except (IndexError, ValueError, KeyError, TypeError, AttributeError): - pass + book_id = self.book_id_from_row(row) + self.gui.iactions['View'].view_triggered(book_id) + return True return False - def _connect_to_main_view_selection(self): - '''Connect to the main library view's selection model to sync selection.''' - if self.gui is None or not hasattr(self.gui, 'library_view'): - return - - try: - library_view = self.gui.library_view - if library_view is None: - return - - selection_model = library_view.selectionModel() - if selection_model is not None: - # Disconnect any existing connections to avoid duplicates - try: - selection_model.currentChanged.disconnect(self._main_current_changed) - except (TypeError, RuntimeError): - pass - try: - selection_model.selectionChanged.disconnect(self._main_selection_changed) - except (TypeError, RuntimeError): - pass - - # Connect to selection changes from main view - selection_model.currentChanged.connect(self._main_current_changed) - selection_model.selectionChanged.connect(self._main_selection_changed) - except (AttributeError, TypeError): - pass + def _handle_mouse_leave(self, ev: QEvent): + '''Handle mouse leave events on the viewport.''' + # Clear hover when mouse leaves viewport + self._hover_fade_timer.stop() + self._hover_buffer_timer.stop() + self._hovered = HoveredCover() + self.update_viewport() def _main_current_changed(self, current, previous): '''Handle current row change from main library view.''' - if self._syncing_from_main or self._model is None: + if self._syncing_from_main: return - try: - if current.isValid(): - row = current.row() - if 0 <= row < self._model.rowCount(QModelIndex()): - self._syncing_from_main = True - self.set_current_row(row) - self._syncing_from_main = False - else: + if current.isValid(): + row = current.row() + if 0 <= row < self._model.rowCount(QModelIndex()): self._syncing_from_main = True - self._current_row = -1 - self.viewport().update() + self.set_current_row(row) self._syncing_from_main = False - except (IndexError, ValueError, AttributeError, TypeError): + else: + self._syncing_from_main = True + self._current_row = -1 + self.update_viewport() self._syncing_from_main = False def _main_selection_changed(self, selected, deselected): '''Handle selection change from main library view.''' - if self._syncing_from_main or self._model is None: + if self._syncing_from_main: return - try: - library_view = self.gui.library_view - if library_view is None: - return + library_view = self.gui.library_view + if not library_view: + return - # Get selected rows from main view - selected_indexes = library_view.selectionModel().selectedIndexes() - rows = {idx.row() for idx in selected_indexes if idx.isValid()} + # Get selected rows from main view + selected_indexes = library_view.selectionModel().selectedIndexes() + rows = {idx.row() for idx in selected_indexes if idx.isValid()} - self._syncing_from_main = True - self.select_rows(list(rows), using_ids=False) - self._syncing_from_main = False - except (AttributeError, TypeError, ValueError): - self._syncing_from_main = False + self._syncing_from_main = True + self.select_rows(list(rows), using_ids=False) + self._syncing_from_main = False def _sync_selection_to_main_view(self): '''Sync selection with the main library view.''' - if self._syncing_from_main or self.gui is None or not hasattr(self.gui, 'library_view'): + if self._syncing_from_main or not self.gui: return - try: - library_view = self.gui.library_view - if self._current_row >= 0 and self._model is not None: - # Get book ID from current row - book_id = self._model.id(self._model.index(self._current_row, 0)) - # Select in library view - library_view.select_rows([book_id], using_ids=True) - except (IndexError, ValueError, KeyError, TypeError, AttributeError): - pass - def mouseDoubleClickEvent(self, ev): - '''Handle double-click to open book.''' - # Double-click is handled in viewportEvent - super().mouseDoubleClickEvent(ev) - - def _book_at_position(self, x, y): - '''Find which book is at the given position. - x, y are in viewport coordinates. - Returns row or -1.''' - if self._model is None: - return -1 - - row_count = self._model.rowCount(QModelIndex()) - if row_count == 0: - return -1 + library_view = self.gui.library_view + if self._current_row >= 0 and self._model: + # Get book ID from current row + book_id = self.book_id_from_row(self._current_row) + # Select in library view + library_view.select_rows([book_id], using_ids=True) + def _book_at_position(self, x: int, y: int) -> int: + ''' + Find which book is at the given position. x, y are in viewport coordinates. + ''' # Convert viewport coordinates to content coordinates scroll_y = self.verticalScrollBar().value() content_y = y + scroll_y - # Use same layout logic as paintEvent (flattened items with grouping) - all_rows = list(range(row_count)) - groups = group_books(all_rows, self._model, self._grouping_mode) + if self._hovered.is_valid(): + if self._hovered.rect().contains(x, content_y): + return self._hovered.row - # Flatten groups into a list of (row, group_name) tuples - flattened_items = [] - for group_name, group_rows in groups: - for row in group_rows: - flattened_items.append((row, group_name)) - - # Use same shelf layout calculation as paintEvent - shelf_layouts = self._calculate_shelf_layouts(flattened_items, self.viewport().width()) - - # Pre-calculate hover shift amount (same as paintEvent) - hover_shift_amount = 0 - hover_spine_width = 0 - if self._hovered_row >= 0: - try: - hover_index = self._model.index(self._hovered_row, 0) - if hover_index.isValid(): - hover_book_id = self._model.id(hover_index) - db = self._model.db - hover_mi = db.get_metadata(hover_book_id, index_is_id=True) - hover_pages = self._get_page_count(hover_book_id, db, hover_mi) - hover_spine_width = self._calculate_spine_width(hover_pages) - hover_shift_amount = max(0, self.HOVER_EXPANDED_WIDTH - hover_spine_width) * self._hover_shift_progress - except (AttributeError, IndexError, KeyError, TypeError): - pass - - shelf_y = 10 - - for shelf_items in shelf_layouts: - # Get starting x position for this shelf (centered) - base_x_pos = shelf_items['start_x'] - shelf_start_row = shelf_items['start_row'] - last_group_name = None - - for row, group_name in shelf_items['items']: - # Account for inline divider when group changes - if self._grouping_mode != 'none' and group_name != last_group_name and last_group_name is not None: - base_x_pos += 32 # DIVIDER_WIDTH (30) + gap (2) - last_group_name = group_name - - # Calculate spine width from page count - try: - index = self._model.index(row, 0) - if index.isValid(): - book_id = self._model.id(index) - db = self._model.db - mi = db.get_metadata(book_id, index_is_id=True) - pages = self._get_page_count(book_id, db, mi) - spine_width = self._calculate_spine_width(pages) - else: - spine_width = 40 - except (AttributeError, IndexError, KeyError, TypeError): - spine_width = 40 - - # Check if this book is hovered - is_hovered = row == self._hovered_row - - # Calculate display position and width (same as paintEvent with cumulative positioning) - if is_hovered: - # Hovered book: use expanded width at base position - display_width = self.HOVER_EXPANDED_WIDTH - current_x = base_x_pos - width_to_add = self.HOVER_EXPANDED_WIDTH - else: - # Non-hovered book: use spine width at base position - display_width = int(spine_width) - current_x = base_x_pos - width_to_add = spine_width - - spine_rect = QRect(int(current_x), shelf_y, display_width, self.SPINE_HEIGHT) - - # Check if point is within this spine - if spine_rect.contains(x, content_y): - return row - - # Update base_x_pos for next book - use actual width (expanded for hovered, spine for others) - base_x_pos += int(width_to_add) + 2 - - # Move to next shelf - shelf_y += self.SHELF_SPACING - - # Early exit if we've scrolled past the point - if shelf_y > content_y + self.SPINE_HEIGHT: + for shelf in self._current_shelf_layouts: + if content_y < shelf.start_y: + continue + if shelf.start_y >= content_y + self.SPINE_HEIGHT: break + for item in shelf.items: + if not isinstance(item, SpineTuple): + continue + spine_rect = QRect(item.start_x, item.start_y, item.width, self.SPINE_HEIGHT) + if spine_rect.contains(x, content_y): + return item.row return -1 - def indexAt(self, pos): + def indexAt(self, pos) -> QModelIndex: '''Return the model index at the given position (required for drag/drop). pos is a QPoint in viewport coordinates.''' row = self._book_at_position(pos.x(), pos.y()) - if row >= 0 and self._model is not None: + if row >= 0 and self._model: return self._model.index(row, 0) return QModelIndex() - def currentIndex(self): + def currentIndex(self) -> QModelIndex: '''Return the current model index (required for drag/drop).''' - if self._current_row >= 0 and self._model is not None: + if self._current_row >= 0 and self._model: return self._model.index(self._current_row, 0) return QModelIndex() - def handle_mouse_press_event(self, ev): - '''Handle mouse press events (called by setup_dnd_interface).''' - # This is called by the setup_dnd_interface mousePressEvent - # Our actual mousePressEvent already handles selection, so this can be a no-op - # or we can delegate to it - pass - - def handle_mouse_move_event(self, ev): - '''Handle mouse move events (called by setup_dnd_interface).''' - # Phase 2: Handle hover detection - # Delegate to _handle_mouse_move to avoid code duplication - self._handle_mouse_move(ev) - - def handle_mouse_release_event(self, ev): - '''Handle mouse release events (called by setup_dnd_interface).''' - # This is called by the setup_dnd_interface mouseReleaseEvent - # We don't need special handling for mouse release - pass - - def wheelEvent(self, ev): + def wheelEvent(self, ev: QWheelEvent): '''Handle wheel events for scrolling.''' - from calibre.constants import islinux number_of_pixels = ev.pixelDelta() number_of_degrees = ev.angleDelta() / 8.0 b = self.verticalScrollBar() @@ -2852,4 +2198,14 @@ class BookshelfView(QAbstractScrollArea): # {{{ if abs(dy) > 0: b.setValue(b.value() - dy) ev.accept() -# }}} + + # setup_dnd_interface + # handled in viewportEvent() + def handle_mouse_move_event(self, ev: QEvent): + pass + + def handle_mouse_press_event(self, ev: QEvent): + pass + + def handle_mouse_release_event(self, ev: QEvent): + pass diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 64f62ed139..5a00147b82 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -1266,6 +1266,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ _('Running database shutdown plugins. This could take a few seconds...')) self.grid_view.shutdown() + self.bookshelf_view.shutdown() db = None try: db = self.library_view.model().db