Repository: qutebrowser/qutebrowser
Pull request: Add statusbar match counter and option to silence search wrap messages, fix wrap messages not displaying
<!-- Thanks for submitting a pull request! Please pick a descriptive title (not just "issue 12345"). If there is an open issue associated to your PR, please add a line like "Closes #12345" somewhere in the PR description (outside of this comment) -->

Fixes #3910, #6345, #5351.

This PR adds a match counter to the statusbar when starting a search and using `:search-prev` and `:search-next`, which is between the keystring and URL display by default (but can be moved and disabled like the other statusbar widgets).

It also adds an option to disable the search wrapping messages (`Search hit TOP` and so on) and restructures some of the QtWebEngine searching code in general.

I removed the janky "check scroll position to determine whether we wrapped around" code and replaced it with a match counter comparison, which does mean that the wrapping messages will no longer display on `qt < 5.14`, but will now work even when wrapping doesn't cause scrolling (because all the results are grouped together or the page is very short). If the messages disappearing on older versions is a problem, I can reinsert the scroll position-checking code.

Some things that aren't quite done yet:
- [x] Some lints need fixing
- [x] There aren't any tests for the new functionality yet


Problem statement:
Feature Request: Display match count for text search (e.g., '2 of 12')
I can't imagine I'm the first person who wanted to see this implemented, but I couldn't find it in the existing issues anywhere, and couldn't find an existing config option for it (looked under `search.*`).

When searching for text on a page (_i.e.,_ with `/`), it would be nice to have a visual indication of how many total matches there are, and the position of the current match. That is, something like "2 of 12 matches".

Hints from issue discussion:
This would be really useful, but I don't think that qtwebengine [has an api](https://doc.qt.io/qt-5/qwebenginepage.html#findText-1) for this as of now. I think that the other qtwebengine browsers would benefit from this as well, maybe someon should put in a bug report to qt.

