From 91a46f99a931b6216cf0c557130c598d78f6fcaf Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Sun, 17 Dec 2023 13:41:20 +0000 Subject: [PATCH 1/2] The tag browser notes/links icon stuff. Clicking the "buttons" works because of how mouse tracking works. The user must move the mouse over/into the current item line before clicking a button, which causes that line to be painted. When the line is painted the horizontal positions of the buttons on the line are recorded. If/when a click happens, the click X point is checked to see if it is in the ranges defined by the horizontal positions. --- src/calibre/gui2/__init__.py | 2 + src/calibre/gui2/preferences/look_feel.py | 2 + src/calibre/gui2/preferences/look_feel.ui | 139 +++++++++++++--------- src/calibre/gui2/tag_browser/model.py | 53 ++++++++- src/calibre/gui2/tag_browser/ui.py | 1 + src/calibre/gui2/tag_browser/view.py | 71 +++++++++-- 6 files changed, 202 insertions(+), 66 deletions(-) diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 801fdc1330..9f2aca03da 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -423,6 +423,8 @@ def create_defs(): defs['tb_search_order'] = {'0': 1, '1': 2, '2': 3, '3': 4, '4': 0} defs['search_tool_bar_shows_text'] = True defs['allow_keyboard_search_in_library_views'] = True + defs['show_links_in_tag_brouser'] = False + defs['show_notes_in_tag_brouser'] = False def migrate_tweak(tweak_name, pref_name): # If the tweak has been changed then leave the tweak in the file so diff --git a/src/calibre/gui2/preferences/look_feel.py b/src/calibre/gui2/preferences/look_feel.py index 88eadc357b..17499e2b26 100644 --- a/src/calibre/gui2/preferences/look_feel.py +++ b/src/calibre/gui2/preferences/look_feel.py @@ -646,6 +646,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): r('language', prefs, choices=choices, restart_required=True, setting=LanguageSetting) r('show_avg_rating', config) + r('show_links_in_tag_brouser', gprefs) + r('show_notes_in_tag_brouser', gprefs) r('disable_animations', config) r('systray_icon', config, restart_required=True) r('show_splash_screen', gprefs) diff --git a/src/calibre/gui2/preferences/look_feel.ui b/src/calibre/gui2/preferences/look_feel.ui index e64bcd9cb7..06c8022af1 100644 --- a/src/calibre/gui2/preferences/look_feel.ui +++ b/src/calibre/gui2/preferences/look_feel.ui @@ -1290,66 +1290,99 @@ structure and you want to use the same column order for each one.</p> - - - - Show &average ratings - - - true - - - - - - - Show &tooltips - - - - - - - <p>Show counts for items in the Tag browser. Such as the number of books + + + + + + Show &average ratings + + + Show the average rating per item indication in the Tag browser + + + true + + + + + + + Show &links icons + + + Show an icon if the item has an attached link + + + true + + + + + + + Show &tooltips + + + + + + + <p>Show counts for items in the Tag browser. Such as the number of books by each author, the number of authors, etc. If you turn it off, you can still see the counts by hovering your mouse over any item.</p> - - - Show &counts - - - - - - - Use &alternating row colors - - - - - - - <p>When checked, calibre will automatically hide any category + + + Show &counts + + + + + + + Show &notes icons + + + Show an icon if the item has an attached note + + + true + + + + + + + Use &alternating row colors + + + + + + + <p>When checked, calibre will automatically hide any category (a column, custom or standard) that has no items to show. For example, some categories might not have values when using Virtual libraries. Checking this box will cause these empty categories to be hidden.</p> - - - Hide empt&y categories (columns) - - - - - - - <p>When checked, Find in the Tag browser will show all items + + + Hide empt&y categories (columns) + + + + + + + <p>When checked, Find in the Tag browser will show all items that match the search instead of the first one. If Hide empty categories is also checked then only categories containing a matched item will be shown.</p> - - - Find &shows all items that match - - + + + Find &shows all items that match + + + + diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 0f04d8ebd1..515747023f 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -235,10 +235,16 @@ class TagTreeItem: # {{{ ar = self.average_rating if ar: tt.append(_('Average rating for books in this category: %.1f') % ar) - elif self.type == self.TAG and ar is not None: - tt.append(_('Books in this category are unrated')) - if self.type == self.TAG and tag.category != 'search': - tt.append(_('Number of books: %s') % self.item_count) + elif self.type == self.TAG: + if ar is not None: + tt.append(_('Books in this category are unrated')) + if tag.category != 'search': + tt.append(_('Number of books: %s') % self.item_count) + from calibre.gui2.ui import get_gui + db = get_gui().current_db.new_api + link = db.get_link_map(tag.category).get(tag.original_name) + if link: + tt.append(_('Link: %s') % link) return '\n'.join(tt) return None if role == DRAG_IMAGE_ROLE: @@ -365,6 +371,7 @@ class TagsModel(QAbstractItemModel): # {{{ self._build_in_progress = False self.reread_collapse_model({}, rebuild=False) self.show_error_after_event_loop_tick_signal.connect(self.on_show_error_after_event_loop_tick, type=Qt.ConnectionType.QueuedConnection) + self.reset_notes_and_link_maps() @property def gui_parent(self): @@ -441,6 +448,43 @@ class TagsModel(QAbstractItemModel): # {{{ self._run_rebuild() self.endResetModel() + def _cached_notes_map(self, category): + if self.notes_map is None: + self.notes_map = {} + if category not in self.notes_map: + try: + self.notes_map[category] = (self.db.new_api.get_all_items_that_have_notes(category), + self.db.new_api.get_item_name_map(category)) + except: + self.notes_map[category] = (frozenset(), {}) + return self.notes_map[category] + + def _cached_link_map(self, category): + if self.link_map is None: + self.link_map = {} + if category not in self.link_map: + try: + self.link_map[category] = self.db.new_api.get_link_map(category) + except Exception: + self.link_map[category] = {} + return self.link_map[category] + + def category_has_notes(self, category): + return len(self._cached_notes_map(category)[0]) > 0 + + def item_has_note(self, category, item_name): + notes_map, item_id_map = self._cached_notes_map(category) + return item_id_map.get(item_name) in notes_map + + def category_has_links(self, category): + return len(self._cached_link_map(category)) > 0 + + def item_has_link(self, category, item_name): + return item_name in self._cached_link_map(category) + + def reset_notes_and_link_maps(self): + self.link_map = self.notes_map = None + def rebuild_node_tree(self, state_map={}): if self._build_in_progress: print('Tag browser build already in progress') @@ -455,6 +499,7 @@ class TagsModel(QAbstractItemModel): # {{{ self._build_in_progress = False def _run_rebuild(self, state_map={}): + self.reset_notes_and_link_maps() for node in itervalues(self.node_map): node.break_cycles() del node # Clear reference to node in the current frame diff --git a/src/calibre/gui2/tag_browser/ui.py b/src/calibre/gui2/tag_browser/ui.py index 88bdaaa2c4..da9c9490dc 100644 --- a/src/calibre/gui2/tag_browser/ui.py +++ b/src/calibre/gui2/tag_browser/ui.py @@ -514,6 +514,7 @@ class TagBrowserMixin: # {{{ m = self.library_view.model() ids = [m.id(r) for r in rows] + self.tags_view.model().reset_notes_and_link_maps() m.refresh(reset=False) m.research() self.library_view.select_rows(ids) diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index f332a3ae33..f6c7572b19 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -22,7 +22,7 @@ from calibre.constants import config_dir from calibre.ebooks.metadata import rating_to_stars from calibre.gui2 import ( FunctionDispatcher, choose_files, config, empty_index, gprefs, pixmap_to_data, - question_dialog, rating_font, + question_dialog, rating_font, safe_open_url, ) from calibre.gui2.dialogs.edit_category_notes import EditNoteDialog from calibre.gui2.complete2 import EditWithComplete @@ -43,6 +43,9 @@ class TagDelegate(QStyledItemDelegate): # {{{ self.rating_pat = re.compile(r'[%s]' % rating_to_stars(3, True)) self.rating_font = QFont(rating_font()) self.tags_view = tags_view + self.links_icon = QIcon.ic('external-link.png') + self.notes_icon = QIcon.ic('notes.png') + self.blank_icon = QIcon() def draw_average_rating(self, item, style, painter, option, widget): rating = item.average_rating @@ -86,6 +89,30 @@ class TagDelegate(QStyledItemDelegate): # {{{ hover = option.state & QStyle.StateFlag.State_MouseOver is_search = (True if item.type == TagTreeItem.TAG and item.tag.category == 'search' else False) + + show_notes = gprefs['show_notes_in_tag_brouser'] + show_links = gprefs['show_links_in_tag_brouser'] + if item.type == TagTreeItem.TAG: + category = item.tag.category + name = item.tag.original_name + m = self.tags_view._model + if show_notes and m.category_has_notes(category): + icon = self.notes_icon if m.item_has_note(category, name) else self.blank_icon + width = int(tr.height()/2) + r = QRect(tr) + r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4) + self.tags_view.current_note_button_position = (r.left(), r.left()+r.width()) + icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) + tr.setRight(r.left() - 1) + + if show_links and m.category_has_links(category): + icon = self.links_icon if m.item_has_link(category, name) else self.blank_icon + width = int(tr.height()/2) + r = QRect(tr) + r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4) + self.tags_view.current_link_button_position = (r.left(), r.left()+r.width()) + icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) + tr.setRight(r.left() - 1) if not is_search and (hover or gprefs['tag_browser_show_counts']): count = str(index.data(COUNT_ROLE)) width = painter.fontMetrics().boundingRect(count).width() @@ -202,6 +229,8 @@ class TagsView(QTreeView): # {{{ self.setTabKeyNavigation(True) self.setAnimated(True) self.setHeaderHidden(True) + self.current_note_button_position = (-1, -1) + self.current_link_button_position = (-1, -1) self.setItemDelegate(TagDelegate(tags_view=self)) self.made_connections = False self.setAcceptDrops(True) @@ -428,10 +457,29 @@ class TagsView(QTreeView): # {{{ except: pass + def number_in_range(self, val, range_tuple): + return range_tuple[0] <= val <= range_tuple[1] + def mousePressEvent(self, event): if event.buttons() & Qt.MouseButton.LeftButton: # Only remember a possible drag start if the item is drag enabled dex = self.indexAt(event.pos()) + t = self._model.data(dex, Qt.UserRole) + if t.type == TagTreeItem.TAG: + db = self._model.db.new_api + tag = t.tag + x = event.pos().x() + if self.number_in_range(x, self.current_note_button_position): + from calibre.gui2.dialogs.show_category_note import ShowNoteDialog + item_id = db.get_item_id(tag.category, tag.original_name) + if db.notes_for(tag.category, item_id): + ShowNoteDialog(tag.category, item_id, db, parent=self).show() + return + elif self.number_in_range(x, self.current_link_button_position): + link = db.get_link_map(tag.category).get(tag.original_name) + if link: + safe_open_url(link) + return if self._model.flags(dex) & Qt.ItemFlag.ItemIsDragEnabled: self.possible_drag_start = event.pos() else: @@ -811,6 +859,19 @@ class TagsView(QTreeView): # {{{ self.context_menu.addAction(_('Edit link for %s')%display_name(tag), partial(self.context_menu_handler, action='edit_author_link', index=tag.id)).setIcon(QIcon.ic('insert-link.png')) + elif self.db.new_api.has_link_map(key): + self.context_menu.addAction(_('Edit link for %s')%display_name(tag), + partial(self.context_menu_handler, action='open_editor', + category=tag.original_name if tag else None, + key=key)) + + if self.db.new_api.field_supports_notes(key): + item_id = self.db.new_api.get_item_id(tag.category, tag.original_name) + has_note = self._model.item_has_note(key, tag.original_name) #bool(self.db.new_api.notes_for(tag.category, item_id)) + self.context_menu.addAction(self.edit_metadata_icon, + (_('Edit note for %s') if has_note else _('Create note for %s'))%display_name(tag), + partial(self.context_menu_handler, action='edit_note', + index=index, extra=item_id, category=tag.category)) # is_editable is also overloaded to mean 'can be added # to a User category' @@ -848,14 +909,6 @@ class TagsView(QTreeView): # {{{ m.addAction(self.minus_icon, _('Remove %s from selected books') % display_name(tag), partial(self.context_menu_handler, action='remove_tag', index=index)) - - item_id = self.db.new_api.get_item_id(tag.category, tag.original_name) - has_note = bool(self.db.new_api.notes_for(tag.category, item_id)) - self.context_menu.addAction(self.edit_metadata_icon, - (_('Edit note for %s') if has_note else _('Create note for %s'))%display_name(tag), - partial(self.context_menu_handler, action='edit_note', - index=index, extra=item_id, category=tag.category)) - elif key == 'search' and tag.is_searchable: self.context_menu.addAction(self.rename_icon, _('Rename %s')%display_name(tag), From 88dc2d85d3e22437636815a1449690864ccdeb75 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Mon, 18 Dec 2023 11:58:55 +0000 Subject: [PATCH 2/2] Two things: 1) Fixes for the icon clicks. It now works when items are painted out of order. Resizing is now accounted for. Toggling and setting item focus now works properly. 2) Added items to the tag browser config menu, including the possibility of shortcuts. --- src/calibre/gui2/tag_browser/ui.py | 32 ++++++++++- src/calibre/gui2/tag_browser/view.py | 80 ++++++++++++++++------------ 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/src/calibre/gui2/tag_browser/ui.py b/src/calibre/gui2/tag_browser/ui.py index da9c9490dc..fed24fd810 100644 --- a/src/calibre/gui2/tag_browser/ui.py +++ b/src/calibre/gui2/tag_browser/ui.py @@ -779,12 +779,26 @@ class TagBrowserWidget(QFrame): # {{{ _('Configure Tag browser'), default_keys=(), action=ac, group=_('Tag browser')) ac.triggered.connect(l.showMenu) - l.m.aboutToShow.connect(self.about_to_show_configure_menu) + # Show/hide counts l.m.show_counts_action = ac = l.m.addAction('counts') ac.triggered.connect(self.toggle_counts) + # Show/hide average rating l.m.show_avg_rating_action = ac = l.m.addAction(QIcon.ic('rating.png'), 'avg rating') ac.triggered.connect(self.toggle_avg_rating) + # Show/hide notes icon + l.m.show_notes_icon_action = ac = l.m.addAction(QIcon.ic('notes.png'), 'notes icon') + ac.triggered.connect(self.toggle_notes_icon) + parent.keyboard.register_shortcut('tag browser toggle notes', + _('Toggle notes icons'), default_keys=(), + action=ac, group=_('Tag browser')) + # Show/hide links icon + l.m.show_links_icon_action = ac = l.m.addAction(QIcon.ic('external-link.png'), 'links icon') + ac.triggered.connect(self.toggle_links_icon) + parent.keyboard.register_shortcut('tag browser toggle links', + _('Toggle links icons'), default_keys=(), + action=ac, group=_('Tag browser')) + sb = l.m.addAction(QIcon.ic('sort.png'), _('Sort by')) sb.m = l.sort_menu = QMenu(l.m) sb.setMenu(sb.m) @@ -858,6 +872,12 @@ class TagBrowserWidget(QFrame): # {{{ ac = self.alter_tb.m.show_avg_rating_action ac.setText(_('Hide average rating') if config['show_avg_rating'] else _('Show average rating')) ac.setIcon(QIcon.ic('minus.png' if config['show_avg_rating'] else 'plus.png')) + ac = self.alter_tb.m.show_notes_icon_action + ac.setText(_('Hide notes icon') if gprefs['show_notes_in_tag_brouser'] else _('Show notes icon')) + ac.setIcon(QIcon.ic('minus.png' if gprefs['show_notes_in_tag_brouser'] else 'plus.png')) + ac = self.alter_tb.m.show_links_icon_action + ac.setText(_('Hide links icon') if gprefs['show_links_in_tag_brouser'] else _('Show links icon')) + ac.setIcon(QIcon.ic('minus.png' if gprefs['show_links_in_tag_brouser'] else 'plus.png')) def filter_book_list(self): self.tags_view.model().set_in_tag_browser() @@ -865,9 +885,19 @@ class TagBrowserWidget(QFrame): # {{{ def toggle_counts(self): gprefs['tag_browser_show_counts'] ^= True + self.tags_view.recount_with_position_based_index() def toggle_avg_rating(self): config['show_avg_rating'] ^= True + self.tags_view.recount_with_position_based_index() + + def toggle_notes_icon(self): + gprefs['show_notes_in_tag_brouser'] ^= True + self.tags_view.recount_with_position_based_index() + + def toggle_links_icon(self): + gprefs['show_links_in_tag_brouser'] ^= True + self.tags_view.recount_with_position_based_index() def save_state(self): gprefs.set('tag browser search box visible', self.toggle_search_button.isChecked()) diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index f6c7572b19..561d3446ec 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -8,6 +8,7 @@ __docformat__ = 'restructuredtext en' import os import re import traceback +from collections import defaultdict from contextlib import suppress from functools import partial from qt.core import ( @@ -95,22 +96,22 @@ class TagDelegate(QStyledItemDelegate): # {{{ if item.type == TagTreeItem.TAG: category = item.tag.category name = item.tag.original_name - m = self.tags_view._model - if show_notes and m.category_has_notes(category): - icon = self.notes_icon if m.item_has_note(category, name) else self.blank_icon - width = int(tr.height()/2) - r = QRect(tr) - r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4) - self.tags_view.current_note_button_position = (r.left(), r.left()+r.width()) - icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) - tr.setRight(r.left() - 1) - + tv = self.tags_view + m = tv._model if show_links and m.category_has_links(category): icon = self.links_icon if m.item_has_link(category, name) else self.blank_icon width = int(tr.height()/2) r = QRect(tr) r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4) - self.tags_view.current_link_button_position = (r.left(), r.left()+r.width()) + tv.category_button_positions[category]['links'] = (r.left(), r.left()+r.width()) + icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) + tr.setRight(r.left() - 1) + if show_notes and m.category_has_notes(category): + icon = self.notes_icon if m.item_has_note(category, name) else self.blank_icon + width = int(tr.height()/2) + r = QRect(tr) + r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4) + tv.category_button_positions[category]['notes'] = (r.left(), r.left()+r.width()) icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On) tr.setRight(r.left() - 1) if not is_search and (hover or gprefs['tag_browser_show_counts']): @@ -229,8 +230,6 @@ class TagsView(QTreeView): # {{{ self.setTabKeyNavigation(True) self.setAnimated(True) self.setHeaderHidden(True) - self.current_note_button_position = (-1, -1) - self.current_link_button_position = (-1, -1) self.setItemDelegate(TagDelegate(tags_view=self)) self.made_connections = False self.setAcceptDrops(True) @@ -248,6 +247,12 @@ class TagsView(QTreeView): # {{{ self.plus_icon = QIcon.ic('plus.png') self.minus_icon = QIcon.ic('minus.png') + # Dict for recording the positions of the fake buttons for category tag + # lines. It is recorded per category because we can't guarantee the + # order that items are painted. The numbers get updated whenever an item + # is painted, which deals with resizing. + self.category_button_positions = defaultdict(dict) + self._model = TagsModel(self) self._model.search_item_renamed.connect(self.search_item_renamed) self._model.refresh_required.connect(self.refresh_required, @@ -361,7 +366,7 @@ class TagsView(QTreeView): # {{{ match_pop = 0 self.alter_tb.match_menu.actions()[match_pop].setChecked(True) if not self.made_connections: - self.clicked.connect(self.toggle) + self.clicked.connect(self.toggle_on_mouse_click) self.customContextMenuRequested.connect(self.show_context_menu) self.refresh_required.connect(self.recount, type=Qt.ConnectionType.QueuedConnection) self.alter_tb.sort_menu.triggered.connect(self.sort_changed) @@ -457,29 +462,12 @@ class TagsView(QTreeView): # {{{ except: pass - def number_in_range(self, val, range_tuple): - return range_tuple[0] <= val <= range_tuple[1] - def mousePressEvent(self, event): if event.buttons() & Qt.MouseButton.LeftButton: + # Record the press point for processing during the clicked signal + self.mouse_clicked_point = event.pos() # Only remember a possible drag start if the item is drag enabled dex = self.indexAt(event.pos()) - t = self._model.data(dex, Qt.UserRole) - if t.type == TagTreeItem.TAG: - db = self._model.db.new_api - tag = t.tag - x = event.pos().x() - if self.number_in_range(x, self.current_note_button_position): - from calibre.gui2.dialogs.show_category_note import ShowNoteDialog - item_id = db.get_item_id(tag.category, tag.original_name) - if db.notes_for(tag.category, item_id): - ShowNoteDialog(tag.category, item_id, db, parent=self).show() - return - elif self.number_in_range(x, self.current_link_button_position): - link = db.get_link_map(tag.category).get(tag.original_name) - if link: - safe_open_url(link) - return if self._model.flags(dex) & Qt.ItemFlag.ItemIsDragEnabled: self.possible_drag_start = event.pos() else: @@ -534,11 +522,37 @@ class TagsView(QTreeView): # {{{ joiner = ' and ' if self.match_all else ' or ' return joiner.join(tokens) + def click_in_button_range(self, val, category, kind): + range_tuple = self.category_button_positions[category].get(kind) + return range_tuple and range_tuple[0] <= val <= range_tuple[1] + def toggle_current_index(self): ci = self.currentIndex() if ci.isValid(): self.toggle(ci) + def toggle_on_mouse_click(self, index): + # Check if one of the link or note icons was clicked. If so, deal with + # it here and don't do the real toggle + t = self._model.data(index, Qt.UserRole) + if t.type == TagTreeItem.TAG: + db = self._model.db.new_api + category = t.tag.category + orig_name = t.tag.original_name + x = self.mouse_clicked_point.x() + if self.click_in_button_range(x, category, 'notes'): + from calibre.gui2.dialogs.show_category_note import ShowNoteDialog + item_id = db.get_item_id(category, orig_name) + if db.notes_for(category, item_id): + ShowNoteDialog(category, item_id, db, parent=self).show() + return + if self.click_in_button_range(x, category, 'links'): + link = db.get_link_map(category).get(orig_name) + if link: + safe_open_url(link) + return + self._toggle(index, None) + def toggle(self, index): self._toggle(index, None)