From cd24e5d9b99697c06f7a0a72fabbdf261358c303 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 21 Jan 2026 22:55:35 +0530 Subject: [PATCH] Bookshelf: Allow specifying the font to use for spines --- src/calibre/gui2/__init__.py | 2 +- src/calibre/gui2/library/bookshelf_view.py | 42 ++-- .../look_feel_tabs/bookshelf_view.py | 43 +++- .../look_feel_tabs/bookshelf_view.ui | 123 +++++----- .../look_feel_tabs/font_selection_dialog.py | 212 ++++++++++++++++++ 5 files changed, 348 insertions(+), 74 deletions(-) create mode 100644 src/calibre/gui2/preferences/look_feel_tabs/font_selection_dialog.py diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 96368ac570..3a3e590647 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -523,7 +523,7 @@ def create_defs(): defs['bookshelf_min_font_multiplier'] = 0.75 defs['bookshelf_max_font_multiplier'] = 1.3 defs['bookshelf_outline_width'] = 0 - defs['bookshelf_bold_font'] = True + defs['bookshelf_font'] = {'family': None, 'style': None} defs['bookshelf_use_custom_colors'] = False defs['bookshelf_custom_colors'] = { 'light': {}, 'dark': {}, diff --git a/src/calibre/gui2/library/bookshelf_view.py b/src/calibre/gui2/library/bookshelf_view.py index 561d30dc85..435c64f378 100644 --- a/src/calibre/gui2/library/bookshelf_view.py +++ b/src/calibre/gui2/library/bookshelf_view.py @@ -27,6 +27,7 @@ from qt.core import ( QEasingCurve, QEvent, QFont, + QFontDatabase, QFontInfo, QFontMetricsF, QIcon, @@ -1482,7 +1483,10 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): self.theme: ColorTheme = None self.palette_changed() - self.base_font_size_pts = QFontInfo(self.font()).pointSizeF() + self.spine_font = self.default_spine_font = QFont(self.font()) + self.spine_font.setBold(True) + self.divider_font = QFont(self.spine_font) + self.base_font_size_pts = QFontInfo(self.spine_font).pointSizeF() self.outline_width = 0 self.min_line_height = self.base_font_size_pts * 1.2 @@ -1632,9 +1636,17 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): def refresh_settings(self): '''Refresh the gui and render settings.''' self.template_inited = False + s = gprefs['bookshelf_font'] + if s and s.get('family'): + self.spine_font = QFontDatabase.font(s['family'], s['style'], int(self.base_font_size_pts)) + self.spine_font.setPointSizeF(self.base_font_size_pts) + else: + self.spine_font = self.default_spine_font + self.get_sized_font.cache_clear() + self.get_text_metrics.cache_clear() self.min_font_size = max(0.1, min(gprefs['bookshelf_min_font_multiplier'], 1)) * self.base_font_size_pts self.max_font_size = max(1, min(gprefs['bookshelf_max_font_multiplier'], 3)) * self.base_font_size_pts - _, fm, _ = self.get_sized_font(self.min_font_size, bold=gprefs['bookshelf_bold_font']) + _, fm, _ = self.get_sized_font(self.min_font_size) self.outline_width = float(max(0, min(gprefs['bookshelf_outline_width'], 5))) self.min_line_height = math.ceil(fm.height() + self.outline_width * 2) self.calculate_shelf_geometry() @@ -1973,25 +1985,24 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): painter.restore() @lru_cache(maxsize=128) - def get_sized_font(self, sz: float = 9, bold: bool = False) -> tuple[QFont, QFontMetricsF, QFontInfo]: - font = QFont(self.font()) + def get_sized_font(self, sz: float = 9, for_divider: bool = False) -> tuple[QFont, QFontMetricsF, QFontInfo]: + font = QFont(self.divider_font if for_divider else self.spine_font) font.setPointSizeF(sz) - font.setBold(bold) return font, QFontMetricsF(font), QFontInfo(font) @lru_cache(maxsize=4096) def get_text_metrics( - self, first_line: str, second_line: str = '', sz: QSize = QSize(), bold: bool = False, allow_wrap: bool = False, - outline_width: float = 0, + self, first_line: str, second_line: str = '', sz: QSize = QSize(), allow_wrap: bool = False, + outline_width: float = 0, for_divider: bool = False, ) -> tuple[str, str, QFont, QFontMetricsF, bool]: width, height = sz.width(), sz.height() - font, fm, fi = self.get_sized_font(self.base_font_size_pts, bold) + font, fm, fi = self.get_sized_font(self.base_font_size_pts, for_divider=for_divider) extra_height = outline_width * 2 # stroke width above and below if allow_wrap and not second_line and first_line and fm.boundingRect(first_line).width() > width and height >= 2 * self.min_line_height: # rather than reducing font size if there is available space, wrap to two lines font2, fm2, fi2 = font, fm, fi while math.ceil(2 * (fm2.height() + extra_height)) > height: - font2, fm2, fi2 = self.get_sized_font(font2.pointSizeF() - 0.5, bold) + font2, fm2, fi2 = self.get_sized_font(font2.pointSizeF() - 0.5) if fm2.boundingRect(first_line).width() >= width: # two line font size is larger than one line font size font, fm, fi = font2, fm2, fi2 has_third_line = False @@ -2015,7 +2026,7 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): # larger font sizes if math.ceil(fm.height() + extra_height) < height: while font.pointSizeF() < self.max_font_size: - q, qm, qi = self.get_sized_font(font.pointSizeF() + 1, bold) + q, qm, qi = self.get_sized_font(font.pointSizeF() + 1) if math.ceil(qm.height() + extra_height) < height: font, fm = q, qm else: @@ -2025,14 +2036,14 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): nsz = font.pointSizeF() if nsz < self.min_font_size and second_line: return '', '', font, fm, False - font, fm, fi = self.get_sized_font(nsz - 0.5, bold) + font, fm, fi = self.get_sized_font(nsz - 0.5) # Now reduce the font size as much as needed to fit within width text = first_line if second_line and fm.boundingRect(first_line).width() < fm.boundingRect(second_line).width(): text = second_line while fi.pointSizeF() > self.min_font_size and fm.boundingRect(text).width() > width: - font, fm, fi = self.get_sized_font(font.pointSizeF() - 1, bold) + font, fm, fi = self.get_sized_font(font.pointSizeF() - 1) if fi.pointSizeF() <= self.min_font_size: first_line = fm.elidedText(first_line, Qt.TextElideMode.ElideRight, width) if second_line: @@ -2069,7 +2080,7 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): -self.TEXT_MARGIN if text_right else 0, 0, ) - elided_text, _, font, _, _ = self.get_text_metrics(divider.group_name, '', text_rect.size(), bold=True) + elided_text, _, font, _, _ = self.get_text_metrics(divider.group_name, '', text_rect.size(), for_divider=True) painter.save() painter.setFont(font) painter.setPen(self.theme.divider_text_color) @@ -2200,13 +2211,12 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea): nfl, nsl, font, fm, was_wrapped = self.get_text_metrics( first_line, second_line, first_rect.transposed().size(), allow_wrap=True, - bold=gprefs['bookshelf_bold_font'], outline_width=self.outline_width) + outline_width=self.outline_width) if not nfl and not nsl: # two lines dont fit second_line = '' first_rect = QRect(rect.left(), first_rect.top(), rect.width(), first_rect.height()) nfl, nsl, font, fm, _ = self.get_text_metrics( - first_line, second_line, first_rect.transposed().size(), bold=gprefs['bookshelf_bold_font'], - outline_width=self.outline_width) + first_line, second_line, first_rect.transposed().size(), outline_width=self.outline_width) elif was_wrapped: first_rect, second_rect = calculate_rects(True) first_line, second_line, = nfl, nsl diff --git a/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.py b/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.py index db7527b2ad..74cb18f241 100644 --- a/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.py +++ b/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.py @@ -9,7 +9,7 @@ import os from contextlib import suppress from functools import lru_cache, partial -from qt.core import QApplication, QDialog, QDialogButtonBox, QIcon, QInputDialog, QLabel, Qt, QTabWidget, QTextBrowser, QTimer, QVBoxLayout, pyqtSignal +from qt.core import QDialog, QDialogButtonBox, QIcon, QInputDialog, QLabel, Qt, QTabWidget, QTextBrowser, QTimer, QVBoxLayout, pyqtSignal from calibre.gui2 import gprefs from calibre.gui2.dialogs.confirm_delete import confirm @@ -56,8 +56,29 @@ class BookshelfTab(QTabWidget, LazyConfigWidgetBase, Ui_Form): recount_updated = pyqtSignal(object) def __init__(self, parent=None): + self.current_font_choice = {} super().__init__(parent) + def restore_defaults(self): + super().restore_defaults() + self.current_font_choice = gprefs.defaults['bookshelf_font'].copy() + self.update_font_display() + self.populate_custom_color_theme(use_defaults=True) + + def update_font_display(self): + text = '' + s = self.current_font_choice + if s.get('family'): + text = s['family'] + ' - ' + (s.get('style') or '') + self.bookshelf_font_display.setText(text) + + def initialize(self): + super().initialize() + s = gprefs['bookshelf_font'] or {} + self.current_font_choice = s.copy() + self.update_font_display() + self.populate_custom_color_theme() + def commit(self, *args): import re tp = self.opt_bookshelf_spine_size_template.text() @@ -72,6 +93,7 @@ class BookshelfTab(QTabWidget, LazyConfigWidgetBase, Ui_Form): for k,b in v.items(): d[k] = b.color gprefs['bookshelf_custom_colors'] = newval + gprefs['bookshelf_font'] = self.current_font_choice.copy() return super().commit(*args) def genesis(self, gui): @@ -87,7 +109,6 @@ class BookshelfTab(QTabWidget, LazyConfigWidgetBase, Ui_Form): r('bookshelf_make_space_for_second_line', gprefs) r('bookshelf_min_font_multiplier', gprefs) r('bookshelf_max_font_multiplier', gprefs) - r('bookshelf_bold_font', gprefs) r('bookshelf_outline_width', gprefs) r('bookshelf_divider_text_right', gprefs) @@ -178,8 +199,18 @@ different calibre library you use.''').format('{size}', '{random}', '{pages}')) l.setBuddy(b) layout.insertRow(r, b, l) b.color_changed.connect(self.changed_signal) - QApplication.instance().palette_changed.connect(self.populate_custom_color_theme) - self.populate_custom_color_theme() + self.change_font_button.clicked.connect(self.change_font) + + def change_font(self): + from calibre.gui2.preferences.look_feel_tabs.font_selection_dialog import FontSelectionDialog + s = self.current_font_choice + + d = FontSelectionDialog(family=s.get('family') or '', style=s.get('style') or '', parent=self) + if d.exec() == QDialog.DialogCode.Accepted: + family, style = d.selected_font() + self.current_font_choice = {'family': family, 'style': style} + self.update_font_display() + self.changed_signal.emit() def lazy_initialize(self): self.recount_timer.start() @@ -273,13 +304,13 @@ def evaluate(book, context): 'current_selected_color': _('&The current and selected book highlight'), } - def populate_custom_color_theme(self): + def populate_custom_color_theme(self, use_defaults=False): from calibre.gui2.library.bookshelf_view import ColorTheme default = { 'light': ColorTheme.light_theme()._asdict(), 'dark': ColorTheme.dark_theme()._asdict(), } - configs = gprefs['bookshelf_custom_colors'] + configs = (gprefs.defaults if use_defaults else gprefs)['bookshelf_custom_colors'] for theme in default: for k in self.color_label_map(): b = self.color_buttons[theme][k] diff --git a/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.ui b/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.ui index 44a61d698a..0d064008b1 100644 --- a/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.ui +++ b/src/calibre/gui2/preferences/look_feel_tabs/bookshelf_view.ui @@ -7,7 +7,7 @@ 0 0 626 - 520 + 521 @@ -377,6 +377,26 @@ + + + + &Font: + + + bookshelf_font_display + + + + + + + &Font size: + + + opt_bookshelf_min_font_multiplier + + + @@ -434,6 +454,38 @@ + + + + &Outline + + + opt_bookshelf_outline_width + + + + + + + <p>Set the thickness, in pixels, of the outline (border) around text on the spine. A value of zero means no outline. An outline helps the text standout against the spine, especially when the spine has a colorful cover. + + + none + + + px + + + 1 + + + 3.000000000000000 + + + 0.100000000000000 + + + @@ -458,16 +510,6 @@ - - - - <p>Use a bold font to draw text on the book spine - - - Use &bold font - - - @@ -483,47 +525,26 @@ - - - - &Font size: - - - opt_bookshelf_min_font_multiplier - - - - - - - Text &outline: - - - opt_bookshelf_outline_width - - - - - - <p>Set the thickness, in pixels, of the outline (border) around text on the spine. A value of zero means no outline. An outline helps the text standout against the spine, especially when the spine has a colorful cover. - - - none - - - px - - - 1 - - - 3.000000000000000 - - - 0.100000000000000 - - + + + + + true + + + Use default font + + + + + + + Change &font + + + + diff --git a/src/calibre/gui2/preferences/look_feel_tabs/font_selection_dialog.py b/src/calibre/gui2/preferences/look_feel_tabs/font_selection_dialog.py new file mode 100644 index 0000000000..c29ab74302 --- /dev/null +++ b/src/calibre/gui2/preferences/look_feel_tabs/font_selection_dialog.py @@ -0,0 +1,212 @@ +from qt.core import QDialog, QDialogButtonBox, QFontDatabase, QFontInfo, QHBoxLayout, QLabel, QListWidget, QScrollArea, Qt, QVBoxLayout, QWidget, pyqtSignal + + +class FontSelectionDialog(QDialog): + fontSelected = pyqtSignal(str, str) # family, style + + def __init__(self, family: str = '', style: str = '', min_size=8, medium_size=12, max_size=24, parent=None): + super().__init__(parent) + if family: + self.initial_family, self.initial_style = family, style + else: + font = self.font() + fi = QFontInfo(font) + self.initial_family = fi.family() + self.initial_style = fi.styleName() + self.min_size = min_size + self.medium_size = medium_size + self.max_size = max_size + + self.setWindowTitle(_('Select font')) + self.setMinimumSize(600, 500) + + self._setup_ui() + self._populate_families() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # Top section: Font families and styles side by side + lists_layout = QHBoxLayout() + + # Font families list + families_layout = QVBoxLayout() + families_label = QLabel(_('&Family:')) + self.families_list = QListWidget() + families_label.setBuddy(self.families_list) + self.families_list.currentItemChanged.connect(self._on_family_changed) + families_layout.addWidget(families_label) + families_layout.addWidget(self.families_list) + + # Styles list + styles_layout = QVBoxLayout() + styles_label = QLabel(_('&Style:')) + self.styles_list = QListWidget() + styles_label.setBuddy(self.styles_list) + self.styles_list.currentItemChanged.connect(self._on_style_changed) + styles_layout.addWidget(styles_label) + styles_layout.addWidget(self.styles_list) + + lists_layout.addLayout(families_layout, 2) + lists_layout.addLayout(styles_layout, 1) + + main_layout.addLayout(lists_layout, stretch=2) + + # Preview area + preview_group = QWidget() + preview_layout = QVBoxLayout(preview_group) + preview_layout.setContentsMargins(0, 10, 0, 10) + + # Scrollable preview area + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumHeight(200) + + preview_container = QWidget() + self.preview_layout = QVBoxLayout(preview_container) + self.preview_layout.setSpacing(10) + self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Preview labels for different sizes + self.preview_small = QLabel('The quick brown fox jumps over the lazy dog') + self.preview_medium = QLabel(self.preview_small) + self.preview_large = QLabel(self.preview_small) + self.preview_layout.addWidget(self.preview_small) + self.preview_layout.addWidget(self.preview_medium) + self.preview_layout.addWidget(self.preview_large) + + scroll_area.setWidget(preview_container) + preview_layout.addWidget(scroll_area) + + main_layout.addWidget(preview_group, stretch=1) + + # OK/Cancel buttons + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + main_layout.addWidget(button_box) + + def _populate_families(self): + '''Populate the families list with smoothly scalable fonts only''' + self.families_list.clear() + + # Get all font families + all_families = QFontDatabase.families() + + # Filter for smoothly scalable fonts + scalable_families = [] + idx = i = 0 + for family in all_families: + if QFontDatabase.isSmoothlyScalable(family): + scalable_families.append(family) + if family == self.initial_family: + idx = i + i += 1 + + scalable_families.sort() + self.families_list.addItems(scalable_families) + + # Select the initial item if available + if self.families_list.count() > 0: + self.families_list.setCurrentRow(idx) + self._on_family_changed(self.families_list.currentItem(), None) + + def _on_family_changed(self, current, previous): + '''When a family is selected, populate the styles list''' + if not current: + self.styles_list.clear() + return + + family = current.text() + self.styles_list.clear() + + # Get all styles for this family + styles = QFontDatabase.styles(family) + idx = 0 + if family == self.initial_family and self.initial_style in styles: + idx = styles.index(self.initial_style) + self.styles_list.addItems(styles) + + # Select first style if available + if self.styles_list.count() > 0: + self.styles_list.setCurrentRow(idx) + self._update_preview() + + def _on_style_changed(self, current, previous): + '''Update the preview when style changes''' + self._update_preview() + + def _update_preview(self): + '''Update the preview labels with the selected font''' + family_item = self.families_list.currentItem() + style_item = self.styles_list.currentItem() + + if not family_item or not style_item: + return + + family = family_item.text() + style = style_item.text() + systems = tuple(QFontDatabase.writingSystems(family)) + text = '' + for s in systems: + if (t := QFontDatabase.writingSystemSample(s)): + text = t + break + + def s(label, sz): + font = QFontDatabase.font(family, style, int(sz)) + font.setPointSizeF(sz) + label.setFont(font) + label.setText('') + if label is self.preview_small: + prefix = _('Minimum size:') + elif label is self.preview_medium: + prefix = _('Base size:') + else: + prefix = _('Maximum size:') + label.setText(prefix + ' ' + text) + s(self.preview_small, self.min_size) + s(self.preview_medium, self.medium_size) + s(self.preview_large, self.max_size) + + def selected_font(self): + '''Returns the selected font family and style as a tuple''' + family_item = self.families_list.currentItem() + style_item = self.styles_list. currentItem() + + if family_item and style_item: + return family_item.text(), style_item.text() + return None, None + + def get_font(self, size=None): + '''Returns a QFont object for the selected family and style''' + family, style = self.selected_font() + if family and style: + if size is None: + size = self.medium_size + return QFontDatabase.font(family, style, size) + return None + + def accept(self): + '''Override accept to emit signal with selected font''' + family, style = self.selected_font() + if family and style: + self.fontSelected.emit(family, style) + super().accept() + + +if __name__ == '__main__': + from calibre.gui2 import Application + + app = Application() + + def show_dialog(): + dialog = FontSelectionDialog(min_size=10, medium_size=14, max_size=28) + if dialog.exec() == QDialog.DialogCode.Accepted: + family, style = dialog.selected_font() + selected_font = dialog.get_font(16) + print(f'Selected: {family} - {style}') + print(f'Font: {selected_font. family()} {selected_font.styleName()} {selected_font.pointSize()}pt') + + show_dialog()