Repository: spyder-ide/spyder

Issue:
Buttons in find and replace widget jump around for small editor widths
## Description

### What steps will reproduce the problem?

1 - Use master
2 - create a file containing `aaaa`
3 - search and replace `aa` to `bb`
4 - the buttons are jumping around! :
<img width="694" alt="Screenshot 2023-02-04 at 10 26 42" src="https://user-images.githubusercontent.com/6740194/216759760-58878ebb-4b43-4e99-881f-dd3b7c01b9f4.png">
<img width="694" alt="Screenshot 2023-02-04 at 10 26 50" src="https://user-images.githubusercontent.com/6740194/216759765-7c778620-12d3-4a7e-8aa9-510c16c09ce4.png">
<img width="691" alt="Screenshot 2023-02-04 at 10 26 58" src="https://user-images.githubusercontent.com/6740194/216759768-33a48d98-0ee2-4781-a00d-86b8193c2a9b.png">

This makes it really annoying to replace many occurrences as you have to adjust the mouse every time
<!--- You can use Markdown here --->


### Traceback


## Versions

* Spyder version: 6.0.0.dev0 dc630ab62 (pip)
* Python version: 3.10.8 64-bit
* Qt version: 5.15.5
* PyQt5 version: 5.15.7
* Operating System: Darwin 22.2.0

### Dependencies

```
# Mandatory:
applaunchservices >=0.3.0     :  0.3.0 (OK)
atomicwrites >=1.2.0          :  1.4.1 (OK)
chardet >=2.0.0               :  5.0.0 (OK)
cloudpickle >=0.5.0           :  2.2.0 (OK)
cookiecutter >=1.6.0          :  2.1.1 (OK)
diff_match_patch >=20181111   :  20200713 (OK)
intervaltree >=3.0.2          :  3.1.0 (OK)
IPython >=7.31.1;<9.0.0       :  7.34.0 (OK)
jedi >=0.17.2;<0.19.0         :  0.18.1 (OK)
jellyfish >=0.7               :  0.9.0 (OK)
jsonschema >=3.2.0            :  4.16.0 (OK)
keyring >=17.0.0              :  23.9.3 (OK)
nbconvert >=4.0               :  7.1.0 (OK)
numpydoc >=0.6.0              :  1.4.0 (OK)
parso >=0.7.0;<0.9.0          :  0.8.3 (OK)
pexpect >=4.4.0               :  4.8.0 (OK)
pickleshare >=0.4             :  0.7.5 (OK)
psutil >=5.3                  :  5.9.2 (OK)
pygments >=2.0                :  2.13.0 (OK)
pylint >=2.5.0;<3.0           :  2.15.3 (OK)
pylint_venv >=2.1.1           :  2.3.0 (OK)
pyls_spyder >=0.4.0           :  0.4.0 (OK)
pylsp >=1.7.1;<1.8.0          :  1.7.1 (OK)
pylsp_black >=1.2.0           :  1.2.1 (OK)
qdarkstyle >=3.0.2;<3.1.0     :  3.0.3 (OK)
qstylizer >=0.2.2             :  0.2.2 (OK)
qtawesome >=1.2.1             :  1.2.1 (OK)
qtconsole >=5.4.0;<5.5.0      :  5.5.0.dev0 (OK)
qtpy >=2.1.0                  :  2.2.1 (OK)
rtree >=0.9.7                 :  1.0.0 (OK)
setuptools >=49.6.0           :  65.4.1 (OK)
sphinx >=0.6.6                :  5.2.3 (OK)
spyder_kernels >=2.4.2;<2.5.0 :  3.0.0.dev0 (OK)
textdistance >=4.2.0          :  4.5.0 (OK)
three_merge >=0.1.1           :  0.1.1 (OK)
watchdog >=0.10.3             :  2.1.9 (OK)
zmq >=22.1.0                  :  24.0.1 (OK)

# Optional:
cython >=0.21                 :  0.29.32 (OK)
matplotlib >=3.0.0            :  3.6.1 (OK)
numpy >=1.7                   :  1.23.3 (OK)
pandas >=1.1.1                :  1.5.0 (OK)
scipy >=0.17.0                :  1.9.2 (OK)
sympy >=0.7.3                 :  1.11.1 (OK)
```



Discussion:
Thanks for the feedback @impact27 ! What do you think @ccordoba12 ?
I think this is a quite valid UX bug. I didn't consider @impact27's scenario but I agree it's quite annoying that the buttons don't stay in the same place after clicking several times on them.

