diff options
author | Florian Bruhin <me@the-compiler.org> | 2022-06-13 15:47:31 +0200 |
---|---|---|
committer | Florian Bruhin <me@the-compiler.org> | 2022-06-13 15:47:31 +0200 |
commit | 583354d524ff42cfb24ff5618d3f8b1919e9d554 (patch) | |
tree | 6c273aa0b6bb6e5a59763d038a45d316b91defc6 | |
parent | 06587ea43a0cf44bc53b7b6da3eabfd935b0918c (diff) | |
parent | 57f0155fa0d9972f22298d1189ea54e9efa15433 (diff) | |
download | qutebrowser-583354d524ff42cfb24ff5618d3f8b1919e9d554.tar.gz qutebrowser-583354d524ff42cfb24ff5618d3f8b1919e9d554.zip |
Merge remote-tracking branch 'origin/pr/6670' into dev
-rw-r--r-- | qutebrowser/browser/browsertab.py | 17 | ||||
-rw-r--r-- | qutebrowser/browser/commands.py | 36 | ||||
-rw-r--r-- | qutebrowser/browser/webengine/webenginetab.py | 135 | ||||
-rw-r--r-- | qutebrowser/config/configdata.yml | 10 | ||||
-rw-r--r-- | qutebrowser/mainwindow/mainwindow.py | 3 | ||||
-rw-r--r-- | qutebrowser/mainwindow/statusbar/bar.py | 9 | ||||
-rw-r--r-- | qutebrowser/mainwindow/statusbar/searchmatch.py | 49 | ||||
-rw-r--r-- | qutebrowser/mainwindow/tabbedbrowser.py | 6 | ||||
-rw-r--r-- | scripts/dev/check_coverage.py | 2 | ||||
-rw-r--r-- | tests/end2end/features/search.feature | 49 |
10 files changed, 223 insertions, 93 deletions
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 699fe1b0b..f8ac3d24c 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -295,14 +295,23 @@ class AbstractSearch(QObject): text: The last thing this view was searched for. search_displayed: Whether we're currently displaying search results in this view. + current_match: The currently active search match on the page. + 0 if no search is active or the feature isn't available. + total_match_count: The total number of search matches on the page. + 0 if no search is active or the feature isn't available. _flags: The flags of the last search (needs to be set by subclasses). _widget: The underlying WebView widget. + + Signals: + finished: A search has finished. True if the text was found, false otherwise. + search_match_changed: The currently active search match has changed. + Emits (0, 0) if no search is active. + Will not be emitted if search matches are not available. + cleared: An existing search was cleared. """ - #: Signal emitted when a search was finished - #: (True if the text was found, False otherwise) finished = pyqtSignal(bool) - #: Signal emitted when an existing search was cleared. + search_match_changed = pyqtSignal(int, int) cleared = pyqtSignal() _Callback = Callable[[bool], None] @@ -313,6 +322,8 @@ class AbstractSearch(QObject): self._widget = cast(_WidgetType, None) self.text: Optional[str] = None self.search_displayed = False + self.current_match = 0 + self.total_match_count = 0 def _is_case_sensitive(self, ignore_case: usertypes.IgnoreCase) -> bool: """Check if case-sensitivity should be used. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 4f782c3ee..939f02108 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1538,13 +1538,14 @@ class CommandDispatcher: message.error(str(e)) ed.backup() - def _search_cb(self, found, *, tab, old_scroll_pos, options, text, prev): + def _search_cb(self, found, *, tab, old_current_match, options, text, prev): """Callback called from search/search_next/search_prev. Args: found: Whether the text was found. tab: The AbstractTab in which the search was made. - old_scroll_pos: The scroll position (QPoint) before the search. + old_current_match: The previously active match before the search + was performed. options: The options (dict) the search was made with. text: The text searched for. prev: Whether we're searching backwards (i.e. :search-prev) @@ -1556,11 +1557,24 @@ class CommandDispatcher: going_up = options['reverse'] ^ prev if found: - # Check if the scroll position got smaller and show info. - if not going_up and tab.scroller.pos_px().y() < old_scroll_pos.y(): - message.info("Search hit BOTTOM, continuing at TOP") - elif going_up and tab.scroller.pos_px().y() > old_scroll_pos.y(): - message.info("Search hit TOP, continuing at BOTTOM") + if not config.val.search.wrap_messages: + return + + new_current_match = tab.search.current_match + # Check if the match count change is opposite to the search direction + if old_current_match > 0: + if not going_up: + if old_current_match > new_current_match: + message.info("Search hit BOTTOM, continuing at TOP", + replace="search-hit-msg") + elif old_current_match == new_current_match: + message.info("Search hit BOTTOM", replace="search-hit-msg") + elif going_up: + if old_current_match < new_current_match: + message.info("Search hit TOP, continuing at BOTTOM", + replace="search-hit-msg") + elif old_current_match == new_current_match: + message.info("Search hit TOP", replace="search-hit-msg") else: message.warning(f"Text '{text}' not found on page!", replace='find-in-page') @@ -1591,8 +1605,8 @@ class CommandDispatcher: self._tabbed_browser.search_options = dict(options) cb = functools.partial(self._search_cb, tab=tab, - old_scroll_pos=tab.scroller.pos_px(), - options=options, text=text, prev=False) + old_current_match=0, options=options, + text=text, prev=False) options['result_cb'] = cb tab.scroller.before_jump_requested.emit() @@ -1624,7 +1638,7 @@ class CommandDispatcher: return cb = functools.partial(self._search_cb, tab=tab, - old_scroll_pos=tab.scroller.pos_px(), + old_current_match=tab.search.current_match, options=window_options, text=window_text, prev=False) @@ -1658,7 +1672,7 @@ class CommandDispatcher: return cb = functools.partial(self._search_cb, tab=tab, - old_scroll_pos=tab.scroller.pos_px(), + old_current_match=tab.search.current_match, options=window_options, text=window_text, prev=True) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 0a2333afc..1d02f7e99 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -104,83 +104,33 @@ class _WebEngineSearchWrapHandler: Attributes: flag_wrap: An additional flag indicating whether the last search used wrapping. - _active_match: The 1-based index of the currently active match - on the page. - _total_matches: The total number of search matches on the page. - _nowrap_available: Whether the functionality to prevent wrapping - is available. + nowrap_available: Whether the functionality to prevent wrapping + is available. """ def __init__(self): - self._active_match = 0 - self._total_matches = 0 self.flag_wrap = True - self._nowrap_available = False + self.nowrap_available = False - def connect_signal(self, page): - """Connect to the findTextFinished signal of the page. - - Args: - page: The QtWebEnginePage to connect to this handler. - """ - if not qtutils.version_check("5.14"): - return - - try: - # pylint: disable=unused-import - from PyQt5.QtWebEngineCore import QWebEngineFindTextResult - except ImportError: - # WORKAROUND for some odd PyQt/packaging bug where the - # findTextResult signal is available, but QWebEngineFindTextResult - # is not. Seems to happen on e.g. Gentoo. - log.webview.warning("Could not import QWebEngineFindTextResult " - "despite running on Qt 5.14. You might need " - "to rebuild PyQtWebEngine.") - return - - page.findTextFinished.connect(self._store_match_data) - self._nowrap_available = True - - def _store_match_data(self, result): - """Store information on the last match. - - The information will be checked against when wrapping is turned off. - - Args: - result: A FindTextResult passed by the findTextFinished signal. - """ - self._active_match = result.activeMatch() - self._total_matches = result.numberOfMatches() - log.webview.debug("Active search match: {}/{}" - .format(self._active_match, self._total_matches)) - - def reset_match_data(self): - """Reset match information. - - Stale information could lead to next_result or prev_result misbehaving. - """ - self._active_match = 0 - self._total_matches = 0 - - def prevent_wrapping(self, *, going_up): + def prevent_wrapping(self, current_match, total_match_count, *, going_up): """Prevent wrapping if possible and required. Returns True if a wrap was prevented and False if not. Args: + current_match: The currently active search match on the page. + total_match_count: The total number of search matches on the page. going_up: Whether the search would scroll the page up or down. """ - if (not self._nowrap_available or - self.flag_wrap or self._total_matches == 0): - return False - elif going_up and self._active_match == 1: - message.info("Search hit TOP") - return True - elif not going_up and self._active_match == self._total_matches: - message.info("Search hit BOTTOM") - return True - else: - return False + return ( + self.nowrap_available and + not self.flag_wrap and + total_match_count != 0 and + ( + going_up and current_match == 1 or + not going_up and current_match == total_match_count + ) + ) class WebEngineSearch(browsertab.AbstractSearch): @@ -191,6 +141,7 @@ class WebEngineSearch(browsertab.AbstractSearch): _flags: The QWebEnginePage.FindFlags of the last search. _pending_searches: How many searches have been started but not called back yet. + """ _widget: webview.WebEngineView @@ -199,7 +150,6 @@ class WebEngineSearch(browsertab.AbstractSearch): super().__init__(tab, parent) self._flags = self._empty_flags() self._pending_searches = 0 - # The API necessary to stop wrapping was added in this version self._wrap_handler = _WebEngineSearchWrapHandler() def _empty_flags(self): @@ -214,7 +164,25 @@ class WebEngineSearch(browsertab.AbstractSearch): return flags def connect_signals(self): - self._wrap_handler.connect_signal(self._widget.page()) + """Connect the signals necessary for this class to function.""" + # The API necessary to stop wrapping was added in this version + if not qtutils.version_check("5.14"): + return + + try: + # pylint: disable=unused-import + from PyQt5.QtWebEngineCore import QWebEngineFindTextResult + except ImportError: + # WORKAROUND for some odd PyQt/packaging bug where the + # findTextResult signal is available, but QWebEngineFindTextResult + # is not. Seems to happen on e.g. Gentoo. + log.webview.warning("Could not import QWebEngineFindTextResult " + "despite running on Qt 5.14. You might need " + "to rebuild PyQtWebEngine.") + return + + self._wrap_handler.nowrap_available = True + self._widget.page().findTextFinished.connect(self._on_find_finished) def _find(self, text, flags, callback, caller): """Call findText on the widget.""" @@ -254,6 +222,14 @@ class WebEngineSearch(browsertab.AbstractSearch): self._widget.page().findText(text, flags, wrapped_callback) + def _on_find_finished(self, find_text_result): + """Unwrap the result, store it, and pass it along.""" + self.current_match = find_text_result.activeMatch() + self.total_match_count = find_text_result.numberOfMatches() + log.webview.debug("Active search match: {}/{}" + .format(self.current_match, self.total_match_count)) + self.search_match_changed.emit(self.current_match, self.total_match_count) + def search(self, text, *, ignore_case=usertypes.IgnoreCase.never, reverse=False, wrap=True, result_cb=None): # Don't go to next entry on duplicate search @@ -265,7 +241,7 @@ class WebEngineSearch(browsertab.AbstractSearch): self.text = text self._flags = self._args_to_flags(reverse, ignore_case) - self._wrap_handler.reset_match_data() + self._reset_match_data() self._wrap_handler.flag_wrap = wrap self._find(text, self._flags, result_cb, 'search') @@ -273,29 +249,44 @@ class WebEngineSearch(browsertab.AbstractSearch): def clear(self): if self.search_displayed: self.cleared.emit() + self.search_match_changed.emit(0, 0) self.search_displayed = False - self._wrap_handler.reset_match_data() + self._reset_match_data() self._widget.page().findText('') def prev_result(self, *, result_cb=None): # The int() here makes sure we get a copy of the flags. flags = QWebEnginePage.FindFlags(int(self._flags)) if flags & QWebEnginePage.FindBackward: - if self._wrap_handler.prevent_wrapping(going_up=False): + if self._wrap_handler.prevent_wrapping(self.current_match, + self.total_match_count, going_up=False): + result_cb(True) return flags &= ~QWebEnginePage.FindBackward else: - if self._wrap_handler.prevent_wrapping(going_up=True): + if self._wrap_handler.prevent_wrapping(self.current_match, + self.total_match_count, going_up=True): + result_cb(True) return flags |= QWebEnginePage.FindBackward self._find(self.text, flags, result_cb, 'prev_result') def next_result(self, *, result_cb=None): going_up = self._flags & QWebEnginePage.FindBackward - if self._wrap_handler.prevent_wrapping(going_up=going_up): + if self._wrap_handler.prevent_wrapping(self.current_match, + self.total_match_count, going_up=going_up): + result_cb(True) return self._find(self.text, self._flags, result_cb, 'next_result') + def _reset_match_data(self): + """Reset match counter information. + + Stale information could lead to next_result or prev_result misbehaving. + """ + self.current_match = 0 + self.total_match_count = 0 + class WebEngineCaret(browsertab.AbstractCaret): diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 5f348d102..4da003b37 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -72,6 +72,13 @@ search.wrap: Wrap around at the top and bottom of the page when advancing through text matches using `:search-next` and `:search-prev`. +search.wrap_messages: + type: Bool + default: true + desc: >- + Display messages when advancing through text matches at the top and bottom + of the page, e.g. `Search hit TOP`. + new_instance_open_target: type: name: String @@ -2058,6 +2065,7 @@ statusbar.widgets: - scroll_raw: "Raw percentage of the current page position like `10`." - history: "Display an arrow when possible to go back/forward in history." + - search_match: "A match count when searching, e.g. `Match [2/10]`." - tabs: "Current active tab, e.g. `2`." - keypress: "Display pressed keys when composing a vi command." - progress: "Progress bar for the current page loading." @@ -2067,7 +2075,7 @@ statusbar.widgets: https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes[the Python datetime documentation]." none_ok: true - default: ['keypress', 'url', 'scroll', 'history', 'tabs', 'progress'] + default: ['keypress', 'search_match', 'url', 'scroll', 'history', 'tabs', 'progress'] desc: "List of widgets displayed in the statusbar." ## tabs diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index b247da632..c9744cfc1 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -537,6 +537,9 @@ class MainWindow(QWidget): self.tabbed_browser.cur_load_status_changed.connect( self.status.url.on_load_status_changed) + self.tabbed_browser.cur_search_match_changed.connect( + self.status.search_match.set_match_index) + self.tabbed_browser.cur_caret_selection_toggled.connect( self.status.on_caret_selection_toggled) diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index ae33a386a..eaf60db3e 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -31,7 +31,7 @@ from qutebrowser.keyinput import modeman from qutebrowser.utils import usertypes, log, objreg, utils from qutebrowser.mainwindow.statusbar import (backforward, command, progress, keystring, percentage, url, - tabindex, textbase, clock) + tabindex, textbase, clock, searchmatch) @dataclasses.dataclass @@ -143,6 +143,7 @@ class StatusBar(QWidget): url: The UrlText widget in the statusbar. prog: The Progress widget in the statusbar. cmd: The Command widget in the statusbar. + search_match: The SearchMatch widget in the statusbar. _hbox: The main QHBoxLayout. _stack: The QStackedLayout with cmd/txt widgets. _win_id: The window ID the statusbar is associated with. @@ -193,6 +194,8 @@ class StatusBar(QWidget): self.cmd.hide_cmd.connect(self._hide_cmd_widget) self._hide_cmd_widget() + self.search_match = searchmatch.SearchMatch() + self.url = url.UrlText() self.percentage = percentage.Percentage() self.backforward = backforward.Backforward() @@ -225,6 +228,8 @@ class StatusBar(QWidget): return self.keystring elif key == 'progress': return self.prog + elif key == 'search_match': + return self.search_match elif key.startswith('text:'): new_text_widget = textbase.TextBase() self._text_widgets.append(new_text_widget) @@ -243,7 +248,7 @@ class StatusBar(QWidget): elif option == 'statusbar.widgets': self._draw_widgets() - def _draw_widgets(self): + def _draw_widgets(self): # noqa: C901 """Draw statusbar widgets.""" self._clear_widgets() diff --git a/qutebrowser/mainwindow/statusbar/searchmatch.py b/qutebrowser/mainwindow/statusbar/searchmatch.py new file mode 100644 index 000000000..6e9a435de --- /dev/null +++ b/qutebrowser/mainwindow/statusbar/searchmatch.py @@ -0,0 +1,49 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. + +"""The search match indicator in the statusbar.""" + + +from PyQt5.QtCore import pyqtSlot + +from qutebrowser.utils import log +from qutebrowser.mainwindow.statusbar import textbase + + +class SearchMatch(textbase.TextBase): + + """The part of the statusbar that displays the search match counter.""" + + @pyqtSlot(int, int) + def set_match_index(self, current: int, total: int) -> None: + """Set the match counts in the statusbar. + + Passing (0, 0) hides the match counter. + + Args: + current: The currently active search match. + total: The total number of search matches on the page. + """ + if current <= 0 and total <= 0: + self.setText('') + log.statusbar.debug('Clearing search match text.') + else: + self.setText('Match [{}/{}]'.format(current, total)) + log.statusbar.debug('Setting search match text to {}/{}' + .format(current, total)) diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 68b4adfdb..9a98b3d70 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -185,6 +185,7 @@ class TabbedBrowser(QWidget): arg 1: x-position in %. arg 2: y-position in %. cur_load_status_changed: Loading status of current tab changed. + cur_search_match_changed: The active search match changed. close_window: The last tab was closed, close this window. resized: Emitted when the browser window has resized, so the completion widget can adjust its size to it. @@ -201,6 +202,7 @@ class TabbedBrowser(QWidget): cur_link_hovered = pyqtSignal(str) cur_scroll_perc_changed = pyqtSignal(int, int) cur_load_status_changed = pyqtSignal(usertypes.LoadStatus) + cur_search_match_changed = pyqtSignal(int, int) cur_fullscreen_requested = pyqtSignal(bool) cur_caret_selection_toggled = pyqtSignal(browsertab.SelectionState) close_window = pyqtSignal() @@ -347,6 +349,8 @@ class TabbedBrowser(QWidget): self._filter.create(self.cur_fullscreen_requested, tab)) tab.caret.selection_toggled.connect( self._filter.create(self.cur_caret_selection_toggled, tab)) + tab.search.search_match_changed.connect( + self._filter.create(self.cur_search_match_changed, tab)) # misc tab.scroller.perc_changed.connect(self._on_scroll_pos_changed) tab.scroller.before_jump_requested.connect(lambda: self.set_mark("'")) @@ -901,6 +905,8 @@ class TabbedBrowser(QWidget): .format(current_mode.name, mode_on_change)) self._now_focused = tab self.current_tab_changed.emit(tab) + self.cur_search_match_changed.emit(tab.search.current_match, + tab.search.total_match_count) QTimer.singleShot(0, self._update_window_title) self._tab_insert_idx_left = self.widget.currentIndex() self._tab_insert_idx_right = self.widget.currentIndex() + 1 diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 8f1d2df2b..0f8b23554 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -139,6 +139,8 @@ PERFECT_FILES = [ (None, 'qutebrowser/mainwindow/statusbar/keystring.py'), + (None, + 'qutebrowser/mainwindow/statusbar/searchmatch.py'), ('tests/unit/mainwindow/statusbar/test_percentage.py', 'qutebrowser/mainwindow/statusbar/percentage.py'), ('tests/unit/mainwindow/statusbar/test_progress.py', diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index 305b45690..86ceab70e 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -237,13 +237,13 @@ Feature: Searching on a page Scenario: Preventing wrapping at the top of the page with QtWebEngine When I set search.ignore_case to always And I set search.wrap to false + And I set search.wrap_messages to true And I run :search --reverse foo And I wait for "search found foo with flags FindBackward" in the log And I run :search-next And I wait for "next_result found foo with flags FindBackward" in the log And I run :search-next - And I wait for "Search hit TOP" in the log - Then "foo" should be found + Then the message "Search hit TOP" should be shown @qtwebkit_skip @qt>=5.14 Scenario: Preventing wrapping at the bottom of the page with QtWebEngine @@ -254,8 +254,7 @@ Feature: Searching on a page And I run :search-next And I wait for "next_result found foo" in the log And I run :search-next - And I wait for "Search hit BOTTOM" in the log - Then "Foo" should be found + Then the message "Search hit BOTTOM" should be shown @qtwebengine_skip Scenario: Preventing wrapping at the top of the page with QtWebKit @@ -281,6 +280,48 @@ Feature: Searching on a page And I wait for "next_result didn't find foo" in the log Then the warning "Text 'foo' not found on page!" should be shown + ## search match counter + + @qtwebkit_skip @qt>=5.14 + Scenario: Setting search match counter on search + When I set search.ignore_case to always + And I set search.wrap to true + And I run :search ba + And I wait for "search found ba" in the log + Then "Setting search match text to 1/5" should be logged + + @qtwebkit_skip @qt>=5.14 + Scenario: Updating search match counter on search-next + When I set search.ignore_case to always + And I set search.wrap to true + And I run :search ba + And I wait for "search found ba" in the log + And I run :search-next + And I wait for "next_result found ba" in the log + And I run :search-next + And I wait for "next_result found ba" in the log + Then "Setting search match text to 3/5" should be logged + + @qtwebkit_skip @qt>=5.14 + Scenario: Updating search match counter on search-prev with wrapping + When I set search.ignore_case to always + And I set search.wrap to true + And I run :search ba + And I wait for "search found ba" in the log + And I run :search-prev + And I wait for the message "Search hit TOP, continuing at BOTTOM" + Then "Setting search match text to 5/5" should be logged + + @qtwebkit_skip @qt>=5.14 + Scenario: Updating search match counter on search-prev without wrapping + When I set search.ignore_case to always + And I set search.wrap to false + And I run :search ba + And I wait for "search found ba" in the log + And I run :search-prev + And I wait for the message "Search hit TOP" + Then "Setting search match text to 1/5" should be logged + ## follow searched links @skip # Too flaky Scenario: Follow a searched link |