diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index b3f45610d..9b5907f40 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -173,6 +173,7 @@ class HintContext: filterstr: Used to save the filter string for restoring in rapid mode. tab: The WebTab object we started hinting in. group: The group of web elements to hint. + text_filter: Optional text filter to apply to hints. """ tab: 'browsertab.AbstractTab' @@ -184,6 +185,7 @@ class HintContext: baseurl: QUrl args: list[str] group: str + text_filter: Optional[str] = None all_labels: list[HintLabel] = dataclasses.field(default_factory=list) labels: dict[str, HintLabel] = dataclasses.field(default_factory=dict) @@ -621,6 +623,26 @@ class HintManager(QObject): message.error("No elements found.") return + # Apply text filter if provided + if self._context.text_filter: + filtered_elems = [] + for elem in elems: + # Get text content, value, and placeholder for filtering + elemstr = str(elem) # textContent + elem_value = elem.value() or "" # input value + elem_placeholder = elem.get('placeholder', '') # placeholder attribute + + # Combine all text sources for filtering + combined_text = f"{elemstr} {elem_value} {elem_placeholder}".strip() + + if self._filter_matches(self._context.text_filter, combined_text): + filtered_elems.append(elem) + elems = filtered_elems + + if not elems: + message.error(f"No elements found matching text filter: {self._context.text_filter}") + return + # Because _start_cb is called asynchronously, it's possible that the # user switched to another tab or closed the tab/window. In that case # we should not start hinting. @@ -666,7 +688,8 @@ class HintManager(QObject): mode: str = None, add_history: bool = False, rapid: bool = False, - first: bool = False) -> None: + first: bool = False, + text: str = None) -> None: """Start hinting. Args: @@ -716,6 +739,10 @@ class HintManager(QObject): - `word`: Use hint words based on the html elements and the extra words. + text: Filter hints to only show elements containing this text as a + substring. The filtering is case-insensitive and supports + multi-word matching. + *args: Arguments for spawn/userscript/run/fill. - With `spawn`: The executable and arguments to spawn. @@ -769,6 +796,7 @@ class HintManager(QObject): baseurl=baseurl, args=list(args), group=group, + text_filter=text, ) try: @@ -890,7 +918,15 @@ class HintManager(QObject): visible = [] for label in self._context.all_labels: try: - if self._filter_matches(filterstr, str(label.elem)): + # Get text content, value, and placeholder for filtering + elemstr = str(label.elem) # textContent + elem_value = label.elem.value() or "" # input value + elem_placeholder = label.elem.get('placeholder', '') # placeholder attribute + + # Combine all text sources for filtering + combined_text = f"{elemstr} {elem_value} {elem_placeholder}".strip() + + if self._filter_matches(filterstr, combined_text): visible.append(label) # Show label again if it was hidden before label.show() diff --git a/tests/end2end/data/hints/text_filter.html b/tests/end2end/data/hints/text_filter.html new file mode 100644 index 000000000..3d7f331c9 --- /dev/null +++ b/tests/end2end/data/hints/text_filter.html @@ -0,0 +1,44 @@ + + + + + + Text Filter Test + + +

Text Filter Test Page

