%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/ |
Current File : //lib/calibre/calibre/gui2/search_box.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>' __docformat__ = 'restructuredtext en' import re, time from functools import partial from qt.core import ( QComboBox, Qt, QLineEdit, pyqtSlot, QDialog, QEvent, pyqtSignal, QCompleter, QAction, QKeySequence, QTimer, QIcon, QMenu, QApplication, QKeyEvent) from calibre.gui2 import config, error_dialog, question_dialog, gprefs, QT_HIDDEN_CLEAR_ACTION from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.saved_search_editor import SavedSearchEditor from calibre.gui2.dialogs.search import SearchDialog from calibre.utils.icu import primary_sort_key from polyglot.builtins import native_string_type, string_or_bytes class AsYouType(str): def __new__(cls, text): self = str.__new__(cls, text) self.as_you_type = True return self class SearchLineEdit(QLineEdit): # {{{ key_pressed = pyqtSignal(object) clear_history = pyqtSignal() select_on_mouse_press = None as_url = None def keyPressEvent(self, event): self.key_pressed.emit(event) QLineEdit.keyPressEvent(self, event) def dropEvent(self, ev): self.parent().normalize_state() return QLineEdit.dropEvent(self, ev) def contextMenuEvent(self, ev): self.parent().normalize_state() menu = self.createStandardContextMenu() menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) ac = menu.addAction(_('Paste and &search')) ac.setEnabled(bool(QApplication.clipboard().text())) ac.setIcon(QIcon(I('search.png'))) ac.triggered.connect(self.paste_and_search) for action in menu.actions(): if action.text().startswith(_('&Paste') + '\t'): menu.insertAction(action, ac) break else: menu.addAction(ac) menu.addSeparator() if self.as_url is not None: url = self.as_url(self.text()) if url: menu.addAction(_('Copy search as URL'), lambda : QApplication.clipboard().setText(url)) menu.addAction(_('&Clear search history')).triggered.connect(self.clear_history) menu.exec(ev.globalPos()) def paste_and_search(self): self.paste() ev = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Enter, Qt.KeyboardModifier.NoModifier) self.keyPressEvent(ev) @pyqtSlot() def paste(self, *args): self.parent().normalize_state() return QLineEdit.paste(self) def focusInEvent(self, ev): self.select_on_mouse_press = time.time() return QLineEdit.focusInEvent(self, ev) def mousePressEvent(self, ev): QLineEdit.mousePressEvent(self, ev) if self.select_on_mouse_press is not None and abs(time.time() - self.select_on_mouse_press) < 0.2: self.selectAll() self.select_on_mouse_press = None # }}} class SearchBox2(QComboBox): # {{{ ''' To use this class: * Call initialize() * Connect to the search() and cleared() signals from this widget. * Connect to the changed() signal to know when the box content changes * Connect to focus_to_library() signal to be told to manually change focus * Call search_done() after every search is complete * Call set_search_string() to perform a search programmatically * You can use the current_text property to get the current search text Be aware that if you are using it in a slot connected to the changed() signal, if the connection is not queued it will not be accurate. ''' INTERVAL = 1500 #: Time to wait before emitting search signal MAX_COUNT = 25 search = pyqtSignal(object) cleared = pyqtSignal() changed = pyqtSignal() focus_to_library = pyqtSignal() def __init__(self, parent=None, add_clear_action=True, as_url=None): QComboBox.__init__(self, parent) self.line_edit = SearchLineEdit(self) self.line_edit.as_url = as_url self.setLineEdit(self.line_edit) self.line_edit.clear_history.connect(self.clear_history) if add_clear_action: self.lineEdit().setClearButtonEnabled(True) ac = self.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.clear_clicked) c = self.line_edit.completer() c.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) c.highlighted[native_string_type].connect(self.completer_used) self.line_edit.key_pressed.connect(self.key_pressed, type=Qt.ConnectionType.DirectConnection) # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807 self.activated[native_string_type].connect(self.history_selected, type=Qt.ConnectionType.QueuedConnection) self.setEditable(True) self.as_you_type = True self.timer = QTimer() self.timer.setSingleShot(True) self.timer.timeout.connect(self.timer_event, type=Qt.ConnectionType.QueuedConnection) self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.setMaxCount(self.MAX_COUNT) self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self.setMinimumContentsLength(25) self._in_a_search = False self.tool_tip_text = self.toolTip() def add_action(self, icon, position=QLineEdit.ActionPosition.TrailingPosition): if not isinstance(icon, QIcon): icon = QIcon(I(icon)) return self.lineEdit().addAction(icon, position) def initialize(self, opt_name, colorize=False, help_text=_('Search'), as_you_type=None): self.as_you_type = config['search_as_you_type'] if as_you_type is None else as_you_type self.opt_name = opt_name items = [] for item in config[opt_name]: if item not in items: items.append(item) self.addItems(items) self.line_edit.setPlaceholderText(help_text) self.colorize = colorize self.clear() def clear_history(self): config[self.opt_name] = [] super().clear() self.clear() clear_search_history = clear_history def hide_completer_popup(self): try: self.lineEdit().completer().popup().setVisible(False) except: pass def normalize_state(self): self.setToolTip(self.tool_tip_text) self.line_edit.setStyleSheet('') def text(self): return self.currentText() def clear(self, emit_search=True): self.normalize_state() self.setEditText('') if emit_search: self.search.emit('') self._in_a_search = False self.cleared.emit() def clear_clicked(self, *args): self.clear() self.setFocus(Qt.FocusReason.OtherFocusReason) def search_done(self, ok): if isinstance(ok, string_or_bytes): self.setToolTip(ok) ok = False if not str(self.currentText()).strip(): self.clear(emit_search=False) return self._in_a_search = ok if self.colorize: self.line_edit.setStyleSheet(QApplication.instance().stylesheet_for_line_edit(not ok)) else: self.line_edit.setStyleSheet('') # Comes from the lineEdit control def key_pressed(self, event): k = event.key() if k in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Home, Qt.Key.Key_End, Qt.Key.Key_PageUp, Qt.Key.Key_PageDown, Qt.Key.Key_unknown): return self.normalize_state() if self._in_a_search: self.changed.emit() self._in_a_search = False if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.do_search() self.focus_to_library.emit() elif self.as_you_type and str(event.text()): self.timer.start(1500) # Comes from the combobox itself def keyPressEvent(self, event): k = event.key() if k in (Qt.Key.Key_Enter, Qt.Key.Key_Return): return self.do_search() if k not in (Qt.Key.Key_Up, Qt.Key.Key_Down): return QComboBox.keyPressEvent(self, event) self.blockSignals(True) self.normalize_state() if k == Qt.Key.Key_Down and self.currentIndex() == 0 and not self.lineEdit().text(): self.setCurrentIndex(1), self.setCurrentIndex(0) event.accept() else: QComboBox.keyPressEvent(self, event) self.blockSignals(False) def completer_used(self, text): self.timer.stop() self.normalize_state() def timer_event(self): self._do_search(as_you_type=True) def history_selected(self, text): self.changed.emit() self.do_search() def _do_search(self, store_in_history=True, as_you_type=False): self.hide_completer_popup() text = str(self.currentText()).strip() if not text: return self.clear() if as_you_type: text = AsYouType(text) self.search.emit(text) if store_in_history: idx = self.findText(text, Qt.MatchFlag.MatchFixedString|Qt.MatchFlag.MatchCaseSensitive) self.block_signals(True) if idx < 0: self.insertItem(0, text) else: t = self.itemText(idx) self.removeItem(idx) self.insertItem(0, t) self.setCurrentIndex(0) self.block_signals(False) history = [str(self.itemText(i)) for i in range(self.count())] config[self.opt_name] = history def do_search(self, *args): self._do_search() def block_signals(self, yes): self.blockSignals(yes) self.line_edit.blockSignals(yes) def set_search_string(self, txt, store_in_history=False, emit_changed=True): if not store_in_history: self.activated[native_string_type].disconnect() try: self.setFocus(Qt.FocusReason.OtherFocusReason) if not txt: self.clear() else: self.normalize_state() # must turn on case sensitivity here so that tag browser strings # are not case-insensitively replaced from history self.line_edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) self.setEditText(txt) self.line_edit.end(False) if emit_changed: self.changed.emit() self._do_search(store_in_history=store_in_history) self.line_edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self.focus_to_library.emit() finally: if not store_in_history: # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807 self.activated[native_string_type].connect(self.history_selected, type=Qt.ConnectionType.QueuedConnection) def search_as_you_type(self, enabled): self.as_you_type = enabled def in_a_search(self): return self._in_a_search @property def current_text(self): return str(self.lineEdit().text()) # }}} class SavedSearchBox(QComboBox): # {{{ ''' To use this class: * Call initialize() * Connect to the changed() signal from this widget if you care about changes to the list of saved searches. ''' changed = pyqtSignal() def __init__(self, parent=None): QComboBox.__init__(self, parent) self.line_edit = SearchLineEdit(self) self.setLineEdit(self.line_edit) self.line_edit.key_pressed.connect(self.key_pressed, type=Qt.ConnectionType.DirectConnection) self.activated[native_string_type].connect(self.saved_search_selected) # Turn off auto-completion so that it doesn't interfere with typing # names of new searches. completer = QCompleter(self) self.setCompleter(completer) self.setEditable(True) self.setMaxVisibleItems(25) self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self.setMinimumContentsLength(25) self.tool_tip_text = self.toolTip() def initialize(self, _search_box, colorize=False, help_text=_('Search')): self.search_box = _search_box try: self.line_edit.setPlaceholderText(help_text) except: # Using Qt < 4.7 pass self.colorize = colorize self.clear() def normalize_state(self): # need this because line_edit will call it in some cases such as paste pass def clear(self): QComboBox.clear(self) self.initialize_saved_search_names() self.setEditText('') self.setToolTip(self.tool_tip_text) self.line_edit.home(False) def key_pressed(self, event): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.saved_search_selected(self.currentText()) def saved_search_selected(self, qname): from calibre.gui2.ui import get_gui db = get_gui().current_db qname = str(qname) if qname is None or not qname.strip(): self.search_box.clear() return if not db.saved_search_lookup(qname): self.search_box.clear() self.setEditText(qname) return self.search_box.set_search_string('search:"%s"' % qname, emit_changed=False) self.setEditText(qname) self.setToolTip(db.saved_search_lookup(qname)) def initialize_saved_search_names(self): from calibre.gui2.ui import get_gui gui = get_gui() try: names = gui.current_db.saved_search_names() except AttributeError: # Happens during gui initialization names = [] self.addItems(names) self.setCurrentIndex(-1) # SIGNALed from the main UI def save_search_button_clicked(self): from calibre.gui2.ui import get_gui db = get_gui().current_db name = str(self.currentText()) if not name.strip(): name = str(self.search_box.text()).replace('"', '') name = name.replace('\\', '') if not name: error_dialog(self, _('Create saved search'), _('Invalid saved search name. ' 'It must contain at least one letter or number'), show=True) return if not self.search_box.text(): error_dialog(self, _('Create saved search'), _('There is no search to save'), show=True) return db.saved_search_delete(name) db.saved_search_add(name, str(self.search_box.text())) # now go through an initialization cycle to ensure that the combobox has # the new search in it, that it is selected, and that the search box # references the new search instead of the text in the search. self.clear() self.setCurrentIndex(self.findText(name)) self.saved_search_selected(name) self.changed.emit() def delete_current_search(self): from calibre.gui2.ui import get_gui db = get_gui().current_db idx = self.currentIndex() if idx <= 0: error_dialog(self, _('Delete current search'), _('No search is selected'), show=True) return if not confirm('<p>'+_('The selected search will be ' '<b>permanently deleted</b>. Are you sure?') + '</p>', 'saved_search_delete', self): return ss = db.saved_search_lookup(str(self.currentText())) if ss is None: return db.saved_search_delete(str(self.currentText())) self.clear() self.search_box.clear() self.changed.emit() # SIGNALed from the main UI def copy_search_button_clicked(self): from calibre.gui2.ui import get_gui db = get_gui().current_db idx = self.currentIndex() if idx < 0: return self.search_box.set_search_string(db.saved_search_lookup(str(self.currentText()))) # }}} class SearchBoxMixin: # {{{ def __init__(self, *args, **kwargs): pass def init_search_box_mixin(self): self.search.initialize('main_search_history', colorize=True, help_text=_('Search (For advanced search click the gear icon to the left)')) self.search.cleared.connect(self.search_box_cleared) # Queued so that search.current_text will be correct self.search.changed.connect(self.search_box_changed, type=Qt.ConnectionType.QueuedConnection) self.search.focus_to_library.connect(self.focus_to_library) self.advanced_search_toggle_action.triggered.connect(self.do_advanced_search) self.search.clear() self.search.setMaximumWidth(self.width()-150) self.action_focus_search = QAction(self) shortcuts = list( map(lambda x:str(x.toString(QKeySequence.SequenceFormat.PortableText)), QKeySequence.keyBindings(QKeySequence.StandardKey.Find))) shortcuts += ['/', 'Alt+S'] self.keyboard.register_shortcut('start search', _('Start search'), default_keys=shortcuts, action=self.action_focus_search) self.action_focus_search.triggered.connect(self.focus_search_box) self.addAction(self.action_focus_search) self.search.setStatusTip(re.sub(r'<\w+>', ' ', str(self.search.toolTip()))) self.set_highlight_only_button_icon() self.highlight_only_button.clicked.connect(self.highlight_only_clicked) tt = _('Enable or disable search highlighting.') + '<br><br>' tt += config.help('highlight_search_matches') self.highlight_only_button.setToolTip(tt) self.highlight_only_action = ac = QAction(self) self.addAction(ac), ac.triggered.connect(self.highlight_only_clicked) self.keyboard.register_shortcut('highlight search results', _('Highlight search results'), action=self.highlight_only_action) def highlight_only_clicked(self, state): if not config['highlight_search_matches'] and not question_dialog(self, _('Are you sure?'), _('This will change how searching works. When you search, instead of showing only the ' 'matching books, all books will be shown with the matching books highlighted. ' 'Are you sure this is what you want?'), skip_dialog_name='confirm_search_highlight_toggle'): return config['highlight_search_matches'] = not config['highlight_search_matches'] self.set_highlight_only_button_icon() self.search.do_search() self.focus_to_library() def set_highlight_only_button_icon(self): b = self.highlight_only_button if config['highlight_search_matches']: b.setIcon(QIcon(I('highlight_only_on.png'))) b.setText(_('Filter')) else: b.setIcon(QIcon(I('highlight_only_off.png'))) b.setText(_('Highlight')) self.highlight_only_button.setVisible(gprefs['show_highlight_toggle_button']) self.library_view.model().set_highlight_only(config['highlight_search_matches']) def focus_search_box(self, *args): self.search.setFocus(Qt.FocusReason.OtherFocusReason) self.search.lineEdit().selectAll() def search_box_cleared(self): self.tags_view.clear() self.saved_search.clear() self.set_number_of_books_shown() def search_box_changed(self): self.saved_search.clear() self.tags_view.conditional_clear(self.search.current_text) def do_advanced_search(self, *args): d = SearchDialog(self, self.library_view.model().db) if d.exec() == QDialog.DialogCode.Accepted: self.search.set_search_string(d.search_string(), store_in_history=True) def do_search_button(self): self.search.do_search() self.focus_to_library() def focus_to_library(self): self.current_view().setFocus(Qt.FocusReason.OtherFocusReason) # }}} class SavedSearchBoxMixin: # {{{ def __init__(self, *args, **kwargs): pass def init_saved_seach_box_mixin(self): self.saved_search.changed.connect(self.saved_searches_changed) ac = self.search.findChild(QAction, QT_HIDDEN_CLEAR_ACTION) if ac is not None: ac.triggered.connect(self.saved_search.clear) self.save_search_button.clicked.connect( self.saved_search.save_search_button_clicked) self.copy_search_button.clicked.connect( self.saved_search.copy_search_button_clicked) # self.saved_searches_changed() self.saved_search.initialize(self.search, colorize=True, help_text=_('Saved searches')) self.saved_search.tool_tip_text=_('Choose saved search or enter name for new saved search') self.saved_search.setToolTip(self.saved_search.tool_tip_text) self.saved_search.setStatusTip(self.saved_search.tool_tip_text) for x in ('copy', 'save'): b = getattr(self, x+'_search_button') b.setStatusTip(b.toolTip()) self.save_search_button.setToolTip('<p>' + _("Save current search under the name shown in the box. " "Press and hold for a pop-up options menu.") + '</p>') self.save_search_button.setMenu(QMenu(self.save_search_button)) self.save_search_button.menu().addAction( QIcon(I('plus.png')), _('Create Saved search'), self.saved_search.save_search_button_clicked) self.save_search_button.menu().addAction( QIcon(I('trash.png')), _('Delete Saved search'), self.saved_search.delete_current_search) self.save_search_button.menu().addAction( QIcon(I('search.png')), _('Manage Saved searches'), partial(self.do_saved_search_edit, None)) self.add_saved_search_button.setMenu(QMenu(self.add_saved_search_button)) self.add_saved_search_button.menu().aboutToShow.connect(self.populate_add_saved_search_menu) def populate_add_saved_search_menu(self): m = self.add_saved_search_button.menu() m.clear() m.addAction(QIcon(I('plus.png')), _('Add Saved search'), self.add_saved_search) m.addAction(QIcon(I("search_copy_saved.png")), _('Get Saved search expression'), self.get_saved_search_text) m.addActions(list(self.save_search_button.menu().actions())[-1:]) m.addSeparator() db = self.current_db for name in sorted(db.saved_search_names(), key=lambda x: primary_sort_key(x.strip())): m.addAction(name.strip(), partial(self.saved_search.saved_search_selected, name)) def saved_searches_changed(self, set_restriction=None, recount=True): self.build_search_restriction_list() if recount: self.tags_view.recount() if set_restriction: # redo the search restriction if there was one self.apply_named_search_restriction(set_restriction) def do_saved_search_edit(self, search): d = SavedSearchEditor(self, search) d.exec() if d.result() == QDialog.DialogCode.Accepted: self.do_rebuild_saved_searches() def do_rebuild_saved_searches(self): self.saved_searches_changed() self.saved_search.clear() def add_saved_search(self): from calibre.gui2.dialogs.saved_search_editor import AddSavedSearch d = AddSavedSearch(parent=self, search=self.search.current_text) if d.exec() == QDialog.DialogCode.Accepted: self.current_db.new_api.ensure_has_search_category(fail_on_existing=False) self.do_rebuild_saved_searches() def get_saved_search_text(self, search_name=None): db = self.current_db try: current_search = search_name if search_name else self.search.currentText() if not current_search.startswith('search:'): raise ValueError() # This strange expression accounts for the four ways a search can be written: # search:fff, search:"fff", search:"=fff". and search:="fff" current_search = current_search[7:].lstrip('=').strip('"').lstrip('=') current_search = db.saved_search_lookup(current_search) if not current_search: raise ValueError() self.search.set_search_string(current_search) except: from calibre.gui2.ui import get_gui get_gui().status_bar.show_message(_('Current search is not a saved search'), 3000) # }}}