summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Bruhin <me@the-compiler.org>2022-06-14 10:46:09 +0200
committerFlorian Bruhin <me@the-compiler.org>2022-06-14 10:46:09 +0200
commit24c3da000604477b24ddcd08500a5b7b92c83004 (patch)
treefbd6e0998619cd4c0f9f2ab08fa4440a97d5d730
parenta8d7e91e17419e1b365b0c8cc43f5defdf674291 (diff)
parentede1981ccb56068767e74caaff79bcb1f4001668 (diff)
downloadqutebrowser-24c3da000604477b24ddcd08500a5b7b92c83004.tar.gz
qutebrowser-24c3da000604477b24ddcd08500a5b7b92c83004.zip
Merge branch 'dev'
-rw-r--r--doc/changelog.asciidoc10
-rw-r--r--doc/help/settings.asciidoc11
-rw-r--r--qutebrowser/browser/browsertab.py81
-rw-r--r--qutebrowser/browser/commands.py112
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py235
-rw-r--r--qutebrowser/browser/webkit/webkittab.py46
-rw-r--r--qutebrowser/config/configdata.yml10
-rw-r--r--qutebrowser/mainwindow/mainwindow.py3
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py7
-rw-r--r--qutebrowser/mainwindow/statusbar/searchmatch.py43
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py5
-rw-r--r--scripts/dev/check_coverage.py2
-rw-r--r--tests/end2end/features/search.feature83
-rw-r--r--tests/unit/browser/test_caret.py4
-rw-r--r--tests/unit/browser/webengine/test_webenginetab.py43
15 files changed, 458 insertions, 237 deletions
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc
index 2fceb1829..32940da44 100644
--- a/doc/changelog.asciidoc
+++ b/doc/changelog.asciidoc
@@ -68,14 +68,20 @@ Changed
currently focused element (`focused`).
- The `:click-element` command now can select the first found element via
`--select-first`.
-- The `statusbar.widgets` setting now supports a `clock` option to show the
- current time.
+- New `search.wrap_messages` setting, making it possible to disable search
+ wrapping messages.
+- New widgets for `statusbar.widgets`:
+ * `clock`, showing the current time
+ * `search_match`, showing the current match and total count when finding text
+ on a page
Fixed
~~~~~
- When the devtools are clicked but `input.insert_mode.auto_enter` is set to
`false`, insert mode now isn't entered anymore.
+- The search wrapping messages are now correctly displayed in (hopefully) all
+ cases with QtWebEngine.
[[v2.5.2]]
v2.5.2 (unreleased)
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 344879b4e..b16fe2a06 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -303,6 +303,7 @@
|<<search.ignore_case,search.ignore_case>>|When to find text on a page case-insensitively.
|<<search.incremental,search.incremental>>|Find text on a page incrementally, renewing the search for each typed character.
|<<search.wrap,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,search.wrap_messages>>|Display messages when advancing through text matches at the top and bottom of the page, e.g. `Search hit TOP`.
|<<session.default_name,session.default_name>>|Name of the session to save by default.
|<<session.lazy_restore,session.lazy_restore>>|Load a restored tab as soon as it takes focus.
|<<spellcheck.languages,spellcheck.languages>>|Languages to use for spell checking.
@@ -4002,6 +4003,14 @@ Type: <<types,Bool>>
Default: +pass:[true]+
+[[search.wrap_messages]]
+=== search.wrap_messages
+Display messages when advancing through text matches at the top and bottom of the page, e.g. `Search hit TOP`.
+
+Type: <<types,Bool>>
+
+Default: +pass:[true]+
+
[[session.default_name]]
=== session.default_name
Name of the session to save by default.
@@ -4128,6 +4137,7 @@ Valid values:
* +scroll+: Percentage of the current page position like `10%`.
* +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.
@@ -4137,6 +4147,7 @@ Valid values:
Default:
- +pass:[keypress]+
+- +pass:[search_match]+
- +pass:[url]+
- +pass:[scroll]+
- +pass:[history]+
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 699fe1b0b..e87ac806e 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -287,6 +287,61 @@ class AbstractPrinting:
diag.open(do_print)
+@dataclasses.dataclass
+class SearchMatch:
+
+ """The currently highlighted search match.
+
+ Attributes:
+ current: The currently active search match on the page.
+ 0 if no search is active or the feature isn't available.
+ total: The total number of search matches on the page.
+ 0 if no search is active or the feature isn't available.
+ """
+
+ current: int = 0
+ total: int = 0
+
+ def reset(self) -> None:
+ """Reset match counter information.
+
+ Stale information could lead to next_result or prev_result misbehaving.
+ """
+ self.current = 0
+ self.total = 0
+
+ def is_null(self) -> bool:
+ """Whether the SearchMatch is set to zero."""
+ return self.current == 0 and self.total == 0
+
+ def at_limit(self, going_up: bool) -> bool:
+ """Whether the SearchMatch is currently at the first/last result."""
+ return (
+ self.total != 0 and
+ (
+ going_up and self.current == 1 or
+ not going_up and self.current == self.total
+ )
+ )
+
+ def __str__(self) -> str:
+ return f"{self.current}/{self.total}"
+
+
+class SearchNavigationResult(enum.Enum):
+
+ """The outcome of calling prev_/next_result."""
+
+ found = enum.auto()
+ not_found = enum.auto()
+
+ wrapped_bottom = enum.auto()
+ wrap_prevented_bottom = enum.auto()
+
+ wrapped_top = enum.auto()
+ wrap_prevented_top = enum.auto()
+
+
class AbstractSearch(QObject):
"""Attribute ``search`` of AbstractTab for doing searches.
@@ -295,17 +350,24 @@ class AbstractSearch(QObject):
text: The last thing this view was searched for.
search_displayed: Whether we're currently displaying search results in
this view.
+ match: The currently active search match.
_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.
+ match_changed: The currently active search match has changed.
+ Emits SearchMatch(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.
+ match_changed = pyqtSignal(SearchMatch)
cleared = pyqtSignal()
_Callback = Callable[[bool], None]
+ _NavCallback = Callable[[SearchNavigationResult], None]
def __init__(self, tab: 'AbstractTab', parent: QWidget = None):
super().__init__(parent)
@@ -313,6 +375,7 @@ class AbstractSearch(QObject):
self._widget = cast(_WidgetType, None)
self.text: Optional[str] = None
self.search_displayed = False
+ self.match = SearchMatch()
def _is_case_sensitive(self, ignore_case: usertypes.IgnoreCase) -> bool:
"""Check if case-sensitivity should be used.
@@ -333,7 +396,6 @@ class AbstractSearch(QObject):
def search(self, text: str, *,
ignore_case: usertypes.IgnoreCase = usertypes.IgnoreCase.never,
reverse: bool = False,
- wrap: bool = True,
result_cb: _Callback = None) -> None:
"""Find the given text on the page.
@@ -341,7 +403,6 @@ class AbstractSearch(QObject):
text: The text to search for.
ignore_case: Search case-insensitively.
reverse: Reverse search direction.
- wrap: Allow wrapping at the top or bottom of the page.
result_cb: Called with a bool indicating whether a match was found.
"""
raise NotImplementedError
@@ -350,19 +411,21 @@ class AbstractSearch(QObject):
"""Clear the current search."""
raise NotImplementedError
- def prev_result(self, *, result_cb: _Callback = None) -> None:
+ def prev_result(self, *, wrap: bool = False, callback: _NavCallback = None) -> None:
"""Go to the previous result of the current search.
Args:
- result_cb: Called with a bool indicating whether a match was found.
+ wrap: Allow wrapping at the top or bottom of the page.
+ callback: Called with a SearchNavigationResult.
"""
raise NotImplementedError
- def next_result(self, *, result_cb: _Callback = None) -> None:
+ def next_result(self, *, wrap: bool = False, callback: _NavCallback = None) -> None:
"""Go to the next result of the current search.
Args:
- result_cb: Called with a bool indicating whether a match was found.
+ wrap: Allow wrapping at the top or bottom of the page.
+ callback: Called with a SearchNavigationResult.
"""
raise NotImplementedError
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index 4f782c3ee..e6d2af822 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -1538,33 +1538,39 @@ class CommandDispatcher:
message.error(str(e))
ed.backup()
- def _search_cb(self, found, *, tab, old_scroll_pos, options, text, prev):
- """Callback called from search/search_next/search_prev.
+ def _search_cb(self, found, *, text):
+ """Callback called from :search.
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.
- options: The options (dict) the search was made with.
- text: The text searched for.
- prev: Whether we're searching backwards (i.e. :search-prev)
"""
- # :search/:search-next without reverse -> down
- # :search/:search-next with reverse -> up
- # :search-prev without reverse -> up
- # :search-prev with reverse -> down
- 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")
- else:
+ if not found:
message.warning(f"Text '{text}' not found on page!",
replace='find-in-page')
+ def _search_navigation_cb(self, result):
+ """Callback called from :search-prev/next."""
+ if result == browsertab.SearchNavigationResult.not_found:
+ # FIXME check if this actually can happen...
+ message.warning("Search result vanished...")
+ return
+ elif result == browsertab.SearchNavigationResult.found:
+ return
+ elif not config.val.search.wrap_messages:
+ return
+
+ messages = {
+ browsertab.SearchNavigationResult.wrap_prevented_bottom:
+ "Search hit BOTTOM",
+ browsertab.SearchNavigationResult.wrap_prevented_top:
+ "Search hit TOP",
+ browsertab.SearchNavigationResult.wrapped_bottom:
+ "Search hit BOTTOM, continuing at TOP",
+ browsertab.SearchNavigationResult.wrapped_top:
+ "Search hit TOP, continuing at BOTTOM",
+ }
+ message.info(messages[result], replace="search-hit-msg")
+
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
def search(self, text="", reverse=False):
@@ -1584,29 +1590,18 @@ class CommandDispatcher:
options = {
'ignore_case': config.val.search.ignore_case,
'reverse': reverse,
- 'wrap': config.val.search.wrap,
}
self._tabbed_browser.search_text = text
- 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)
- options['result_cb'] = cb
+ self._tabbed_browser.search_options = options
tab.scroller.before_jump_requested.emit()
- tab.search.search(text, **options)
- @cmdutils.register(instance='command-dispatcher', scope='window')
- @cmdutils.argument('count', value=cmdutils.Value.count)
- def search_next(self, count=1):
- """Continue the search to the ([count]th) next term.
+ cb = functools.partial(self._search_cb, text=text)
+ tab.search.search(text, **options, result_cb=cb)
- Args:
- count: How many elements to ignore.
- """
- tab = self._current_widget()
+ def _search_prev_next(self, count, tab, method):
+ """Continue the search to the prev/next term."""
window_text = self._tabbed_browser.search_text
window_options = self._tabbed_browser.search_options
@@ -1623,48 +1618,33 @@ class CommandDispatcher:
if count == 0:
return
- cb = functools.partial(self._search_cb, tab=tab,
- old_scroll_pos=tab.scroller.pos_px(),
- options=window_options, text=window_text,
- prev=False)
+ wrap = config.val.search.wrap
for _ in range(count - 1):
- tab.search.next_result()
- tab.search.next_result(result_cb=cb)
+ method(wrap=wrap)
+ method(callback=self._search_navigation_cb, wrap=wrap)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', value=cmdutils.Value.count)
- def search_prev(self, count=1):
- """Continue the search to the ([count]th) previous term.
+ def search_next(self, count=1):
+ """Continue the search to the ([count]th) next term.
Args:
count: How many elements to ignore.
"""
tab = self._current_widget()
- window_text = self._tabbed_browser.search_text
- window_options = self._tabbed_browser.search_options
+ self._search_prev_next(count, tab, tab.search.next_result)
- if window_text is None:
- raise cmdutils.CommandError("No search done yet.")
-
- tab.scroller.before_jump_requested.emit()
-
- if window_text is not None and window_text != tab.search.text:
- tab.search.clear()
- tab.search.search(window_text, **window_options)
- count -= 1
-
- if count == 0:
- return
-
- cb = functools.partial(self._search_cb, tab=tab,
- old_scroll_pos=tab.scroller.pos_px(),
- options=window_options, text=window_text,
- prev=True)
+ @cmdutils.register(instance='command-dispatcher', scope='window')
+ @cmdutils.argument('count', value=cmdutils.Value.count)
+ def search_prev(self, count=1):
+ """Continue the search to the ([count]th) previous term.
- for _ in range(count - 1):
- tab.search.prev_result()
- tab.search.prev_result(result_cb=cb)
+ Args:
+ count: How many elements to ignore.
+ """
+ tab = self._current_widget()
+ self._search_prev_next(count, tab, tab.search.prev_result)
def _jseval_cb(self, out):
"""Show the data returned from JS."""
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index 0a2333afc..916164ba7 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -97,90 +97,41 @@ class WebEnginePrinting(browsertab.AbstractPrinting):
self._widget.page().print(printer, callback)
-class _WebEngineSearchWrapHandler:
-
- """QtWebEngine implementations related to wrapping when searching.
-
- 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.
- """
-
- def __init__(self):
- self._active_match = 0
- self._total_matches = 0
- self.flag_wrap = True
- 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
+@dataclasses.dataclass
+class _FindFlags:
+
+ case_sensitive: bool = False
+ backward: bool = False
+
+ def to_qt(self):
+ """Convert flags into Qt flags."""
+ # FIXME:mypy Those should be correct, reevaluate with PyQt6-stubs
+ flags = QWebEnginePage.FindFlag(0)
+ if self.case_sensitive:
+ flags |= ( # type: ignore[assignment]
+ QWebEnginePage.FindFlag.FindCaseSensitively)
+ if self.backward:
+ flags |= QWebEnginePage.FindFlag.FindBackward # type: ignore[assignment]
+ return flags
- def prevent_wrapping(self, *, going_up):
- """Prevent wrapping if possible and required.
+ def __bool__(self):
+ """Flags are truthy if any flag is set to True."""
+ return any(dataclasses.astuple(self))
- Returns True if a wrap was prevented and False if not.
+ def __str__(self):
+ """List all true flags, in Qt enum style.
- Args:
- going_up: Whether the search would scroll the page up or down.
+ This needs to be in the same format as QtWebKit, for tests.
"""
- 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
+ names = {
+ "case_sensitive": "FindCaseSensitively",
+ "backward": "FindBackward",
+ }
+ d = dataclasses.asdict(self)
+ truthy = [names[key] for key, value in d.items() if value]
+ if not truthy:
+ return "<no find flags>"
+ return "|".join(truthy)
class WebEngineSearch(browsertab.AbstractSearch):
@@ -188,7 +139,7 @@ class WebEngineSearch(browsertab.AbstractSearch):
"""QtWebEngine implementations related to searching on the page.
Attributes:
- _flags: The QWebEnginePage.FindFlags of the last search.
+ _flags: The FindFlags of the last search.
_pending_searches: How many searches have been started but not called
back yet.
"""
@@ -197,24 +148,34 @@ class WebEngineSearch(browsertab.AbstractSearch):
def __init__(self, tab, parent=None):
super().__init__(tab, parent)
- self._flags = self._empty_flags()
+ self._flags = _FindFlags()
self._pending_searches = 0
- # The API necessary to stop wrapping was added in this version
- self._wrap_handler = _WebEngineSearchWrapHandler()
+ self.match = browsertab.SearchMatch()
+ self._old_match = browsertab.SearchMatch()
- def _empty_flags(self):
- return QWebEnginePage.FindFlags(0)
-
- def _args_to_flags(self, reverse, ignore_case):
- flags = self._empty_flags()
- if self._is_case_sensitive(ignore_case):
- flags |= QWebEnginePage.FindCaseSensitively
- if reverse:
- flags |= QWebEnginePage.FindBackward
- return flags
+ def _store_flags(self, reverse, ignore_case):
+ self._flags.case_sensitive = self._is_case_sensitive(ignore_case)
+ self._flags.backward = reverse
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._widget.page().findTextFinished.connect(self._on_find_finished)
def _find(self, text, flags, callback, caller):
"""Call findText on the widget."""
@@ -241,8 +202,7 @@ class WebEngineSearch(browsertab.AbstractSearch):
found_text = 'found' if found else "didn't find"
if flags:
- flag_text = 'with flags {}'.format(debug.qflags_key(
- QWebEnginePage, flags, klass=QWebEnginePage.FindFlag))
+ flag_text = f'with flags {flags}'
else:
flag_text = ''
log.webview.debug(' '.join([caller, found_text, text, flag_text])
@@ -250,51 +210,88 @@ class WebEngineSearch(browsertab.AbstractSearch):
if callback is not None:
callback(found)
+
self.finished.emit(found)
- self._widget.page().findText(text, flags, wrapped_callback)
+ self._widget.page().findText(text, flags.to_qt(), wrapped_callback)
+
+ def _on_find_finished(self, find_text_result):
+ """Unwrap the result, store it, and pass it along."""
+ self._old_match = self.match
+ self.match = browsertab.SearchMatch(
+ current=find_text_result.activeMatch(),
+ total=find_text_result.numberOfMatches(),
+ )
+ log.webview.debug(f"Active search match: {self.match}")
+ self.match_changed.emit(self.match)
def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
- reverse=False, wrap=True, result_cb=None):
+ reverse=False, result_cb=None):
# Don't go to next entry on duplicate search
if self.text == text and self.search_displayed:
log.webview.debug("Ignoring duplicate search request"
" for {}, but resetting flags".format(text))
- self._flags = self._args_to_flags(reverse, ignore_case)
+ self._store_flags(reverse, ignore_case)
return
self.text = text
- self._flags = self._args_to_flags(reverse, ignore_case)
- self._wrap_handler.reset_match_data()
- self._wrap_handler.flag_wrap = wrap
+ self._store_flags(reverse, ignore_case)
+ self.match.reset()
self._find(text, self._flags, result_cb, 'search')
def clear(self):
if self.search_displayed:
self.cleared.emit()
+ self.match_changed.emit(browsertab.SearchMatch())
self.search_displayed = False
- self._wrap_handler.reset_match_data()
+ self.match.reset()
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):
- return
- flags &= ~QWebEnginePage.FindBackward
+ def _prev_next_cb(self, found, *, going_up, callback):
+ """Call the prev/next callback based on the search result."""
+ if found:
+ result = browsertab.SearchNavigationResult.found
+ # Check if the match count change is opposite to the search direction
+ if self._old_match.current > 0:
+ if not going_up and self._old_match.current > self.match.current:
+ result = browsertab.SearchNavigationResult.wrapped_bottom
+ elif going_up and self._old_match.current < self.match.current:
+ result = browsertab.SearchNavigationResult.wrapped_top
else:
- if self._wrap_handler.prevent_wrapping(going_up=True):
- return
- flags |= QWebEnginePage.FindBackward
- self._find(self.text, flags, result_cb, 'prev_result')
+ result = browsertab.SearchNavigationResult.not_found
+
+ callback(result)
+
+ def prev_result(self, *, wrap=False, callback=None):
+ going_up = not self._flags.backward
+ flags = dataclasses.replace(self._flags, backward=going_up)
- 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.match.at_limit(going_up=going_up) and not wrap:
+ res = (
+ browsertab.SearchNavigationResult.wrap_prevented_top if going_up else
+ browsertab.SearchNavigationResult.wrap_prevented_bottom
+ )
+ if callback is not None:
+ callback(res)
+ return
+
+ cb = functools.partial(self._prev_next_cb, going_up=going_up, callback=callback)
+ self._find(self.text, flags, cb, 'prev_result')
+
+ def next_result(self, *, wrap=False, callback=None):
+ going_up = self._flags.backward
+ if self.match.at_limit(going_up=going_up) and not wrap:
+ res = (
+ browsertab.SearchNavigationResult.wrap_prevented_top if going_up else
+ browsertab.SearchNavigationResult.wrap_prevented_bottom
+ )
+ if callback is not None:
+ callback(res)
return
- self._find(self.text, self._flags, result_cb, 'next_result')
+
+ cb = functools.partial(self._prev_next_cb, going_up=going_up, callback=callback)
+ self._find(self.text, self._flags, cb, 'next_result')
class WebEngineCaret(browsertab.AbstractCaret):
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 24d232c9c..2faf93a32 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -115,14 +115,12 @@ class WebKitSearch(browsertab.AbstractSearch):
def _empty_flags(self):
return QWebPage.FindFlags(0) # type: ignore[call-overload]
- def _args_to_flags(self, reverse, ignore_case, wrap):
+ def _args_to_flags(self, reverse, ignore_case):
flags = self._empty_flags()
if self._is_case_sensitive(ignore_case):
flags |= QWebPage.FindCaseSensitively
if reverse:
flags |= QWebPage.FindBackward
- if wrap:
- flags |= QWebPage.FindWrapsAroundDocument
return flags
def _call_cb(self, callback, found, text, flags, caller):
@@ -150,7 +148,19 @@ class WebKitSearch(browsertab.AbstractSearch):
log.webview.debug(' '.join([caller, found_text, text, flag_text])
.strip())
if callback is not None:
- QTimer.singleShot(0, functools.partial(callback, found))
+ if caller in ["prev_result", "next_result"]:
+ if found:
+ # no wrapping detection
+ cb_value = browsertab.SearchNavigationResult.found
+ elif flags & QWebPage.FindBackward:
+ cb_value = browsertab.SearchNavigationResult.wrap_prevented_top
+ else:
+ cb_value = browsertab.SearchNavigationResult.wrap_prevented_bottom
+ elif caller == "search":
+ cb_value = found
+ else:
+ raise utils.Unreachable(caller)
+ QTimer.singleShot(0, functools.partial(callback, cb_value))
self.finished.emit(found)
@@ -164,12 +174,12 @@ class WebKitSearch(browsertab.AbstractSearch):
'', QWebPage.HighlightAllOccurrences) # type: ignore[arg-type]
def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
- reverse=False, wrap=True, result_cb=None):
+ reverse=False, result_cb=None):
# Don't go to next entry on duplicate search
if self.text == text and self.search_displayed:
log.webview.debug("Ignoring duplicate search request"
" for {}, but resetting flags".format(text))
- self._flags = self._args_to_flags(reverse, ignore_case, wrap)
+ self._flags = self._args_to_flags(reverse, ignore_case)
return
# Clear old search results, this is done automatically on QtWebEngine.
@@ -177,7 +187,7 @@ class WebKitSearch(browsertab.AbstractSearch):
self.text = text
self.search_displayed = True
- self._flags = self._args_to_flags(reverse, ignore_case, wrap)
+ self._flags = self._args_to_flags(reverse, ignore_case)
# We actually search *twice* - once to highlight everything, then again
# to get a mark so we can navigate.
found = self._widget.findText(text, self._flags)
@@ -185,22 +195,34 @@ class WebKitSearch(browsertab.AbstractSearch):
self._flags | QWebPage.HighlightAllOccurrences)
self._call_cb(result_cb, found, text, self._flags, 'search')
- def next_result(self, *, result_cb=None):
+ def next_result(self, *, wrap=False, callback=None):
self.search_displayed = True
- found = self._widget.findText(self.text, self._flags) # type: ignore[arg-type]
- self._call_cb(result_cb, found, self.text, self._flags, 'next_result')
+ # The int() here makes sure we get a copy of the flags.
+ flags = QWebPage.FindFlags(
+ int(self._flags)) # type: ignore[call-overload]
- def prev_result(self, *, result_cb=None):
+ if wrap:
+ flags |= QWebPage.FindWrapsAroundDocument
+
+ found = self._widget.findText(self.text, flags) # type: ignore[arg-type]
+ self._call_cb(callback, found, self.text, flags, 'next_result')
+
+ def prev_result(self, *, wrap=False, callback=None):
self.search_displayed = True
# The int() here makes sure we get a copy of the flags.
flags = QWebPage.FindFlags(
int(self._flags)) # type: ignore[call-overload]
+
if flags & QWebPage.FindBackward:
flags &= ~QWebPage.FindBackward
else:
flags |= QWebPage.FindBackward
+
+ if wrap:
+ flags |= QWebPage.FindWrapsAroundDocument
+
found = self._widget.findText(self.text, flags) # type: ignore[arg-type]
- self._call_cb(result_cb, found, self.text, flags, 'prev_result')
+ self._call_cb(callback, found, self.text, flags, 'prev_result')
class WebKitCaret(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..22245d8c1 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)
+
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 ff4822f1e..e2b6e5786 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)
diff --git a/qutebrowser/mainwindow/statusbar/searchmatch.py b/qutebrowser/mainwindow/statusbar/searchmatch.py
new file mode 100644
index 000000000..6d0b86007
--- /dev/null
+++ b/qutebrowser/mainwindow/statusbar/searchmatch.py
@@ -0,0 +1,43 @@
+# 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.browser import browsertab
+from qutebrowser.mainwindow.statusbar import textbase
+
+
+class SearchMatch(textbase.TextBase):
+
+ """The part of the statusbar that displays the search match counter."""
+
+ @pyqtSlot(browsertab.SearchMatch)
+ def set_match(self, search_match: browsertab.SearchMatch) -> None:
+ """Set the match counts in the statusbar.
+
+ Passing SearchMatch(0, 0) hides the match counter.
+
+ Args:
+ search_match: The currently active search match.
+ """
+ text = '' if search_match.is_null() else f'Match [{search_match}]'
+ self.setText(text)
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index 68b4adfdb..c623ce809 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(browsertab.SearchMatch)
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.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,7 @@ 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.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
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..9446c36ac 100644
--- a/tests/end2end/features/search.feature
+++ b/tests/end2end/features/search.feature
@@ -202,6 +202,22 @@ Feature: Searching on a page
And I wait for "prev_result found foo" in the log
Then "Foo" should be found
+ # This makes sure we don't mutate the original flags
+ # Seems to be broken with QtWebKit, wontfix
+ @qtwebkit_skip
+ Scenario: Jumping to previous match with --reverse twice
+ When I set search.ignore_case to always
+ And I run :search --reverse baz
+ # BAZ
+ And I wait for "search found baz with flags FindBackward" in the log
+ And I run :search-prev
+ # Baz
+ And I wait for "prev_result found baz" in the log
+ And I run :search-prev
+ # baz
+ And I wait for "prev_result found baz" in the log
+ Then "baz" should be found
+
Scenario: Jumping to previous match without search
# Make sure there was no search in the same window before
When I open data/search.html in a new window
@@ -233,20 +249,20 @@ Feature: Searching on a page
## wrapping prevented
- @qtwebkit_skip @qt>=5.14
- Scenario: Preventing wrapping at the top of the page with QtWebEngine
+ @qt>=5.14
+ Scenario: Preventing wrapping at the top of the page
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
+ @qt>=5.14
+ Scenario: Preventing wrapping at the bottom of the page
When I set search.ignore_case to always
And I set search.wrap to false
And I run :search foo
@@ -254,32 +270,49 @@ 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
+ ## 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 false
- And I run :search --reverse foo
- And I wait for "search found foo with flags FindBackward" in the log
+ 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 foo with flags FindBackward" in the log
+ And I wait for "next_result found ba" in the log
And I run :search-next
- And I wait for "next_result didn't find foo with flags FindBackward" in the log
- Then the warning "Text 'foo' not found on page!" should be shown
+ And I wait for "next_result found ba" in the log
+ Then "Setting search match text to 3/5" should be logged
- @qtwebengine_skip
- Scenario: Preventing wrapping at the bottom of the page with QtWebKit
+ @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 foo
- And I wait for "search found foo" in the log
- 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 "next_result didn't find foo" in the log
- Then the warning "Text 'foo' not found on page!" should be shown
+ 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
diff --git a/tests/unit/browser/test_caret.py b/tests/unit/browser/test_caret.py
index 86014040d..85301e358 100644
--- a/tests/unit/browser/test_caret.py
+++ b/tests/unit/browser/test_caret.py
@@ -345,8 +345,8 @@ class TestSearch:
callback.assert_called_with(True)
with qtbot.wait_callback() as callback:
- web_tab.search.next_result(result_cb=callback)
- callback.assert_called_with(True)
+ web_tab.search.next_result(callback=callback)
+ callback.assert_called_with(browsertab.SearchNavigationResult.found)
mode_manager.enter(usertypes.KeyMode.caret)
diff --git a/tests/unit/browser/webengine/test_webenginetab.py b/tests/unit/browser/webengine/test_webenginetab.py
index 3d8eec663..30807bb4e 100644
--- a/tests/unit/browser/webengine/test_webenginetab.py
+++ b/tests/unit/browser/webengine/test_webenginetab.py
@@ -214,3 +214,46 @@ def test_notification_permission_workaround():
permissions = webenginetab._WebEnginePermissions
assert permissions._options[notifications] == 'content.notifications.enabled'
assert permissions._messages[notifications] == 'show notifications'
+
+
+class TestFindFlags:
+
+ @pytest.mark.parametrize("case_sensitive, backward, expected", [
+ (True, True, (QWebEnginePage.FindFlag.FindCaseSensitively |
+ QWebEnginePage.FindFlag.FindBackward)),
+ (True, False, QWebEnginePage.FindFlag.FindCaseSensitively),
+ (False, True, QWebEnginePage.FindFlag.FindBackward),
+ (False, False, QWebEnginePage.FindFlag(0)),
+ ])
+ def test_to_qt(self, case_sensitive, backward, expected):
+ flags = webenginetab._FindFlags(
+ case_sensitive=case_sensitive,
+ backward=backward,
+ )
+ assert flags.to_qt() == expected
+
+ @pytest.mark.parametrize("case_sensitive, backward, expected", [
+ (True, True, True),
+ (True, False, True),
+ (False, True, True),
+ (False, False, False),
+ ])
+ def test_bool(self, case_sensitive, backward, expected):
+ flags = webenginetab._FindFlags(
+ case_sensitive=case_sensitive,
+ backward=backward,
+ )
+ assert bool(flags) == expected
+
+ @pytest.mark.parametrize("case_sensitive, backward, expected", [
+ (True, True, "FindCaseSensitively|FindBackward"),
+ (True, False, "FindCaseSensitively"),
+ (False, True, "FindBackward"),
+ (False, False, "<no find flags>"),
+ ])
+ def test_str(self, case_sensitive, backward, expected):
+ flags = webenginetab._FindFlags(
+ case_sensitive=case_sensitive,
+ backward=backward,
+ )
+ assert str(flags) == expected