+ + + Home + About Us + Contact Information + + + + + + +
+ + + + +
+ + +
+ + +
+ + +
+ Click this nested link here +
+ + + + + + + \ No newline at end of file diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index fb22170d7..e0baa80f7 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -652,3 +652,70 @@ Feature: Using hints And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log And I run :fake-key -g something Then the javascript message "contents: existingsomething" should be logged + + ### Text filtering tests + + Scenario: Hint with text filter - matching text content + When I open data/hints/text_filter.html + And I run :hint --text "Home" + And I wait for "hints: a" in the log + And I run :hint-follow a + And I wait until data/hello.txt is loaded + Then data/hello.txt should be loaded + + Scenario: Hint with text filter - matching placeholder + When I open data/hints/text_filter.html + And I run :hint --text "username" + And I wait for "hints: a" in the log + # Should match input with placeholder="Enter username" + Then the hint a should be visible + + Scenario: Hint with text filter - matching input value + When I open data/hints/text_filter.html + And I run :hint --text "existing content" + And I wait for "hints: a" in the log + # Should match input with value="existing content" + Then the hint a should be visible + + Scenario: Hint with text filter - multi-word matching + When I open data/hints/text_filter.html + And I run :hint --text "Contact Information" + And I wait for "hints: a" in the log + And I run :hint-follow a + And I wait until data/contact.txt is loaded + Then data/contact.txt should be loaded + + Scenario: Hint with text filter - case insensitive + When I open data/hints/text_filter.html + And I run :hint --text "SUBMIT" + And I wait for "hints: a" in the log + # Should match "Submit Form" button case-insensitively + Then the hint a should be visible + + Scenario: Hint with text filter - no matches + When I open data/hints/text_filter.html + And I run :hint --text "nonexistent" + Then the error "No elements found matching text filter: nonexistent" should be shown + + Scenario: Hint with text filter - partial placeholder match + When I open data/hints/text_filter.html + And I run :hint --text "secure" + And I wait for "hints: a" in the log + # Should match input with placeholder="Enter secure password" + Then the hint a should be visible + + Scenario: Hint with text filter - combined text sources + When I open data/hints/text_filter.html + And I run :hint --text "Submit current" + And I wait for "hints: a" in the log + # Should match elements that have both words across text/value/placeholder + Then the hint a should be visible + + Scenario: Interactive filtering still works with placeholders + When I open data/hints/text_filter.html + And I run :hint + And I wait for "Entering mode KeyMode.hint" in the log + # Type to filter interactively - should include placeholder text + And I run :fake-key username + # Should show input with placeholder="Enter username" + Then the hint a should be visible diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py index 40bb64bf6..49b584b80 100644 --- a/tests/unit/browser/test_hints.py +++ b/tests/unit/browser/test_hints.py @@ -5,6 +5,7 @@ import string import functools import operator +from unittest import mock import pytest from qutebrowser.qt.core import QUrl @@ -135,3 +136,155 @@ def test_scattered_hints_count(min_len, num_chars, num_elements): # Check that we really couldn't use any short links assert ((num_chars ** longest_hint_len) - num_elements < len(chars) - 1) + + +class TestTextFiltering: + """Tests for the --text parameter hint filtering functionality.""" + + @pytest.fixture + def hint_manager(self): + """Create a HintManager instance for testing.""" + return qutebrowser.browser.hints.HintManager(win_id=0) + + @pytest.fixture + def mock_elem(self): + """Create a mock web element for testing.""" + elem = mock.Mock() + elem.value = mock.Mock(return_value="") + elem.get = mock.Mock(return_value="") + return elem + + def test_filter_matches_basic(self, hint_manager): + """Test basic text filtering functionality.""" + # Test case-insensitive matching + assert hint_manager._filter_matches("hello", "Hello World") + assert hint_manager._filter_matches("WORLD", "hello world") + + # Test substring matching + assert hint_manager._filter_matches("ell", "Hello") + assert not hint_manager._filter_matches("xyz", "Hello") + + # Test empty filter (should match everything) + assert hint_manager._filter_matches("", "anything") + assert hint_manager._filter_matches(None, "anything") + + def test_filter_matches_multiword(self, hint_manager): + """Test multi-word filtering functionality.""" + text = "Submit the form now" + + # Test multi-word matching (all words must be present) + assert hint_manager._filter_matches("submit form", text) + assert hint_manager._filter_matches("the now", text) + assert hint_manager._filter_matches("form submit", text) # order doesn't matter + + # Test partial multi-word matching fails + assert not hint_manager._filter_matches("submit missing", text) + assert not hint_manager._filter_matches("form xyz", text) + + def test_text_filter_with_placeholder(self, hint_manager, mock_elem): + """Test text filtering includes placeholder text.""" + # Mock element with placeholder + mock_elem.__str__ = mock.Mock(return_value="Submit") # text content + mock_elem.value = mock.Mock(return_value="") # no value + mock_elem.get = mock.Mock(return_value="Enter your name") # placeholder + + # Combined text should be "Submit Enter your name" + combined = f"Submit Enter your name".strip() + + # Should match placeholder text + assert hint_manager._filter_matches("Enter", combined) + assert hint_manager._filter_matches("name", combined) + assert hint_manager._filter_matches("your name", combined) + + # Should still match text content + assert hint_manager._filter_matches("Submit", combined) + + # Should match combination + assert hint_manager._filter_matches("Submit Enter", combined) + + def test_text_filter_with_value(self, hint_manager, mock_elem): + """Test text filtering includes input values.""" + # Mock element with value + mock_elem.__str__ = mock.Mock(return_value="") # no text content + mock_elem.value = mock.Mock(return_value="current input text") # input value + mock_elem.get = mock.Mock(return_value="placeholder text") # placeholder + + combined = f" current input text placeholder text".strip() + + # Should match input value + assert hint_manager._filter_matches("current", combined) + assert hint_manager._filter_matches("input text", combined) + + # Should match placeholder + assert hint_manager._filter_matches("placeholder", combined) + + # Should match combination + assert hint_manager._filter_matches("current placeholder", combined) + + def test_text_filter_combined_sources(self, hint_manager, mock_elem): + """Test text filtering with all text sources combined.""" + # Mock element with all text sources + mock_elem.__str__ = mock.Mock(return_value="Login Button") # text content + mock_elem.value = mock.Mock(return_value="login") # input value + mock_elem.get = mock.Mock(return_value="Enter credentials") # placeholder + + combined = "Login Button login Enter credentials" + + # Should match any individual source + assert hint_manager._filter_matches("Button", combined) + assert hint_manager._filter_matches("login", combined) # matches both text and value + assert hint_manager._filter_matches("credentials", combined) + + # Should match across sources + assert hint_manager._filter_matches("Login Enter", combined) + assert hint_manager._filter_matches("Button credentials", combined) + + def test_text_filter_empty_sources(self, hint_manager, mock_elem): + """Test text filtering with empty/None values.""" + # Mock element with empty values + mock_elem.__str__ = mock.Mock(return_value="Button Text") + mock_elem.value = mock.Mock(return_value=None) # None value + mock_elem.get = mock.Mock(return_value="") # empty placeholder + + combined = "Button Text ".strip() + + # Should still work with just text content + assert hint_manager._filter_matches("Button", combined) + assert hint_manager._filter_matches("Text", combined) + assert not hint_manager._filter_matches("missing", combined) + + def test_hint_context_text_filter(self): + """Test HintContext includes text_filter field.""" + from qutebrowser.browser.hints import HintContext + from qutebrowser.qt.core import QUrl + + # Create a minimal HintContext to test text_filter field + context = HintContext( + tab=mock.Mock(), + target=mock.Mock(), + rapid=False, + hint_mode="number", + add_history=False, + first=False, + baseurl=QUrl("https://example.com"), + args=[], + group="all", + text_filter="test filter" + ) + + assert context.text_filter == "test filter" + + # Test with None (default) + context_none = HintContext( + tab=mock.Mock(), + target=mock.Mock(), + rapid=False, + hint_mode="number", + add_history=False, + first=False, + baseurl=QUrl("https://example.com"), + args=[], + group="all" + ) + + assert context_none.text_filter is None