The only solution I see to solve it is to impose a max width for the search and replace line edits in the widget. So, after a certain editor's width, they wouldn't keep growing and that wouldn't affect the buttons placement. However, that solution would go against some of your suggestions @dalthviz on PR #20049.

So, what do you think?
I think the problem is the "2 matches" text which should have a fixed width
> So, what do you think?

I would say that fixing having jumping things is more important
> I think the problem is the "2 matches" text which should have a fixed width

Yeah, but it's difficult to set a width for that label because

* Users can have hundreds or even thousands of matches in their files (when looking for a single letter).
* That label is localized, so we don't know how many characters it can have on different languages.

So, the way to allow it to grow without us worrying about its width is by making it take space to its right (which is a `QSpacerItem`). But for that, the only solution that occurs to me is to make the width of the search and replace line edits fixed, so that when the label changes size it doesn't reduce their sizes.

> I would say that fixing having jumping things is more important

Ok, great. I'll try to come up with a solution for this in 5.4.3.
> Ok, great. I'll try to come up with a solution for this in 5.4.3.

I only see this in master, not in 5.x
That's strange because there are no differences between 5.x and master about that.
You are right. I can only see this if the editor width is small enough. I see this both in master and 5.x
@impact27, I propose the following solution for small widths: hide the matches label text, show an ellipsis icon for it instead and display the number of matches on its tooltip:

![image](https://user-images.githubusercontent.com/365293/221075409-8d9402a3-eda4-44fa-95a8-c6977af621e2.png)

That would prevent that that label changes size when total matches decrease due to replacements.

What do you think?
Sounds good

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -2216,10 +2216,14 @@ def current_changed(self, index):
             pass
 
         self.update_plugin_title.emit()
+
         # Make sure that any replace happens in the editor on top
         # See spyder-ide/spyder#9688.
         self.find_widget.set_editor(editor, refresh=False)
 
+        # Update total number of matches when switching files.
+        self.find_widget.update_matches()
+
         if editor is not None:
             # Needed in order to handle the close of files open in a directory
             # that has been renamed. See spyder-ide/spyder#5157.
diff --git a/spyder/utils/icon_manager.py b/spyder/utils/icon_manager.py
--- a/spyder/utils/icon_manager.py
+++ b/spyder/utils/icon_manager.py
@@ -181,6 +181,7 @@ def __init__(self):
             'replace_next':            [('mdi6.arrow-right-bottom',), {'color': self.MAIN_FG_COLOR}],
             'replace_all':             [('mdi.file-replace-outline',), {'color': self.MAIN_FG_COLOR}],
             'replace_selection':       [('ph.rectangle-bold',), {'color': self.MAIN_FG_COLOR}],
+            'number_matches':          [('mdi.pound-box-outline',), {'color': self.MAIN_FG_COLOR}],
             'undo':                    [('mdi.undo',), {'color': self.MAIN_FG_COLOR}],
             'redo':                    [('mdi.redo',), {'color': self.MAIN_FG_COLOR}],
             'refresh':                 [('mdi.refresh',), {'color': self.MAIN_FG_COLOR}],
diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py
--- a/spyder/widgets/findreplace.py
+++ b/spyder/widgets/findreplace.py
@@ -16,7 +16,7 @@
 
 # Third party imports
 from qtpy.QtCore import QEvent, QSize, Qt, QTimer, Signal, Slot
-from qtpy.QtGui import QTextCursor
+from qtpy.QtGui import QPixmap, QTextCursor
 from qtpy.QtWidgets import (QAction, QGridLayout, QHBoxLayout, QLabel,
                             QLineEdit, QToolButton, QSizePolicy, QSpacerItem,
                             QWidget)
@@ -81,6 +81,9 @@ def __init__(self, parent, enable_replace=False):
         )
         glayout.addWidget(self.close_button, 0, 0)
 
+        # Icon size is the same for all buttons
+        self.icon_size = self.close_button.iconSize()
+
         # Find layout
         self.search_text = SearchText(self)
 
@@ -101,9 +104,12 @@ def __init__(self, parent, enable_replace=False):
         self.search_text.sig_resized.connect(self._resize_replace_text)
 
         self.number_matches_text = QLabel(self)
