diff options
author | Florian Bruhin <me@the-compiler.org> | 2022-06-14 10:46:09 +0200 |
---|---|---|
committer | Florian Bruhin <me@the-compiler.org> | 2022-06-14 10:46:09 +0200 |
commit | 24c3da000604477b24ddcd08500a5b7b92c83004 (patch) | |
tree | fbd6e0998619cd4c0f9f2ab08fa4440a97d5d730 | |
parent | a8d7e91e17419e1b365b0c8cc43f5defdf674291 (diff) | |
parent | ede1981ccb56068767e74caaff79bcb1f4001668 (diff) | |
download | qutebrowser-24c3da000604477b24ddcd08500a5b7b92c83004.tar.gz qutebrowser-24c3da000604477b24ddcd08500a5b7b92c83004.zip |
Merge branch 'dev'
-rw-r--r-- | doc/changelog.asciidoc | 10 | ||||
-rw-r--r-- | doc/help/settings.asciidoc | 11 | ||||
-rw-r--r-- | qutebrowser/browser/browsertab.py | 81 | ||||
-rw-r--r-- | qutebrowser/browser/commands.py | 112 | ||||
-rw-r--r-- | qutebrowser/browser/webengine/webenginetab.py | 235 | ||||
-rw-r--r-- | qutebrowser/browser/webkit/webkittab.py | 46 | ||||
-rw-r--r-- | qutebrowser/config/configdata.yml | 10 | ||||
-rw-r--r-- | qutebrowser/mainwindow/mainwindow.py | 3 | ||||
-rw-r--r-- | qutebrowser/mainwindow/statusbar/bar.py | 7 | ||||
-rw-r--r-- | qutebrowser/mainwindow/statusbar/searchmatch.py | 43 | ||||
-rw-r--r-- | qutebrowser/mainwindow/tabbedbrowser.py | 5 | ||||
-rw-r--r-- | scripts/dev/check_coverage.py | 2 | ||||
-rw-r--r-- | tests/end2end/features/search.feature | 83 | ||||
-rw-r--r-- | tests/unit/browser/test_caret.py | 4 | ||||
-rw-r--r-- | tests/unit/browser/webengine/test_webenginetab.py | 43 |
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 |