diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index b6c4f0dc43c..c3bc44e982f 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -14,6 +14,7 @@ import gc import os import os.path as osp +from pathlib import Path import random import re import shutil @@ -2535,9 +2536,15 @@ def example_def_2(): @flaky(max_runs=3) -def test_switcher_project_files(main_window, qtbot, tmpdir): - """Test the number of items in the switcher when a project is active.""" - # Wait until the window is fully up +def test_switcher_projects_integration(main_window, pytestconfig, qtbot, + tmp_path): + """Test integration between the Switcher and Projects plugins.""" + # Disable pytest stdin capture to make calls to fzf work. Idea taken from: + # https://github.com/pytest-dev/pytest/issues/2189#issuecomment-449512764 + capmanager = pytestconfig.pluginmanager.getplugin('capturemanager') + capmanager.suspend_global_capture(in_=True) + + # Wait until the console is fully up shell = main_window.ipyconsole.get_current_shellwidget() qtbot.waitUntil( lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, @@ -2550,47 +2557,107 @@ def test_switcher_project_files(main_window, qtbot, tmpdir): editorstack = main_window.editor.get_current_editorstack() # Create a temp project directory - project_dir = to_text_string(tmpdir.mkdir('test')) + project_dir = tmp_path / 'test-projects-switcher' + project_dir.mkdir() + + # Create some empty files in the project dir + n_files_project = 3 + for i in range(n_files_project): + fpath = project_dir / f"test_file{i}.py" + fpath.touch() + + # Copy binary file from our source tree to the project to check it's not + # displayed in the switcher. + binary_file = Path(LOCATION).parents[1] / 'images' / 'windows_app_icon.ico' + binary_file_copy = project_dir / 'windows.ico' + shutil.copyfile(binary_file, binary_file_copy) # Create project with qtbot.waitSignal(projects.sig_project_loaded): - projects.create_project(project_dir) + projects.create_project(str(project_dir)) - # Create four empty files in the project dir - for i in range(3): - main_window.editor.new("test_file"+str(i)+".py") + # Check that the switcher has been populated in Projects + qtbot.waitUntil( + lambda: projects.get_widget()._default_switcher_paths != [], + timeout=1000 + ) + # Assert that the number of items in the switcher is correct switcher.open_switcher() - n_files_project = len(projects.get_project_filenames()) n_files_open = editorstack.get_stack_count() + assert switcher.count() == n_files_open + n_files_project + switcher.on_close() - # Assert that the number of items in the switcher is correct - assert switcher_widget.model.rowCount() == n_files_open + n_files_project + # Assert only two items have visible sections + switcher.open_switcher() + + sections = [] + for row in range(switcher.count()): + item = switcher_widget.model.item(row) + if item._section_visible: + sections.append(item.get_section()) + + assert len(sections) == 2 switcher.on_close() - # Close all files opened in editorstack - main_window.editor.close_all_files() + # Assert searching text in the switcher works as expected + switcher.open_switcher() + switcher.set_search_text('0') + qtbot.wait(500) + assert switcher.count() == 1 + switcher.on_close() + # Assert searching for a non-existent file leaves the switcher empty switcher.open_switcher() - n_files_project = len(projects.get_project_filenames()) - n_files_open = editorstack.get_stack_count() - assert switcher_widget.model.rowCount() == n_files_open + n_files_project + switcher.set_search_text('foo') + qtbot.wait(500) + assert switcher.count() == 0 switcher.on_close() - # Select file in the project explorer + # Assert searching for a binary file leaves the switcher empty + switcher.open_switcher() + switcher.set_search_text('windows') + qtbot.wait(500) + assert switcher.count() == 0 + switcher.on_close() + + # Remove project file and check the switcher is updated + n_files_project -= 1 + os.remove(str(project_dir / 'test_file1.py')) + qtbot.wait(500) + switcher.open_switcher() + assert switcher.count() == n_files_open + n_files_project + switcher.on_close() + + # Check that a project file opened in the editor is not shown twice in the + # switcher idx = projects.get_widget().treewidget.get_index( - osp.join(project_dir, 'test_file0.py')) + str(project_dir / 'test_file0.py') + ) projects.get_widget().treewidget.setCurrentIndex(idx) - - # Press Enter there qtbot.keyClick(projects.get_widget().treewidget, Qt.Key_Enter) switcher.open_switcher() - n_files_project = len(projects.get_project_filenames()) n_files_open = editorstack.get_stack_count() - assert switcher_widget.model.rowCount() == n_files_open + n_files_project + assert switcher.count() == n_files_open + n_files_project - 1 switcher.on_close() + # Check the switcher works without fzf + fzf = projects.get_widget()._fzf + projects.get_widget()._fzf = None + projects.get_widget()._default_switcher_paths = [] + + switcher.open_switcher() + switcher.set_search_text('0') + qtbot.wait(500) + assert switcher.count() == 1 + switcher.on_close() + + projects.get_widget()._fzf = fzf + + # Resume capturing + capmanager.resume_global_capture() + @flaky(max_runs=3) @pytest.mark.skipif(sys.platform == 'darwin', diff --git a/spyder/app/utils.py b/spyder/app/utils.py index 5ff9c51f95c..1ef905bea9f 100644 --- a/spyder/app/utils.py +++ b/spyder/app/utils.py @@ -275,8 +275,20 @@ def create_application(): # The try/except is necessary to run the main window tests on their own. try: app.set_font() - except AttributeError: - pass + except AttributeError as error: + if running_under_pytest(): + # Set font options to avoid a ton of Qt warnings when running tests + app_family = app.font().family() + app_size = app.font().pointSize() + CONF.set('appearance', 'app_font/family', app_family) + CONF.set('appearance', 'app_font/size', app_size) + + from spyder.config.fonts import MEDIUM, MONOSPACE + CONF.set('appearance', 'monospace_app_font/family', MONOSPACE[0]) + CONF.set('appearance', 'monospace_app_font/size', MEDIUM) + else: + # Raise in case the error is valid + raise error # Required for correct icon on GNOME/Wayland: if hasattr(app, 'setDesktopFileName'): diff --git a/spyder/config/utils.py b/spyder/config/utils.py index 6f7738f5a0a..eb5b77b5e36 100644 --- a/spyder/config/utils.py +++ b/spyder/config/utils.py @@ -116,11 +116,11 @@ def get_filter(filetypes, ext): return '' -def get_edit_filetypes(): +def get_edit_filetypes(ignore_pygments_extensions=True): """Get all file types supported by the Editor""" - # The filter details are not hidden on Windows, so we can't use - # all Pygments extensions on that platform - if os.name == 'nt': + # The filter details are not hidden on Windows, so we can't use all + # Pygments extensions on that platform. + if os.name == 'nt' and ignore_pygments_extensions: supported_exts = [] else: try: @@ -154,8 +154,8 @@ def get_edit_extensions(): Return extensions associated with the file types supported by the Editor """ - edit_filetypes = get_edit_filetypes() - return _get_extensions(edit_filetypes)+[''] + edit_filetypes = get_edit_filetypes(ignore_pygments_extensions=False) + return _get_extensions(edit_filetypes) + [''] #============================================================================== diff --git a/spyder/plugins/editor/utils/switcher_manager.py b/spyder/plugins/editor/utils/switcher_manager.py index 4f570700733..6f8e1d40ffd 100644 --- a/spyder/plugins/editor/utils/switcher_manager.py +++ b/spyder/plugins/editor/utils/switcher_manager.py @@ -61,6 +61,9 @@ def setup_switcher(self): self._switcher.sig_rejected.connect(self.handle_switcher_rejection) self._switcher.sig_item_changed.connect( self.handle_switcher_item_change) + self._switcher.sig_search_text_available.connect( + lambda text: self._switcher.setup() + ) def handle_switcher_modes(self, mode): """Handle switcher for registered modes.""" @@ -78,16 +81,9 @@ def create_editor_switcher(self): _('Start typing the name of an open file')) editorstack = self._editorstack() - - # Since editor open files are inserted at position 0, the - # list needs to be reversed so they're shown in order. editor_list = editorstack.data.copy() - editor_list.reverse() - - paths = [data.filename.lower() - for data in editor_list] - save_statuses = [data.newly_created - for data in editor_list] + paths = [data.filename for data in editor_list] + save_statuses = [data.newly_created for data in editor_list] short_paths = shorten_paths(paths, save_statuses) for idx, data in enumerate(editor_list): @@ -99,15 +95,18 @@ def create_editor_switcher(self): if len(paths[idx]) > 75: path = short_paths[idx] else: - path = osp.dirname(data.filename.lower()) + path = osp.dirname(data.filename) last_item = (idx + 1 == len(editor_list)) - self._switcher.add_item(title=title, - description=path, - icon=icon, - section=self._section, - data=data, - last_item=last_item) - self._switcher.set_current_row(0) + + self._switcher.add_item( + title=title, + description=path, + icon=icon, + section=self._section, + data=data, + last_item=last_item, + score=0 # To make these items appear above those from Projects + ) def create_line_switcher(self): """Populate switcher with line info.""" diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 92144612ce6..f5e31358ec8 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -216,7 +216,7 @@ def on_switcher_available(self): self._switcher.sig_item_selected.connect( self._handle_switcher_selection) self._switcher.sig_search_text_available.connect( - self._handle_switcher_results) + self._handle_switcher_search) @on_plugin_teardown(plugin=Plugins.Editor) def on_editor_teardown(self): @@ -281,7 +281,7 @@ def on_switcher_teardown(self): self._switcher.sig_item_selected.disconnect( self._handle_switcher_selection) self._switcher.sig_search_text_available.disconnect( - self._handle_switcher_results) + self._handle_switcher_search) self._switcher = None def on_close(self, cancelable=False): @@ -508,17 +508,11 @@ def _handle_switcher_modes(self, mode): mode: str The selected mode (open files "", symbol "@" or line ":"). """ - items = self.get_widget().handle_switcher_modes() - for (title, description, icon, section, path, is_last_item) in items: - self._switcher.add_item( - title=title, - description=description, - icon=icon, - section=section, - data=path, - last_item=is_last_item - ) - self._switcher.set_current_row(0) + # Don't compute anything if we're not in files mode + if mode != "": + return + + self.get_widget().display_default_switcher_items() def _handle_switcher_selection(self, item, mode, search_text): """ @@ -540,20 +534,33 @@ def _handle_switcher_selection(self, item, mode, search_text): self.get_widget().handle_switcher_selection(item, mode, search_text) self._switcher.hide() - def _handle_switcher_results(self, search_text, items_data): + def _handle_switcher_search(self, search_text): """ Handle user typing in switcher to filter results. - Load switcher results when a search text is typed for projects. Parameters ---------- text: str The current search text in the switcher dialog box. - items_data: list - List of items shown in the switcher. """ - items = self.get_widget().handle_switcher_results(search_text, - items_data) + self.get_widget().handle_switcher_search(search_text) + + def _display_items_in_switcher(self, items, setup, clear_section): + """ + Display a list of items in the switcher. + + Parameters + ---------- + items: list + Items to display. + setup: bool + Call the switcher's setup after adding the items. + clear_section: bool + Clear Projects section before adding the items. + """ + if clear_section: + self._switcher.remove_section(self.get_widget().get_title()) + for (title, description, icon, section, path, is_last_item) in items: self._switcher.add_item( title=title, @@ -562,5 +569,9 @@ def _handle_switcher_results(self, search_text, items_data): section=section, data=path, last_item=is_last_item, - score=100 + score=1e10, # To make the editor results appear first + use_score=False # Results come from fzf in the right order ) + + if setup: + self._switcher.setup() diff --git a/spyder/plugins/projects/tests/test_plugin.py b/spyder/plugins/projects/tests/test_plugin.py index 812950ce6e4..fe7419ab637 100644 --- a/spyder/plugins/projects/tests/test_plugin.py +++ b/spyder/plugins/projects/tests/test_plugin.py @@ -351,7 +351,7 @@ def test_project_explorer_tree_root(projects, tmpdir, qtbot): # Open the projects. for ppath in [ppath1, ppath2]: projects.open_project(path=ppath) - projects.get_widget()._update_explorer(None) + projects.get_widget()._setup_project(ppath) # Check that the root path of the project explorer tree widget is # set correctly. diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index f7adbdaad0f..da82b654782 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -14,7 +14,6 @@ import os.path as osp import pathlib import shutil -import subprocess # Third party imports from qtpy.compat import getexistingdirectory @@ -28,6 +27,7 @@ from spyder.api.widgets.main_widget import PluginMainWidget from spyder.config.base import ( get_home_dir, get_project_config_folder, running_under_pytest) +from spyder.config.utils import get_edit_extensions from spyder.plugins.completion.api import ( CompletionRequestTypes, FileChangeType) from spyder.plugins.completion.decorators import ( @@ -40,9 +40,11 @@ from spyder.plugins.projects.widgets.projectexplorer import ( ProjectExplorerTreeWidget) from spyder.plugins.switcher.utils import get_file_icon, shorten_paths -from spyder.widgets.helperwidgets import PaneEmptyWidget from spyder.utils import encoding from spyder.utils.misc import getcwd_or_home +from spyder.utils.programs import find_program +from spyder.utils.workers import WorkerManager +from spyder.widgets.helperwidgets import PaneEmptyWidget # For logging @@ -77,8 +79,14 @@ class RecentProjectsMenuSections: # ----------------------------------------------------------------------------- @class_register class ProjectExplorerWidget(PluginMainWidget): - """Project Explorer""" + """Project explorer main widget.""" + + # ---- Constants + # ------------------------------------------------------------------------- + MAX_SWITCHER_RESULTS = 50 + # ---- Signals + # ------------------------------------------------------------------------- sig_open_file_requested = Signal(str) """ This signal is emitted when a file is requested to be opened. @@ -150,11 +158,11 @@ class ProjectExplorerWidget(PluginMainWidget): def __init__(self, name, plugin, parent=None): super().__init__(name, plugin=plugin, parent=parent) - # Attributes from conf + # -- Attributes from conf self.name_filters = self.get_conf('name_filters') self.show_hscrollbar = self.get_conf('show_hscrollbar') - # Main attributes + # -- Main attributes self.recent_projects = self._get_valid_recent_projects( self.get_conf('recent_projects', []) ) @@ -162,8 +170,10 @@ def __init__(self, name, plugin, parent=None): self.current_active_project = None self.latest_project = None self.completions_available = False + self._fzf = find_program('fzf') + self._default_switcher_paths = [] - # Tree widget + # -- Tree widget self.treewidget = ProjectExplorerTreeWidget(self, self.show_hscrollbar) self.treewidget.setup() self.treewidget.setup_view() @@ -178,14 +188,29 @@ def __init__(self, name, plugin, parent=None): _("Create one using the menu entry Projects > New project.") ) - # Watcher + # -- Watcher self.watcher = WorkspaceWatcher(self) self.watcher.connect_signals(self) - # Signals - self.sig_project_loaded.connect(self._update_explorer) + # -- Worker manager for calls to fzf + self._worker_manager = WorkerManager(self) + + # -- List of possible file extensions that can be opened in the Editor + self._edit_extensions = get_edit_extensions() + + # -- Signals + self.sig_project_loaded.connect(self._setup_project) - # Layout + # This is necessary to populate the switcher with some default list of + # paths instead of computing that list every time it's shown. + self.sig_project_loaded.connect( + lambda p: self._update_default_switcher_paths() + ) + + # Clear saved paths for the switcher when closing the project. + self.sig_project_closed.connect(lambda p: self._clear_switcher_paths()) + + # -- Layout layout = QVBoxLayout() layout.addWidget(self.pane_empty) layout.addWidget(self.treewidget) @@ -198,7 +223,7 @@ def __init__(self, name, plugin, parent=None): # ---- PluginMainWidget API # ------------------------------------------------------------------------- def get_title(self): - return _("Projects") + return _("Project") def setup(self): """Setup the widget.""" @@ -254,11 +279,13 @@ def setup(self): def set_pane_empty(self): self.treewidget.hide() self.pane_empty.show() - def update_actions(self): pass + def on_close(self): + self._worker_manager.terminate_all() + # ---- Public API # ------------------------------------------------------------------------- @Slot() @@ -610,41 +637,16 @@ def show_widget(self): self.raise_() self.update() - def handle_switcher_modes(self): - """ - Populate switcher with files in active project. - - List the file names of the current active project with their - directories in the switcher. - """ - paths = self._execute_fzf_subprocess() - if paths == []: - return [] - # the paths that are opened in the editor need to be excluded because - # they are shown already in the switcher in the "editor" section. - open_files = self.get_plugin()._get_open_filenames() - for file in open_files: - normalized_path = osp.normpath(file).lower() - if normalized_path in paths: - paths.remove(normalized_path) - - is_unsaved = [False] * len(paths) - short_paths = shorten_paths(paths, is_unsaved) - section = self.get_title() - - items = [] - for i, (path, short_path) in enumerate(zip(paths, short_paths)): - title = osp.basename(path) - icon = get_file_icon(path) - description = osp.dirname(path) - if len(path) > 75: - description = short_path - is_last_item = (i+1 == len(paths)) + # ---- Public API for the Switcher + # ------------------------------------------------------------------------- + def display_default_switcher_items(self): + """Populate switcher with a default set of files in the project.""" + if not self._default_switcher_paths: + return - item_tuple = (title, description, icon, - section, path, is_last_item) - items.append(item_tuple) - return items + self._display_paths_in_switcher( + self._default_switcher_paths, setup=False, clear_section=False + ) def handle_switcher_selection(self, item, mode, search_text): """ @@ -663,14 +665,13 @@ def handle_switcher_selection(self, item, mode, search_text): search_text: str Cleaned search/filter text. """ - if item.get_section() != self.get_title(): return # Open file in editor self.sig_open_file_requested.emit(item.get_data()) - def handle_switcher_results(self, search_text, items_data): + def handle_switcher_search(self, search_text): """ Handle user typing in switcher to filter results. @@ -679,31 +680,8 @@ def handle_switcher_results(self, search_text, items_data): ---------- text: str The current search text in the switcher dialog box. - items_data: list - List of items shown in the switcher. """ - paths = self._execute_fzf_subprocess(search_text) - for sw_path in items_data: - if (sw_path in paths): - paths.remove(sw_path) - - is_unsaved = [False] * len(paths) - short_paths = shorten_paths(paths, is_unsaved) - section = self.get_title() - - items = [] - for i, (path, short_path) in enumerate(zip(paths, short_paths)): - title = osp.basename(path) - icon = get_file_icon(path) - description = osp.dirname(path).lower() - if len(path) > 75: - description = short_path - is_last_item = (i+1 == len(paths)) - - item_tuple = (title, description, icon, - section, path, is_last_item) - items.append(item_tuple) - return items + self._call_fzf(search_text) # ---- Public API for the LSP # ------------------------------------------------------------------------- @@ -737,6 +715,9 @@ def handle_response(self, method, params): @Slot(str, bool) def file_created(self, src_file, is_dir): """Notify LSP server about file creation.""" + self._update_default_switcher_paths() + + # LSP specification only considers file updates if is_dir: return @@ -753,7 +734,8 @@ def file_created(self, src_file, is_dir): requires_response=False) def file_moved(self, src_file, dest_file, is_dir): """Notify LSP server about a file that is moved.""" - # LSP specification only considers file updates + self._update_default_switcher_paths() + if is_dir: return @@ -778,6 +760,8 @@ def file_moved(self, src_file, dest_file, is_dir): @Slot(str, bool) def file_deleted(self, src_file, is_dir): """Notify LSP server about file deletion.""" + self._update_default_switcher_paths() + if is_dir: return @@ -996,10 +980,6 @@ def _load_config(self): if expanded_state is not None: self.treewidget.set_expanded_state(expanded_state) - def _update_explorer(self, _unused): - """Update explorer tree""" - self._setup_project(self.get_active_project_path()) - def _get_valid_recent_projects(self, recent_projects): """ Get the list of valid recent projects. @@ -1015,55 +995,117 @@ def _get_valid_recent_projects(self, recent_projects): return valid_projects - def _execute_fzf_subprocess(self, search_text=""): + # ---- Private API for the Switcher + # ------------------------------------------------------------------------- + def _call_fzf(self, search_text=""): """ - Execute fzf subprocess to get the list of files in the current - project filtered by `search_text`. + Call fzf in a worker to get the list of files in the current project + that match with `search_text`. Parameters ---------- - search_text: str - The current search text in the switcher dialog box. + search_text: str, optional + The search text to pass to fzf. """ project_path = self.get_active_project_path() - if project_path is None: - return [] + if self._fzf is None or project_path is None: + return - # command = fzf --filter - cmd_list = ["fzf", "--filter", search_text] - shell = False - env = os.environ.copy() + self._worker_manager.terminate_all() - # This is only available on Windows - if os.name == 'nt': - startupinfo = subprocess.STARTUPINFO() + worker = self._worker_manager.create_process_worker( + [self._fzf, "--filter", search_text], + os.environ.copy() + ) + + worker.set_cwd(project_path) + worker.sig_finished.connect(self._process_fzf_output) + worker.start() + + def _process_fzf_output(self, worker, output, error): + """Process output that comes from the fzf worker.""" + if output is None or error: + return + + # Get list of paths from fzf output + relative_path_list = output.decode('utf-8').strip().split("\n") + + # List of results with absolute path + if relative_path_list != ['']: + project_path = self.get_active_project_path() + result_list = [ + osp.normpath(os.path.join(project_path, path)) + for path in relative_path_list + ] else: - startupinfo = None + result_list = [] - try: - out = subprocess.check_output( - cmd_list, - cwd=project_path, - shell=shell, - env=env, - startupinfo=startupinfo, - stderr=subprocess.STDOUT + # Filter files that can be opened in the editor + result_list = [ + path for path in result_list + if osp.splitext(path)[1] in self._edit_extensions + ] + + # Limit the number of results to not introduce lags when displaying + # them in the switcher. + if len(result_list) > self.MAX_SWITCHER_RESULTS: + result_list = result_list[:self.MAX_SWITCHER_RESULTS] + + if not self._default_switcher_paths: + self._default_switcher_paths = result_list + else: + self._display_paths_in_switcher( + result_list, setup=True, clear_section=True ) - relative_path_list = out.decode('UTF-8').strip().split("\n") + def _convert_paths_to_switcher_items(self, paths): + """ + Convert a list of paths to items that can be shown in the switcher. + """ + # The paths that are opened in the editor need to be excluded because + # they are already shown in the Editor section of the switcher. + open_files = self.get_plugin()._get_open_filenames() + for file in open_files: + normalized_path = osp.normpath(file) + if normalized_path in paths: + paths.remove(normalized_path) - # List of tuples with the absolute path - result_list = [ - osp.normpath(os.path.join(project_path, path)).lower() - for path in relative_path_list] + is_unsaved = [False] * len(paths) + short_paths = shorten_paths(paths, is_unsaved) + section = self.get_title() + + items = [] + for i, (path, short_path) in enumerate(zip(paths, short_paths)): + title = osp.basename(path) + icon = get_file_icon(path) + description = osp.dirname(path) + if len(path) > 75: + description = short_path + is_last_item = (i + 1 == len(paths)) + + item_tuple = ( + title, description, icon, section, path, is_last_item + ) + items.append(item_tuple) + + return items + + def _display_paths_in_switcher(self, paths, setup, clear_section): + """Display a list of paths in the switcher.""" + items = self._convert_paths_to_switcher_items(paths) + + # Call directly the plugin's method instead of emitting a signal + # because it's faster. + self._plugin._display_items_in_switcher(items, setup, clear_section) - # Limit the number of results to 500 - if (len(result_list) > 500): - result_list = result_list[:500] - return result_list - except (subprocess.CalledProcessError, FileNotFoundError): - return [] + def _clear_switcher_paths(self): + """Clear saved switcher results.""" + self._default_switcher_paths = [] + def _update_default_switcher_paths(self): + """Update default paths to be shown in the switcher.""" + self._default_switcher_paths = [] + self._call_fzf() # ============================================================================= # Tests diff --git a/spyder/plugins/switcher/container.py b/spyder/plugins/switcher/container.py index 24f055a7219..9becff78d96 100644 --- a/spyder/plugins/switcher/container.py +++ b/spyder/plugins/switcher/container.py @@ -58,11 +58,14 @@ def open_switcher(self, symbol=False): switcher.hide() return + # Set mode and setup if symbol: switcher.set_search_text('@') else: switcher.set_search_text('') - switcher.setup() + + # Setup + switcher.setup() # Set position mainwindow = self._plugin.get_main() diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index 160a68fedde..14164a5de40 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -90,7 +90,7 @@ class Switcher(SpyderPluginV2): The selected mode (open files "", symbol "@" or line ":"). """ - sig_search_text_available = Signal(str, list) + sig_search_text_available = Signal(str) """ This signal is emitted when the user stops typing the search/filter text. @@ -98,8 +98,6 @@ class Switcher(SpyderPluginV2): ---------- search_text: str The current search/filter text. - items_data: list - List of items shown in the switcher. """ # --- SpyderPluginV2 API @@ -162,16 +160,15 @@ def on_main_menu_teardown(self): menu_id=ApplicationMenus.File ) - # --- Public API - # ------------------------------------------------------------------------ - + # ---- Public API + # ------------------------------------------------------------------------- # Switcher methods def set_placeholder_text(self, text): """Set the text appearing on the empty line edit.""" self._switcher.set_placeholder_text(text) def setup(self): - """Set-up list widget content based on the filtering.""" + """Setup list widget content based on filtering.""" self._switcher.setup() def open_switcher(self, symbol=False): @@ -206,11 +203,11 @@ def current_item(self): def add_item(self, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, action_item=False, - last_item=True, score=None): + last_item=True, score=None, use_score=True): """Add a switcher list item.""" self._switcher.add_item(icon, title, description, shortcut, section, data, tool_tip, action_item, - last_item, score) + last_item, score, use_score) def set_current_row(self, row): """Set the current selected row in the switcher.""" @@ -228,6 +225,10 @@ def count(self): """Get the item count in the list widget.""" return self._switcher.count() + def remove_section(self, section): + """Remove all items in a section of the switcher.""" + self._switcher.remove_section(section) + # Mode methods def add_mode(self, token, description): """Add mode by token key and description.""" diff --git a/spyder/plugins/switcher/widgets/item.py b/spyder/plugins/switcher/widgets/item.py index f19a487e96e..e7cec5e3632 100644 --- a/spyder/plugins/switcher/widgets/item.py +++ b/spyder/plugins/switcher/widgets/item.py @@ -28,9 +28,9 @@ class SwitcherBaseItem(QStandardItem): _STYLES = None _TEMPLATE = None - def __init__(self, parent=None, styles=_STYLES): + def __init__(self, parent=None, styles=_STYLES, use_score=True): """Create basic List Item.""" - super(SwitcherBaseItem, self).__init__() + super().__init__() # Style self._width = self._WIDTH @@ -39,6 +39,7 @@ def __init__(self, parent=None, styles=_STYLES): self._action_item = False self._score = -1 self._height = self._get_height() + self._use_score = use_score # Setup # self._height is a float from QSizeF but @@ -84,8 +85,9 @@ def get_score(self): def set_score(self, value): """Set the search text fuzzy match score.""" - self._score = value - self._set_rendered_text() + if self._use_score: + self._score = value + self._set_rendered_text() def is_action_item(self): """Return whether the item is of action type.""" @@ -122,8 +124,7 @@ class SwitcherSeparatorItem(SwitcherBaseItem): def __init__(self, parent=None, styles=_STYLES): """Separator Item represented as
.""" - super(SwitcherSeparatorItem, self).__init__(parent=parent, - styles=styles) + super().__init__(parent=parent, styles=styles) self.setFlags(Qt.NoItemFlags) self._set_rendered_text() @@ -218,9 +219,9 @@ class SwitcherItem(SwitcherBaseItem): def __init__(self, parent=None, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, - action_item=False, styles=_STYLES): + action_item=False, styles=_STYLES, score=-1, use_score=True): """Switcher item with title, description, shortcut and section.""" - super(SwitcherItem, self).__init__(parent=parent, styles=styles) + super().__init__(parent=parent, styles=styles, use_score=use_score) self._title = title if title else '' self._rich_title = '' @@ -229,10 +230,12 @@ def __init__(self, parent=None, icon=None, title=None, description=None, self._section = section if section else '' self._icon = icon self._data = data - self._score = -1 + self._score = score self._action_item = action_item - self._section_visible = True + # Section visibility is computed by the setup_sections method of the + # switcher. + self._section_visible = False # Setup self.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index c29906a5f22..7693c5c6518 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -139,7 +139,7 @@ class Switcher(QDialog): The selected mode (open files "", symbol "@" or line ":"). """ - sig_search_text_available = Signal(str, list) + sig_search_text_available = Signal(str) """ This signal is emitted when the user stops typing in the filter line edit. @@ -147,8 +147,6 @@ class Switcher(QDialog): ---------- search_text: str The current search text. - items_data: list - List of items shown in the switcher. """ _MAX_NUM_ITEMS = 15 @@ -160,31 +158,36 @@ class Switcher(QDialog): def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, item_separator_styles=ITEM_SEPARATOR_STYLES): """Multi purpose switcher.""" - super(Switcher, self).__init__(parent) + super().__init__(parent) + + # Attributes self._modes = {} self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles # Widgets - self.timer = QTimer() self.edit = QLineEdit(self) self.list = QListView(self) self.model = QStandardItemModel(self.list) self.proxy = SwitcherProxyModel(self.list) self.filter = KeyPressFilter() - # Widgets setup - self.timer.setInterval(300) - self.timer.setSingleShot(True) - self.timer.timeout.connect(self.setup) + # Search timer + self._search_timer = QTimer(self) + self._search_timer.setInterval(300) + self._search_timer.setSingleShot(True) + self._search_timer.timeout.connect(self._on_search_text_changed) + # Widgets setup self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setWindowOpacity(0.95) -# self.setMinimumHeight(self._MIN_HEIGHT) + # self.setMinimumHeight(self._MIN_HEIGHT) self.setMaximumHeight(self._MAX_HEIGHT) + self.edit.installEventFilter(self.filter) self.edit.setPlaceholderText(help_text if help_text else '') + self.list.setMinimumWidth(self._MIN_WIDTH) self.list.setItemDelegate(SwitcherDelegate(self)) self.list.setFocusPolicy(Qt.NoFocus) @@ -204,29 +207,24 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self.filter.sig_up_key_pressed.connect(self.previous_row) self.filter.sig_down_key_pressed.connect(self.next_row) self.filter.sig_enter_key_pressed.connect(self.enter) + self.edit.textChanged.connect(self.sig_text_changed) - self.edit.textChanged.connect(lambda: self.timer.start()) + self.edit.textChanged.connect(lambda: self._search_timer.start()) self.edit.returnPressed.connect(self.enter) + self.list.clicked.connect(self.enter) self.list.clicked.connect(self.edit.setFocus) self.list.selectionModel().currentChanged.connect( self.current_item_changed) + + # Gives focus to text edit self.edit.setFocus() # ---- Helper methods - def _add_item(self, item, last_item=True, score=None): + def _add_item(self, item, last_item=True): """Perform common actions when adding items.""" - if score is not None: - item.set_score(score) - item.set_width(self._ITEM_WIDTH) - if isinstance(item, SwitcherItem): - if item._section == "Editor": - self.model.insertRow(0, item) - else: - self.model.appendRow(item) - else: - self.model.appendRow(item) + self.model.appendRow(item) if last_item: # Only set the current row to the first item when the added item is @@ -234,7 +232,6 @@ def _add_item(self, item, last_item=True, score=None): # adding multiple items self.set_current_row(0) self.set_height() - self.setup_sections() # ---- API def clear(self): @@ -272,7 +269,7 @@ def clear_modes(self): def add_item(self, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, action_item=False, - last_item=True, score=None): + last_item=True, score=-1, use_score=True): """Add switcher list item.""" item = SwitcherItem( parent=self.list, @@ -284,19 +281,21 @@ def add_item(self, icon=None, title=None, description=None, shortcut=None, section=section, action_item=action_item, tool_tip=tool_tip, - styles=self._item_styles + styles=self._item_styles, + score=score, + use_score=use_score ) - self._add_item(item, last_item=last_item, score=score) + self._add_item(item, last_item=last_item) def add_separator(self): """Add separator item.""" - item = SwitcherSeparatorItem(parent=self.list, - styles=self._item_separator_styles) + item = SwitcherSeparatorItem( + parent=self.list, styles=self._item_separator_styles + ) self._add_item(item) def setup(self): - """Set-up list widget content based on the filtering.""" - # Check exited mode + """Setup list widget content based on filtering.""" mode = self._mode_on if mode: search_text = self.search_text()[len(mode):] @@ -309,6 +308,17 @@ def setup(self): self.clear() self.proxy.set_filter_by_score(False) self.sig_mode_selected.emit(self._mode_on) + + # This is necessary to show the Editor items first when results + # come back from the Editor and Projects. + self.proxy.sortBy('_score') + + # Show sections + self.setup_sections() + + # Give focus to the first row + self.set_current_row(0) + return # Check entered mode @@ -320,22 +330,15 @@ def setup(self): # Filter by text titles = [] - items_data = [] - for row in range(self.model.rowCount() - 1, -1, -1): - # As we are removing items from the model, we need to iterate - # backwards so that the indexes are not affected + for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): - if item._section == "Projects": - self.model.removeRow(row) - continue - else: - title = item.get_title() - if item._data is not None: - items_data.append(item._data._filename.lower()) + title = item.get_title() else: title = '' - titles.insert(0, title) + + titles.append(title) + search_text = clean_string(search_text) scores = get_search_scores(to_text_string(search_text), titles, template=u"{0}") @@ -345,50 +348,74 @@ def setup(self): if not self._is_separator(item) and not item.is_action_item(): rich_title = rich_title.replace(" ", " ") item.set_rich_title(rich_title) + item.set_score(score_value) - self.sig_search_text_available.emit(search_text, items_data) self.proxy.set_filter_by_score(True) + self.proxy.sortBy('_score') + # Graphical setup self.setup_sections() + if self.count(): self.set_current_row(0) else: self.set_current_row(-1) + self.set_height() def setup_sections(self): - """Set-up which sections appear on the item list.""" + """Setup which sections appear on the item list.""" mode = self._mode_on + sections = [] + if mode: search_text = self.search_text()[len(mode):] else: search_text = self.search_text() - if search_text: - for row in range(self.model.rowCount()): - item = self.model.item(row) - if isinstance(item, SwitcherItem): - item.set_section_visible(False) - else: - sections = [] - for row in range(self.model.rowCount()): - item = self.model.item(row) - if isinstance(item, SwitcherItem): - sections.append(item.get_section()) - item.set_section_visible(bool(search_text)) - else: - sections.append('') - - if row != 0: - visible = sections[row] != sections[row - 1] - if not self._is_separator(item): - item.set_section_visible(visible) - else: + for row in range(self.model.rowCount()): + item_row = row + + # When there is search_text, we need to use the proxy model to get + # the actual item's row. + if search_text: + model_index = self.proxy.mapToSource(self.proxy.index(row, 0)) + item_row = model_index.row() + + # Get item + item = self.model.item(item_row) + + # When searching gives no result, the mapped items are None + if item is None: + continue + + # Get item section + if isinstance(item, SwitcherItem): + sections.append(item.get_section()) + else: + sections.append('') + + # Decide if we need to make the item's section visible + if row != 0: + visible = sections[row] != sections[row - 1] + if not self._is_separator(item): + item.set_section_visible(visible) + else: + # We need to remove this when a mode has several sections + if not mode: item.set_section_visible(True) - self.proxy.sortBy('_score') - self.sig_item_changed.emit(self.current_item()) + def remove_section(self, section): + """Remove all items in a section of the switcher.""" + # As we are removing items from the model, we need to iterate backwards + # so that the indexes are not affected. + for row in range(self.model.rowCount() - 1, -1, -1): + item = self.model.item(row) + if isinstance(item, SwitcherItem): + if item._section == section: + self.model.removeRow(row) + continue def set_height(self): """Set height taking into account the number of items.""" @@ -440,8 +467,9 @@ def enter(self, itemClicked=None): item = self.model.item(model_index.row()) if item: mode = self._mode_on - self.sig_item_selected.emit(item, mode, - self.search_text()[len(mode):]) + self.sig_item_selected.emit( + item, mode, self.search_text()[len(mode):] + ) def accept(self): """Override Qt method.""" @@ -449,14 +477,15 @@ def accept(self): def reject(self): """Override Qt method.""" + # This prevents calling _on_search_text_changed, which unnecessarily + # tries to populate the switcher when we're closing it. + self.edit.blockSignals(True) self.set_search_text('') + self.edit.blockSignals(False) + self.sig_rejected.emit() super(Switcher, self).reject() - def resizeEvent(self, event): - """Override Qt method.""" - super(Switcher, self).resizeEvent(event) - # ---- Helper methods: Lineedit widget def search_text(self): """Get the normalized (lowecase) content of the search text.""" @@ -466,6 +495,14 @@ def set_search_text(self, string): """Set the content of the search text.""" self.edit.setText(string) + def _on_search_text_changed(self): + """Actions to take when the search text has changed.""" + if self.search_text() != "": + search_text = clean_string(self.search_text()) + self.sig_search_text_available.emit(search_text) + else: + self.setup() + # ---- Helper methods: List widget def _is_separator(self, item): """Check if item is an separator item (SwitcherSeparatorItem).""" diff --git a/spyder/utils/workers.py b/spyder/utils/workers.py index 5dbd1f9eebe..f7e961ee1ab 100644 --- a/spyder/utils/workers.py +++ b/spyder/utils/workers.py @@ -12,6 +12,7 @@ # Standard library imports from collections import deque +import logging import os import sys @@ -23,7 +24,7 @@ from spyder.py3compat import to_text_string -WIN = os.name == 'nt' +logger = logging.getLogger(__name__) def handle_qbytearray(obj, encoding): @@ -114,6 +115,10 @@ def __init__(self, parent, cmd_list, environ=None): self._process = QProcess(self) self._set_environment(environ) + # This is necessary to pass text input to the process as part of + # cmd_list + self._process.setInputChannelMode(QProcess.ForwardedInputChannel) + self._timer.setInterval(150) self._timer.timeout.connect(self._communicate) self._process.readyReadStandardOutput.connect(self._partial) @@ -123,7 +128,7 @@ def _get_encoding(self): enco = 'utf-8' # Currently only cp1252 is allowed? - if WIN: + if os.name == 'nt': import ctypes codepage = to_text_string(ctypes.cdll.kernel32.GetACP()) # import locale @@ -220,13 +225,18 @@ def start(self): self.sig_started.emit(self) self._started = True + def set_cwd(self, cwd): + """Set the process current working directory.""" + self._process.setWorkingDirectory(cwd) + class WorkerManager(QObject): - """Spyder Worker Manager for Generic Workers.""" + """Manager for generic workers.""" + + def __init__(self, parent=None, max_threads=10): + super().__init__(parent=parent) + self.parent = parent - def __init__(self, max_threads=10): - """Spyder Worker Manager for Generic Workers.""" - super().__init__() self._queue = deque() self._queue_workers = deque() self._threads = [] @@ -257,24 +267,27 @@ def _start(self, worker=None): self._queue_workers.append(worker) if self._queue_workers and self._running_threads < self._max_threads: - #print('Queue: {0} Running: {1} Workers: {2} ' - # 'Threads: {3}'.format(len(self._queue_workers), - # self._running_threads, - # len(self._workers), - # len(self._threads))) - self._running_threads += 1 + if self.parent is not None: + logger.debug( + f"Workers managed in {self.parent} -- " + f"In queue: {len(self._queue_workers)} -- " + f"Running threads: {self._running_threads} -- " + f"Workers: {len(self._workers)} -- " + f"Threads: {len(self._threads)}" + ) + worker = self._queue_workers.popleft() - thread = QThread(None) if isinstance(worker, PythonWorker): + self._running_threads += 1 + thread = QThread(None) + self._threads.append(thread) + worker.moveToThread(thread) worker.sig_finished.connect(thread.quit) thread.started.connect(worker._start) thread.start() elif isinstance(worker, ProcessWorker): - thread.quit() - thread.wait() worker._start() - self._threads.append(thread) else: self._timer.start()