-        self.search_text.clear_action.triggered.connect(
-            self.number_matches_text.hide
+        self.search_text.clear_action.triggered.connect(self.clear_matches)
+        self.hide_number_matches_text = False
+        self.number_matches_pixmap = (
+            ima.icon('number_matches').pixmap(self.icon_size)
         )
+        self.matches_string = ""
 
         self.no_matches_icon = ima.icon('no_matches')
         self.error_icon = ima.icon('error')
@@ -111,9 +117,6 @@ def __init__(self, parent, enable_replace=False):
         self.messages_action.setVisible(False)
         self.search_text.lineEdit().addAction(
             self.messages_action, QLineEdit.TrailingPosition)
-        self.search_text.clear_action.triggered.connect(
-            lambda: self.messages_action.setVisible(False)
-        )
 
         # Button corresponding to the messages_action above
         self.messages_button = (
@@ -248,12 +251,21 @@ def __init__(self, parent, enable_replace=False):
         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
         self.shortcuts = self.create_shortcuts(parent)
 
+        # To highlight found results in the editor
         self.highlight_timer = QTimer(self)
         self.highlight_timer.setSingleShot(True)
         self.highlight_timer.setInterval(300)
         self.highlight_timer.timeout.connect(self.highlight_matches)
+
+        # Install event filter for search_text
         self.search_text.installEventFilter(self)
 
+        # To avoid painting number_matches_text on every resize event
+        self.show_matches_timer = QTimer(self)
+        self.show_matches_timer.setSingleShot(True)
+        self.show_matches_timer.setInterval(25)
+        self.show_matches_timer.timeout.connect(self.show_matches)
+
     def eventFilter(self, widget, event):
         """
         Event filter for search_text widget.
@@ -345,20 +357,11 @@ def update_search_combo(self):
     def update_replace_combo(self):
         self.replace_text.lineEdit().returnPressed.emit()
 
-    @Slot(bool)
-    def toggle_highlighting(self, state):
-        """Toggle the 'highlight all results' feature"""
-        if self.editor is not None:
-            if state:
-                self.highlight_matches()
-            else:
-                self.clear_matches()
-
     def show(self, hide_replace=True):
         """Overrides Qt Method"""
         QWidget.show(self)
 
-        self._resize_search_text()
+        self._width_adjustments()
         self.visibility_changed.emit(True)
         self.change_number_matches()
 
@@ -397,7 +400,7 @@ def show(self, hide_replace=True):
 
     def resizeEvent(self, event):
         super().resizeEvent(event)
-        self._resize_search_text()
+        self._width_adjustments()
 
     @Slot()
     def replace_widget(self, replace_on):
@@ -512,11 +515,14 @@ def highlight_matches(self):
             case = self.case_button.isChecked()
             word = self.words_button.isChecked()
             regexp = self.re_button.isChecked()
-            self.editor.highlight_found_results(text, word=word,
-                                                regexp=regexp, case=case)
+            self.editor.highlight_found_results(
+                text, word=word, regexp=regexp, case=case)
 
     def clear_matches(self):
         """Clear all highlighted matches"""
+        self.matches_string = ""
+        self.messages_action.setVisible(False)
+        self.number_matches_text.hide()
         if self.is_code_editor:
             self.editor.clear_found_results()
 
@@ -536,7 +542,6 @@ def find(self, changed=True, forward=True, rehighlight=True,
                 # Clears the selection for WebEngine
                 self.editor.find_text('')
             self.change_number_matches()
-            self.messages_action.setVisible(False)
             self.clear_matches()
             return None
         else:
@@ -748,16 +753,12 @@ def replace_find_selection(self, focus_replace_text=False):
     def change_number_matches(self, current_match=0, total_matches=0):
         """Change number of match and total matches."""
         if current_match and total_matches:
-            self.number_matches_text.show()
-            self.messages_action.setVisible(False)
-            matches_string = u"{} {} {}".format(current_match, _(u"of"),
-                                                total_matches)
-            self.number_matches_text.setText(matches_string)
+            self.matches_string = "{} {} {}".format(current_match, _("of"),
+                                                    total_matches)
+            self.show_matches()
         elif total_matches:
-            self.number_matches_text.show()
-            self.messages_action.setVisible(False)
-            matches_string = u"{} {}".format(total_matches, _(u"matches"))
-            self.number_matches_text.setText(matches_string)
+            self.matches_string = "{} {}".format(total_matches, _("matches"))
+            self.show_matches()
         else:
             self.number_matches_text.hide()
             if self.search_text.currentText():
@@ -778,6 +779,21 @@ def show_no_matches(self):
         """Show a no matches message with an icon."""
         self._show_icon_message('no_matches')
 
+    def show_matches(self):
+        """Show the number of matches found in the document."""
+        if not self.matches_string:
+            return
+
+        self.number_matches_text.show()
+        self.messages_action.setVisible(False)
+
+        if self.hide_number_matches_text:
+            self.number_matches_text.setPixmap(self.number_matches_pixmap)
+            self.number_matches_text.setToolTip(self.matches_string)
+        else:
+            self.number_matches_text.setPixmap(QPixmap())
+            self.number_matches_text.setText(self.matches_string)
+
     def show_error(self, error_msg):
         """Show a regexp error message with an icon."""
         self._show_icon_message('error', extra_info=error_msg)
@@ -808,13 +824,29 @@ def _show_icon_message(self, kind, extra_info=None):
         self.messages_action.setToolTip(tooltip)
         self.messages_action.setVisible(True)
 
-    def _resize_search_text(self):
-        """Adjust search_text combobox min width according to total one."""
+    def _width_adjustments(self):
+        """Several adjustments according to the widget's total width."""
+        # The widgets list includes search_text and number_matches_text. That's
+        # why we substract a 2 below.
+        buttons_width = self.icon_size.width() * (len(self.widgets) - 2)
+
         total_width = self.size().width()
-        if total_width < (self.search_text.recommended_width + 200):
+        matches_width = self.number_matches_text.size().width()
+        minimal_width = (
+            self.search_text.recommended_width + buttons_width + matches_width
+        )
+
+        if total_width < minimal_width:
             self.search_text.setMinimumWidth(30)
+            self.hide_number_matches_text = True
         else:
             self.search_text.setMinimumWidth(int(total_width / 2))
+            self.hide_number_matches_text = False
+
+        # We don't call show_matches directly here to avoid flickering when the
+        # user hits the widget's minimal width, which changes from text to an
+        # icon (or vice versa) for number_matches_text.
+        self.show_matches_timer.start()
 
     def _resize_replace_text(self, size, old_size):
         """


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_editor.py b/spyder/plugins/editor/widgets/tests/test_editor.py
--- a/spyder/plugins/editor/widgets/tests/test_editor.py
+++ b/spyder/plugins/editor/widgets/tests/test_editor.py
@@ -75,6 +75,7 @@ def editor_find_replace_bot(base_editor_bot, qtbot):
 
     editor_stack = base_editor_bot
     layout.addWidget(editor_stack)
+    widget.editor_stack = editor_stack
 
     text = ('spam bacon\n'
             'spam sausage\n'
@@ -89,7 +90,7 @@ def editor_find_replace_bot(base_editor_bot, qtbot):
     layout.addWidget(find_replace)
 
     # Resize widget and show
-    widget.resize(480, 360)
+    widget.resize(900, 360)
     widget.show()
 
     return widget
@@ -664,6 +665,35 @@ def test_tab_copies_find_to_replace(editor_find_replace_bot, qtbot):
     assert finder.replace_text.currentText() == 'This is some test text!'
 
 
+def test_update_matches_in_find_replace(editor_find_replace_bot, qtbot):
+    """
+    Check that the total number of matches in the FindReplace widget is updated
+    when switching files.
+    """
+    editor_stack = editor_find_replace_bot.editor_stack
+    finder = editor_find_replace_bot.find_replace
+
+    # Search for "spam" in current file
+    finder.show(hide_replace=False)
+    finder.search_text.setFocus()
+    finder.search_text.set_current_text('spam')
+    qtbot.wait(500)
+    qtbot.keyClick(finder.search_text, Qt.Key_Return)
+
+    # Open a new file and only write "spam" on it
+    editor_stack.new('foo.py', 'utf-8', 'spam')
+
+    # Focus new file and check the number of matches was updated
+    editor_stack.set_stack_index(1)
+    assert finder.number_matches_text.text() == '1 matches'
+    qtbot.wait(500)
+
+    # Focus initial file and check the number of matches was updated
+    editor_stack.set_stack_index(0)
+    qtbot.wait(500)
+    assert finder.number_matches_text.text() == '3 matches'
+
+
 def test_autosave_all(editor_bot, mocker):
     """
     Test that `autosave_all()` calls maybe_autosave() on all open buffers.
diff --git a/spyder/widgets/tests/test_findreplace.py b/spyder/widgets/tests/test_findreplace.py
--- a/spyder/widgets/tests/test_findreplace.py
+++ b/spyder/widgets/tests/test_findreplace.py
@@ -50,7 +50,7 @@ def findreplace_editor(qtbot, request):
     layout.addWidget(findreplace)
 
     # Resize widget and show
-    widget.resize(480, 360)
+    widget.resize(900, 360)
     widget.show()
 
     return widget