This is upstream as [QTBUG-50420](https://bugreports.qt.io/browse/QTBUG-50420) - as long as that doesn't happen, nothing qutebrowser can do here.
This is actually possible since Qt 5.14 with the [QWebEngineFindTextResult](https://doc.qt.io/qt-5/qwebenginefindtextresult.html) added there.

@phesch perhaps this is something you want to look at (if you're still using qutebrowser), given your work on #5352?
Sounds like a good idea. While I'm at it, maybe I can find some more info on #6459 or #5351 (or #6345 - searching in qutebrowser seems to have loads of minor bugs / annoyances :P ).
Unfortunately the QtWebEngine search API is rather quirky, so some of those things are a bit hacky indeed. There might be places where they can be improved, but some of those issues might not be fixable at all I think.
Yeah, I saw your issue #5652. Maybe the fixes aren't worth it, and more effort should be put into an alternative search engine instead? I remember working on the search code being quite the mess, considering you have to maintain state while waiting for callbacks and such. In fact, thinking back, there may be some concurrency bugs in there as well.
Some of its API changed with Qt 6, maybe some things are better there, I didn't take a closer look yet.

I think for "normal" searches we should probably still use the Chromium search, because I expect it to be much faster than the JS one - so the fixes are probably worth it either way.
I just had a look at the Qt 6 docs and it doesn't seem like any of the search-relevant functions or types have changed. Anyway, I'll take a look at this over the next few days.
One I'm aware of is [QtWebEngine calling the callback with QWebEngineFindTextResult](https://codereview.qt-project.org/c/qt/qtwebengine/+/359136) (instead of a bool and having the signal). There might be more, but I can't see anything off-hand.
So since this issue invites a restructuring of the GUI for searching, here are some options I came up with for presenting search-related info in the browser:

1. Integrate the match counter into the status bar next to the URL. The "text not found" message would still be presented as a message, since things could get crowded down there otherwise.
2. Place another full-length bar above the status bar when you start a search that contains all search-related info.
3. Don't go full-length - instead, make it a widget that's only as wide as its contents and place it in one of the four corners of a tab.

What do you think? (No rush, I'm busy studying right now too :) )

Commit message: Add setting to disable wrap messages, fix lints

Patch to review:
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index b1827dbf4f7..60e43fbed9d 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -364,6 +364,22 @@ def next_result(self, *, result_cb: _Callback = None) -> None:
         """
         raise NotImplementedError
 
+    def get_current_match(self) -> int:
+        """Get the 1-based index of the currently highlighted match on the page.
+
+        Returns 0 if no successful search has been performed or this feature is unavailable
+        (QtWebKit and QtWebEngine < 5.14)
+        """
+        raise NotImplementedError
+
+    def get_total_match_count(self) -> int:
+        """Get the total number of search matches on the page.
+
+        Returns 0 if no successful search has been performed or this feature is unavailable
+        (QtWebKit and QtWebEngine < 5.14)
+        """
+        raise NotImplementedError
+
 
 class AbstractZoom(QObject):
 
@@ -910,6 +926,8 @@ class AbstractTab(QWidget):
     icon_changed = pyqtSignal(QIcon)
     #: Signal emitted when a page's title changed (new title as str)
     title_changed = pyqtSignal(str)
+    #: Signal emitted when a page's currently active search match changed (match as current/total)
+    search_match_changed = pyqtSignal(int, int)
     #: Signal emitted when this tab was pinned/unpinned (new pinned state as bool)
     pinned_changed = pyqtSignal(bool)
     #: Signal emitted when a new tab should be opened (url as QUrl)
@@ -1197,6 +1215,9 @@ def title(self) -> str:
     def icon(self) -> None:
         raise NotImplementedError
 
+    def current_search_match(self) -> (int, int):
+        raise NotImplementedError
+
     def set_html(self, html: str, base_url: QUrl = QUrl()) -> None:
         raise NotImplementedError
 
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index 8cd73ae4f9b..cefebf4f645 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -1528,13 +1528,13 @@ def on_file_updated(self, ed, elem, text):
             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)
@@ -1546,11 +1546,24 @@ def _search_cb(self, found, *, tab, old_scroll_pos, options, text, prev):
         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.get_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')
@@ -1581,8 +1594,8 @@ def search(self, text="", reverse=False):
         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()
@@ -1614,7 +1627,7 @@ def search_next(self, count=1):
             return
 
         cb = functools.partial(self._search_cb, tab=tab,
-                               old_scroll_pos=tab.scroller.pos_px(),
+                               old_current_match=tab.search.get_current_match(),
                                options=window_options, text=window_text,
                                prev=False)
 
@@ -1648,7 +1661,7 @@ def search_prev(self, count=1):
             return
 
         cb = functools.partial(self._search_cb, tab=tab,
-                               old_scroll_pos=tab.scroller.pos_px(),
+                               old_current_match=tab.search.get_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 ace23d14ab4..cfa5d03ceeb 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -120,21 +120,6 @@ def connect_signal(self, 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
 
@@ -159,6 +144,22 @@ def reset_match_data(self):
         self._active_match = 0
         self._total_matches = 0
 
+    def get_active_match(self):
+        """Get the active search match as tracked by this handler.
+
+        Returns 0 if no search is active or this feature is unavailable
+        (QtWebKit or QtWebEngine < 5.14)
+        """
+        return self._active_match
+
+    def get_total_match_count(self):
+        """Get the total search match count as tracked by this handler.
+
+        Returns 0 if no search is active or this feature is unavailable
+        (QtWebKit or QtWebEngine < 5.14)
+        """
+        return self._total_matches
+
     def prevent_wrapping(self, *, going_up):
         """Prevent wrapping if possible and required.
 
@@ -171,13 +172,8 @@ def prevent_wrapping(self, *, going_up):
                 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 not going_up and self._active_match == self._total_matches
 
 
 class WebEngineSearch(browsertab.AbstractSearch):
@@ -188,20 +184,42 @@ 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.
+
+    Signals:
+        search_match_changed: The currently active search match has changed.
+                              Emits (0, 0) if no search is active.
     """
 
+    search_match_changed = pyqtSignal(int, int)
+
     def __init__(self, tab, parent=None):
         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):
         return QWebEnginePage.FindFlags(0)  # type: ignore[call-overload]
 
     def connect_signals(self):
+        # 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.connect_signal(self._widget.page())
+        self._widget.page().findTextFinished.connect(self._on_find_finished)
 
     def _find(self, text, flags, callback, caller):
         """Call findText on the widget."""
@@ -241,6 +259,11 @@ def wrapped_callback(found):
 
         self._widget.page().findText(text, flags, wrapped_callback)
 
+    def _on_find_finished(self, find_text_result):
+        """Unwrap the QWebEngineFindTextResult and pass it along."""
+        self.search_match_changed.emit(find_text_result.activeMatch(),
+                                       find_text_result.numberOfMatches())
+
     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
@@ -263,6 +286,7 @@ def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
     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._widget.page().findText('')
@@ -273,10 +297,12 @@ def prev_result(self, *, result_cb=None):
             int(self._flags))  # type: ignore[call-overload]
         if flags & QWebEnginePage.FindBackward:
             if self._wrap_handler.prevent_wrapping(going_up=False):
+                result_cb(True)
                 return
             flags &= ~QWebEnginePage.FindBackward
         else:
             if self._wrap_handler.prevent_wrapping(going_up=True):
+                result_cb(True)
                 return
             flags |= QWebEnginePage.FindBackward
         self._find(self.text, flags, result_cb, 'prev_result')
@@ -284,9 +310,16 @@ def prev_result(self, *, result_cb=None):
     def next_result(self, *, result_cb=None):
         going_up = self._flags & QWebEnginePage.FindBackward
         if self._wrap_handler.prevent_wrapping(going_up=going_up):
+            result_cb(True)
             return
         self._find(self.text, self._flags, result_cb, 'next_result')
 
+    def get_current_match(self):
+        return self._wrap_handler.get_active_match()
+
+    def get_total_match_count(self):
+        return self._wrap_handler.get_total_match_count()
+
 
 class WebEngineCaret(browsertab.AbstractCaret):
 
@@ -1369,6 +1402,9 @@ def renderer_process_pid(self) -> Optional[int]:
     def icon(self):
         return self._widget.icon()
 
+    def current_search_match(self) -> (int, int):
+        return (self.search.get_current_match(), self.search.get_total_match_count())
+
     def set_html(self, html, base_url=QUrl()):
         # FIXME:qtwebengine
         # check this and raise an exception if too big:
@@ -1613,6 +1649,11 @@ def _on_url_changed(self, url: QUrl) -> None:
                 not qtutils.version_check('5.14')):
             self.settings.update_for_url(url)
 
+    @pyqtSlot(int, int)
+    def _on_search_match_changed(self, current: int, total: int):
+        """Pass the signal from the WebEngineSearch along."""
+        self.search_match_changed.emit(current, total)
+
     @pyqtSlot(usertypes.NavigationRequest)
     def _on_navigation_request(self, navigation):
         super()._on_navigation_request(navigation)
@@ -1689,6 +1730,8 @@ def _connect_signals(self):
         page.loadFinished.connect(self._restore_zoom)
         page.loadFinished.connect(self._on_load_finished)
 
+        self.search.search_match_changed.connect(self._on_search_match_changed)
+
         try:
             page.renderProcessPidChanged.connect(self._on_renderer_process_pid_changed)
         except AttributeError:
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index df3491ec2e4..58e753c3f6d 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -189,6 +189,13 @@ def prev_result(self, *, result_cb=None):
         found = self._widget.findText(self.text, flags)
         self._call_cb(result_cb, found, self.text, flags, 'prev_result')
 
+    # These aren't supported when using the QtWebKit backend.
+    def get_current_match(self):
+        return 0
+
+    def get_total_match_count(self):
+        return 0
+
 
 class WebKitCaret(browsertab.AbstractCaret):
 
@@ -878,6 +885,10 @@ def run_js_async(self, code, callback=None, *, world=None):
     def icon(self):
         return self._widget.icon()
 
+    def current_search_match(self):
+        """QtWebKit doesn't support match counts."""
+        return (0, 0)
+
     def reload(self, *, force=False):
         if force:
             action = QWebPage.ReloadAndBypassCache
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 5f5a71b061d..c41ce083bda 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
@@ -1963,12 +1970,13 @@ 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."
         - 'text:foo': "Display the static text after the colon, `foo` in the example."
     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 d0723742aa5..011e4ddcb51 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -537,6 +537,9 @@ def _connect_signals(self):
         self.tabbed_browser.cur_load_status_changed.connect(
             self.status.url.on_load_status_changed)
 
+        self.tabbed_browser.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 8bad290beec..6daf22752c5 100644
--- a/qutebrowser/mainwindow/statusbar/bar.py
+++ b/qutebrowser/mainwindow/statusbar/bar.py
@@ -32,7 +32,7 @@
 from qutebrowser.utils import usertypes, log, objreg, utils
 from qutebrowser.mainwindow.statusbar import (backforward, command, progress,
                                               keystring, percentage, url,
-                                              tabindex, textbase)
+                                              tabindex, textbase, searchmatch)
 
 
 @dataclasses.dataclass
@@ -144,6 +144,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.
@@ -194,6 +195,8 @@ def __init__(self, *, win_id, private, parent=None):
         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()
@@ -226,7 +229,9 @@ def _draw_widgets(self):
 
         # Read the list and set widgets accordingly
         for segment in config.val.statusbar.widgets:
-            if segment == 'url':
+            if segment == 'search_match':
+                self._hbox.addWidget(self.search_match)
+            elif segment == 'url':
                 self._hbox.addWidget(self.url)
                 self.url.show()
             elif segment == 'scroll':
diff --git a/qutebrowser/mainwindow/statusbar/searchmatch.py b/qutebrowser/mainwindow/statusbar/searchmatch.py
new file mode 100644
index 00000000000..9c4e67c324a
--- /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.config import config
+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 e081284eeba..0f3c618859c 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -196,6 +196,7 @@ class TabbedBrowser(QWidget):
     cur_link_hovered = pyqtSignal(str)
     cur_scroll_perc_changed = pyqtSignal(int, int)
     cur_load_status_changed = pyqtSignal(usertypes.LoadStatus)
+    search_match_changed = pyqtSignal(int, int)
     cur_fullscreen_requested = pyqtSignal(bool)
     cur_caret_selection_toggled = pyqtSignal(browsertab.SelectionState)
     close_window = pyqtSignal()
@@ -346,6 +347,8 @@ def _connect_tab_signals(self, tab):
             functools.partial(self._on_title_changed, tab))
         tab.icon_changed.connect(
             functools.partial(self._on_icon_changed, tab))
+        tab.search_match_changed.connect(
+            functools.partial(self._on_search_match_changed, tab))
         tab.pinned_changed.connect(
             functools.partial(self._on_pinned_changed, tab))
         tab.load_progress.connect(
@@ -793,6 +796,16 @@ def _on_icon_changed(self, tab):
             return
         self.widget.update_tab_favicon(tab)
 
+    @pyqtSlot(browsertab.AbstractTab, int, int)
+    def _on_search_match_changed(self, tab, current: int, total: int):
+        """Pass along the signal of a changed active match.
+
+        Args:
+            current: The number of the currently active match.
+            total: The total number of matches on the page.
+        """
+        self.search_match_changed.emit(current, total)
+
     @pyqtSlot(usertypes.KeyMode)
     def on_mode_entered(self, mode):
         """Save input mode when tabs.mode_on_change = restore."""
@@ -851,6 +864,7 @@ def _on_current_changed(self, idx):
                         .format(current_mode.name, mode_on_change))
         self._now_focused = tab
         self.current_tab_changed.emit(tab)
+        self.search_match_changed.emit(*tab.current_search_match())
         QTimer.singleShot(0, self._update_window_title)
         self._tab_insert_idx_left = self.widget.currentIndex()
         self._tab_insert_idx_right = self.widget.currentIndex() + 1


Code review comments:
diff_hunk: @@ -171,13 +172,8 @@ def prevent_wrapping(self, *, going_up):
                 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 not going_up and self._active_match == self._total_matches
path: qutebrowser/browser/webengine/webenginetab.py
text: @user1:
Maybe combine this into a single expression while you're at it? Something like:

```python
return (
    self._nowrap_available and
    self.flag_wrap and
    self._total_matches != 0 and
    (
        (going_up and self._active_match == 1) or
        (not going_up and self._active_match = self._total_matches)
    )
)
```

(untested)

@author:
I've done that in the new version, but I find the expression to be very complicated - it contains more than eight logical operators, after all. But if you think it's more readable this way, then I'm fine with that.

diff_hunk: @@ -188,20 +184,42 @@ 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.
+
+    Signals:
+        search_match_changed: The currently active search match has changed.
+                              Emits (0, 0) if no search is active.
     """
 
+    search_match_changed = pyqtSignal(int, int)
path: qutebrowser/browser/webengine/webenginetab.py
text: @user1:
If you define this in `AbstractSearch`, you won't need to duplicate it here.

diff_hunk: @@ -273,20 +297,29 @@ def prev_result(self, *, result_cb=None):
             int(self._flags))  # type: ignore[call-overload]
         if flags & QWebEnginePage.FindBackward:
             if self._wrap_handler.prevent_wrapping(going_up=False):
+                result_cb(True)
                 return
             flags &= ~QWebEnginePage.FindBackward
         else:
             if self._wrap_handler.prevent_wrapping(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):
+            result_cb(True)
             return
         self._find(self.text, self._flags, result_cb, 'next_result')
 
+    def get_current_match(self):
+        return self._wrap_handler.get_active_match()
+
+    def get_total_match_count(self):
+        return self._wrap_handler.get_total_match_count()
path: qutebrowser/browser/webengine/webenginetab.py
text: @user1:
Perhaps those should now be stored here rather than in the `_wrap_handler`, given that they're not only used to check for wrapping anymore.

diff_hunk: @@ -1689,6 +1730,8 @@ def _connect_signals(self):
         page.loadFinished.connect(self._restore_zoom)
         page.loadFinished.connect(self._on_load_finished)
 
+        self.search.search_match_changed.connect(self._on_search_match_changed)
path: qutebrowser/browser/webengine/webenginetab.py
text: @user1:
FWIW you can connect signals to each other: `self.search.search_match_changed.connect(self.search_match_changed)` - but as mentioned above I'd rather get rid of this signal entirely.

diff_hunk: @@ -346,6 +347,8 @@ def _connect_tab_signals(self, tab):
             functools.partial(self._on_title_changed, tab))
         tab.icon_changed.connect(
             functools.partial(self._on_icon_changed, tab))
+        tab.search_match_changed.connect(
+            functools.partial(self._on_search_match_changed, tab))
path: qutebrowser/mainwindow/tabbedbrowser.py
text: @user1:
This should go through `self._filter` like above, so that it ensures that only search match changes from the current tab are processed. Also, the signal should be named `cur_search_match_changed` then.

diff_hunk: @@ -101,83 +102,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):
path: qutebrowser/browser/webengine/webenginetab.py
text: @author:
The wrap handler barely does anything at this point - it's essentially a one-function class. Should I get rid of it altogether?

diff_hunk: @@ -364,6 +364,22 @@ def next_result(self, *, result_cb: _Callback = None) -> None:
         """
         raise NotImplementedError
 
+    def get_current_match(self) -> int:
path: qutebrowser/browser/browsertab.py
text: @user1:
Instead of adding getter functions (which is rarely something done in Python), make the attributes public - see `search_displayed` for a similar example.

diff_hunk: @@ -1197,6 +1215,9 @@ def title(self) -> str:
     def icon(self) -> None:
         raise NotImplementedError
 
+    def current_search_match(self) -> (int, int):
path: qutebrowser/browser/browsertab.py
text: @user1:
This shouldn't be needed - the caller can just use the two attributes directly.

diff_hunk: @@ -910,6 +926,8 @@ class AbstractTab(QWidget):
     icon_changed = pyqtSignal(QIcon)
     #: Signal emitted when a page's title changed (new title as str)
     title_changed = pyqtSignal(str)
+    #: Signal emitted when a page's currently active search match changed (match as current/total)
+    search_match_changed = pyqtSignal(int, int)
path: qutebrowser/browser/browsertab.py
text: @user1:
This signal should be on the `AbstractSearch` rather than here, no?