diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index d3eaae755f..81fdb2e28c 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -114,6 +114,7 @@ class Boss(QObject): self.gui.central.current_editor_changed.connect(self.apply_current_editor_state) self.gui.central.close_requested.connect(self.editor_close_requested) self.gui.central.search_panel.search_triggered.connect(self.search) + self.gui.text_search.find_text.connect(self.find_text) self.gui.preview.sync_requested.connect(self.sync_editor_to_preview) self.gui.preview.split_start_requested.connect(self.split_start_requested) self.gui.preview.split_requested.connect(self.split_requested) @@ -866,6 +867,10 @@ class Boss(QObject): if text and text.strip(): self.gui.central.pre_fill_search(text) + def show_text_search(self): + self.gui.text_search_dock.show() + self.gui.text_search.find.setFocus(Qt.OtherFocusReason) + def search_action_triggered(self, action, overrides=None): ss = self.gui.saved_searches.isVisible() trigger_saved_search = ss and (not self.gui.central.search_panel.isVisible() or self.gui.saved_searches.has_focus()) @@ -909,6 +914,18 @@ class Boss(QObject): else: self.gui.saved_searches.setFocus(Qt.OtherFocusReason) + def find_text(self, state): + from calibre.gui2.tweak_book.text_search import run_text_search + searchable_names = self.gui.file_list.searchable_names + ed = self.gui.central.current_editor + name = editor_name(ed) + if not validate_search_request(name, searchable_names, getattr(ed, 'has_marked_text', False), state, self.gui): + return + ret = run_text_search(state, ed, name, searchable_names, self.gui, self.show_editor, self.edit_file) + ed = ret is True and self.gui.central.current_editor + if getattr(ed, 'has_line_numbers', False): + ed.editor.setFocus(Qt.OtherFocusReason) + def find_word(self, word, locations): # Go to a word from the spell check dialog ed = self.gui.central.current_editor diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index 3424bbef2f..4c26b131cd 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -368,6 +368,48 @@ class TextEdit(PlainTextEdit): self.saved_matches[save_match] = (pat, m) return True + def find_text(self, pat, wrap=False, complete=False): + reverse = pat.flags & regex.REVERSE + c = self.textCursor() + c.clearSelection() + if complete: + # Search the entire text + c.movePosition(c.End if reverse else c.Start) + pos = c.Start if reverse else c.End + if wrap and not complete: + pos = c.End if reverse else c.Start + c.movePosition(pos, c.KeepAnchor) + if hasattr(self.smarts, 'find_text'): + found, start, end = self.smarts.find_text(pat, c) + if not found: + return False + else: + raw = unicode(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') + m = pat.search(raw) + if m is None: + return False + start, end = m.span() + if start == end: + return False + if wrap and not complete: + if reverse: + textpos = c.anchor() + start, end = textpos + end, textpos + start + else: + if reverse: + # Put the cursor at the start of the match + start, end = end, start + else: + textpos = c.anchor() + start, end = textpos + start, textpos + end + c.clearSelection() + c.setPosition(start) + c.setPosition(end, c.KeepAnchor) + self.setTextCursor(c) + # Center search result on screen + self.centerCursor() + return True + def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True): c = self.textCursor() c.setPosition(c.position()) diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index 519b010384..a1aed66e38 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -286,6 +286,9 @@ class Editor(QMainWindow): def find(self, *args, **kwargs): return self.editor.find(*args, **kwargs) + def find_text(self, *args, **kwargs): + return self.editor.find_text(*args, **kwargs) + def find_spell_word(self, *args, **kwargs): return self.editor.find_spell_word(*args, **kwargs) diff --git a/src/calibre/gui2/tweak_book/search.py b/src/calibre/gui2/tweak_book/search.py index 4a0e9f3a47..4986bc179e 100644 --- a/src/calibre/gui2/tweak_book/search.py +++ b/src/calibre/gui2/tweak_book/search.py @@ -1181,7 +1181,7 @@ def initialize_search_request(state, action, current_editor, current_editor_name editor = None where = state['where'] files = OrderedDict() - do_all = state['wrap'] or action in {'replace-all', 'count'} + do_all = state.get('wrap') or action in {'replace-all', 'count'} marked = False if where == 'current': editor = current_editor diff --git a/src/calibre/gui2/tweak_book/text_search.py b/src/calibre/gui2/tweak_book/text_search.py new file mode 100644 index 0000000000..1c7cc626a7 --- /dev/null +++ b/src/calibre/gui2/tweak_book/text_search.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2016, Kovid Goyal + +from __future__ import (unicode_literals, division, absolute_import, + print_function) +from functools import partial + +from PyQt5.Qt import ( + QWidget, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QPushButton, QIcon, + pyqtSignal, QFont, QCheckBox, QSizePolicy +) +from lxml.etree import tostring + +from calibre import prepare_string_for_xml +from calibre.gui2 import error_dialog +from calibre.gui2.tweak_book import tprefs, editors, current_container +from calibre.gui2.tweak_book.search import get_search_regex, InvalidRegex, initialize_search_request +from calibre.gui2.tweak_book.widgets import BusyCursor +from calibre.gui2.widgets2 import HistoryComboBox + +# UI {{{ + +class ModeBox(QComboBox): + + def __init__(self, parent): + QComboBox.__init__(self, parent) + self.addItems([_('Normal'), _('Regex')]) + self.setToolTip('' + _( + '''Select how the search expression is interpreted +
+
Normal
+
The search expression is treated as normal text, calibre will look for the exact text.
+
Regex
+
The search expression is interpreted as a regular expression. See the User Manual for more help on using regular expressions.
+
''')) + + @dynamic_property + def mode(self): + def fget(self): + return ('normal', 'regex')[self.currentIndex()] + def fset(self, val): + self.setCurrentIndex({'regex':1}.get(val, 0)) + return property(fget=fget, fset=fset) + +class WhereBox(QComboBox): + + def __init__(self, parent, emphasize=False): + QComboBox.__init__(self) + self.addItems([_('Current file'), _('All text files'), _('Selected files')]) + self.setToolTip('' + _( + ''' + Where to search/replace: +
+
Current file
+
Search only inside the currently opened file
+
All text files
+
Search in all text (HTML) files
+
Selected files
+
Search in the files currently selected in the Files Browser
+
''')) + self.emphasize = emphasize + self.ofont = QFont(self.font()) + if emphasize: + f = self.emph_font = QFont(self.ofont) + f.setBold(True), f.setItalic(True) + self.setFont(f) + + @dynamic_property + def where(self): + wm = {0:'current', 1:'text', 2:'selected'} + def fget(self): + return wm[self.currentIndex()] + def fset(self, val): + self.setCurrentIndex({v:k for k, v in wm.iteritems()}[val]) + return property(fget=fget, fset=fset) + + def showPopup(self): + # We do it like this so that the popup uses a normal font + if self.emphasize: + self.setFont(self.ofont) + QComboBox.showPopup(self) + + def hidePopup(self): + if self.emphasize: + self.setFont(self.emph_font) + QComboBox.hidePopup(self) + + +class TextSearch(QWidget): + + find_text = pyqtSignal(object) + + def __init__(self, ui): + QWidget.__init__(self, ui) + self.l = l = QVBoxLayout(self) + self.la = la = QLabel(_('&Find:')) + self.find = ft = HistoryComboBox(self) + ft.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + ft.initialize('tweak_book_text_search_history') + la.setBuddy(ft) + self.h = h = QHBoxLayout() + h.addWidget(la), h.addWidget(ft), l.addLayout(h) + + self.h2 = h = QHBoxLayout() + l.addLayout(h) + + self.mode = m = ModeBox(self) + h.addWidget(m) + self.where_box = wb = WhereBox(self) + h.addWidget(wb) + self.cs = cs = QCheckBox(_('&Case sensitive')) + h.addWidget(cs) + self.da = da = QCheckBox(_('&Dot all')) + da.setToolTip('

'+_("Make the '.' special character match any character at all, including a newline")) + h.addWidget(da) + + self.h3 = h = QHBoxLayout() + l.addLayout(h) + h.addStretch(10) + self.next_button = b = QPushButton(QIcon(I('arrow-down.png')), _('&Next'), self) + b.setToolTip(_('Find next match')) + h.addWidget(b), b.clicked.connect(partial(self.do_search, 'down')) + self.prev_button = b = QPushButton(QIcon(I('arrow-up.png')), _('&Previous'), self) + b.setToolTip(_('Find previous match')) + h.addWidget(b), b.clicked.connect(partial(self.do_search, 'up')) + + state = tprefs.get('text_search_widget_state') + self.state = state or {} + + @dynamic_property + def state(self): + def fget(self): + return {'mode': self.mode.mode, 'where':self.where_box.where, 'case_sensitive':self.cs.isChecked(), 'dot_all':self.da.isChecked()} + def fset(self, val): + self.mode.mode = val.get('mode', 'normal') + self.where_box.where = val.get('where', 'current') + self.cs.setChecked(bool(val.get('case_sensitive'))) + self.da.setChecked(bool(val.get('dot_all', True))) + return property(fget=fget, fset=fset) + + def save_state(self): + tprefs['text_search_widget_state'] = self.state + + def do_search(self, direction='down'): + state = self.state + state['find'] = self.find.text() + state['direction'] = direction + self.find_text.emit(state) +# }}} + +def run_text_search(search, current_editor, current_editor_name, searchable_names, gui_parent, show_editor, edit_file): + try: + pat = get_search_regex(search) + except InvalidRegex as e: + return error_dialog(gui_parent, _('Invalid regex'), '

' + _( + 'The regular expression you entered is invalid:

{0}
With error: {1}').format( + prepare_string_for_xml(e.regex), e.message), show=True) + editor, where, files, do_all, marked = initialize_search_request(search, 'count', current_editor, current_editor_name, searchable_names) + with BusyCursor(): + if editor is not None: + if editor.find_text(pat): + return True + if not files and editor.find_text(pat, wrap=True): + return True + for fname, syntax in files.iteritems(): + ed = editors.get(fname, None) + if ed is not None: + if ed.find_text(pat, complete=True, save_match='gui'): + show_editor(fname) + return True + else: + root = current_container().parsed(fname) + if hasattr(root, 'xpath'): + raw = tostring(root, method='text', encoding=unicode, with_tail=True) + else: + raw = current_container().raw_data(fname) + if pat.search(raw) is not None: + edit_file(fname, syntax) + if editors[fname].find_text(pat, complete=True): + return True + + msg = '

' + _('No matches were found for %s') % ('

' + prepare_string_for_xml(search['find']) + '
') + return error_dialog(gui_parent, _('Not found'), msg, show=True) diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index ec8b4fab3e..91a69ba467 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -36,6 +36,7 @@ from calibre.gui2.tweak_book.check import Check from calibre.gui2.tweak_book.check_links import CheckExternalLinks from calibre.gui2.tweak_book.spell import SpellCheck from calibre.gui2.tweak_book.search import SavedSearches +from calibre.gui2.tweak_book.text_search import TextSearch from calibre.gui2.tweak_book.toc import TOCViewer from calibre.gui2.tweak_book.char_select import CharSelect from calibre.gui2.tweak_book.live_css import LiveCSS @@ -248,6 +249,7 @@ class Main(MainWindow): self.check_book = Check(self) self.spell_check = SpellCheck(parent=self) self.toc_view = TOCViewer(self) + self.text_search = TextSearch(self) self.saved_searches = SavedSearches(self) self.image_browser = InsertImage(self, for_browsing=True) self.reports = Reports(self) @@ -450,6 +452,8 @@ class Main(MainWindow): self.action_go_to_line = reg(None, _('Go to &line'), self.boss.go_to_line_number, 'go-to-line-number', ('Ctrl+.',), _('Go to line number')) self.action_saved_searches = treg('folder_saved_search.png', _('Sa&ved searches'), self.boss.saved_searches, 'saved-searches', (), _('Show the saved searches dialog')) + self.action_text_search = treg('view.png', _('Search ignoring HTML markup'), + self.boss.show_text_search, 'text-search', (), _('Show the text search panel')) # Check Book actions group = _('Check Book') @@ -591,6 +595,8 @@ class Main(MainWindow): e.addSeparator() a(self.action_saved_searches) e.aboutToShow.connect(self.search_menu_about_to_show) + e.addSeparator() + a(self.action_text_search) if self.plugin_menu_actions: e = b.addMenu(_('&Plugins')) @@ -710,6 +716,12 @@ class Main(MainWindow): self.addDockWidget(Qt.LeftDockWidgetArea, d) d.close() # Hidden by default + d = create(_('Text Search'), 'text-search') + d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea) + d.setWidget(self.text_search) + self.addDockWidget(Qt.LeftDockWidgetArea, d) + d.close() # Hidden by default + d = create(_('Checkpoints'), 'checkpoints') d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea) self.checkpoints = CheckpointView(self.boss.global_undo, parent=d) @@ -743,6 +755,7 @@ class Main(MainWindow): self.central.save_state() self.saved_searches.save_state() self.check_book.save_state() + self.text_search.save_state() def restore_state(self): geom = tprefs.get('main_window_geometry', None)