From 4f16f7e295070c07bf1e8b2883b074fcad69d1d6 Mon Sep 17 00:00:00 2001 From: codebanesr Date: Sun, 6 Jul 2025 04:00:24 +0800 Subject: [PATCH 1/3] feat(hints): add text filter support for hinting elements Add --text parameter to hint command to filter elements by their text content, value, and placeholder attributes. The filtering is case-insensitive and supports multi-word matching across all text sources. Add test cases for text filtering functionality and update documentation with new scenarios. The feature allows more precise hint targeting when multiple similar elements are present on a page. --- qutebrowser/browser/hints.py | 40 +++++- tests/end2end/data/hints/text_filter.html | 44 +++++++ tests/end2end/features/hints.feature | 67 ++++++++++ tests/unit/browser/test_hints.py | 152 ++++++++++++++++++++++ 4 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 tests/end2end/data/hints/text_filter.html 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 5886b8018..1df39fd0e 100644 --- a/tests/unit/browser/test_hints.py +++ b/tests/unit/browser/test_hints.py @@ -136,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 = pytest.Mock() + elem.value.return_value = "" + elem.get.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__.return_value = "Submit" # text content + mock_elem.value.return_value = "" # no value + mock_elem.get.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__.return_value = "" # no text content + mock_elem.value.return_value = "current input text" # input value + mock_elem.get.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__.return_value = "Login Button" # text content + mock_elem.value.return_value = "login" # input value + mock_elem.get.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__.return_value = "Button Text" + mock_elem.value.return_value = None # None value + mock_elem.get.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=pytest.Mock(), + target=pytest.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=pytest.Mock(), + target=pytest.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 From 5cb1a7da59b5003a033a6dced6416dfc7c7d1290 Mon Sep 17 00:00:00 2001 From: codebanesr Date: Sun, 6 Jul 2025 04:15:44 +0800 Subject: [PATCH 2/3] test(browser): replace pytest.Mock with mock.Mock in hint tests --- tests/unit/browser/test_hints.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py index 1df39fd0e..9eee7dde8 100644 --- a/tests/unit/browser/test_hints.py +++ b/tests/unit/browser/test_hints.py @@ -6,6 +6,7 @@ import string import functools import itertools import operator +from unittest import mock import pytest from qutebrowser.qt.core import QUrl @@ -149,7 +150,7 @@ class TestTextFiltering: @pytest.fixture def mock_elem(self): """Create a mock web element for testing.""" - elem = pytest.Mock() + elem = mock.Mock() elem.value.return_value = "" elem.get.return_value = "" return elem @@ -260,8 +261,8 @@ class TestTextFiltering: # Create a minimal HintContext to test text_filter field context = HintContext( - tab=pytest.Mock(), - target=pytest.Mock(), + tab=mock.Mock(), + target=mock.Mock(), rapid=False, hint_mode="number", add_history=False, @@ -271,13 +272,13 @@ class TestTextFiltering: group="all", text_filter="test filter" ) - + assert context.text_filter == "test filter" - + # Test with None (default) context_none = HintContext( - tab=pytest.Mock(), - target=pytest.Mock(), + tab=mock.Mock(), + target=mock.Mock(), rapid=False, hint_mode="number", add_history=False, @@ -286,5 +287,5 @@ class TestTextFiltering: args=[], group="all" ) - + assert context_none.text_filter is None From ca0144a26786ea492c6b57243d792496699e5315 Mon Sep 17 00:00:00 2001 From: codebanesr Date: Sun, 6 Jul 2025 04:30:17 +0800 Subject: [PATCH 3/3] test(hints): update mock element setup in test cases Use mock.Mock() directly for attribute setup instead of return_value assignments to improve test clarity and maintainability --- tests/unit/browser/test_hints.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py index 9eee7dde8..b476c267a 100644 --- a/tests/unit/browser/test_hints.py +++ b/tests/unit/browser/test_hints.py @@ -151,8 +151,8 @@ class TestTextFiltering: def mock_elem(self): """Create a mock web element for testing.""" elem = mock.Mock() - elem.value.return_value = "" - elem.get.return_value = "" + elem.value = mock.Mock(return_value="") + elem.get = mock.Mock(return_value="") return elem def test_filter_matches_basic(self, hint_manager): @@ -185,9 +185,9 @@ class TestTextFiltering: def test_text_filter_with_placeholder(self, hint_manager, mock_elem): """Test text filtering includes placeholder text.""" # Mock element with placeholder - mock_elem.__str__.return_value = "Submit" # text content - mock_elem.value.return_value = "" # no value - mock_elem.get.return_value = "Enter your name" # 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() @@ -206,9 +206,9 @@ class TestTextFiltering: def test_text_filter_with_value(self, hint_manager, mock_elem): """Test text filtering includes input values.""" # Mock element with value - mock_elem.__str__.return_value = "" # no text content - mock_elem.value.return_value = "current input text" # input value - mock_elem.get.return_value = "placeholder text" # placeholder + 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() @@ -225,9 +225,9 @@ class TestTextFiltering: 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__.return_value = "Login Button" # text content - mock_elem.value.return_value = "login" # input value - mock_elem.get.return_value = "Enter credentials" # placeholder + 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" @@ -243,9 +243,9 @@ class TestTextFiltering: 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__.return_value = "Button Text" - mock_elem.value.return_value = None # None value - mock_elem.get.return_value = "" # empty placeholder + 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()