diff --git a/binder/environment.yml b/binder/environment.yml index 9d3d9f8044b..61275faf3cc 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -50,7 +50,7 @@ dependencies: - setuptools >=49.6.0 - sphinx >=0.6.6 - spyder-kernels >=3.0.0b6,<3.0.0b7 -- superqt >=0.6.1,<1.0.0 +- superqt >=0.6.2,<1.0.0 - textdistance >=4.2.0 - three-merge >=0.1.1 - watchdog >=0.10.3 diff --git a/requirements/main.yml b/requirements/main.yml index c78265da4aa..c2fce5fee93 100644 --- a/requirements/main.yml +++ b/requirements/main.yml @@ -46,7 +46,7 @@ dependencies: - setuptools >=49.6.0 - sphinx >=0.6.6 - spyder-kernels >=3.0.0b6,<3.0.0b7 - - superqt >=0.6.1,<1.0.0 + - superqt >=0.6.2,<1.0.0 - textdistance >=4.2.0 - three-merge >=0.1.1 - watchdog >=0.10.3 diff --git a/setup.py b/setup.py index 822f81c0382..f3398b533e9 100644 --- a/setup.py +++ b/setup.py @@ -247,7 +247,7 @@ def run(self): 'setuptools>=49.6.0', 'sphinx>=0.6.6', 'spyder-kernels>=3.0.0b6,<3.0.0b7', - 'superqt>=0.6.1,<1.0.0', + 'superqt>=0.6.2,<1.0.0', 'textdistance>=4.2.0', 'three-merge>=0.1.1', 'watchdog>=0.10.3', diff --git a/spyder/api/widgets/menus.py b/spyder/api/widgets/menus.py index 61f0c1c6117..3ccc18edd56 100644 --- a/spyder/api/widgets/menus.py +++ b/spyder/api/widgets/menus.py @@ -295,12 +295,17 @@ def _add_missing_actions(self): self._unintroduced_actions = {} - def render(self): + def render(self, force=False): """ Create the menu prior to showing it. This takes into account sections and location of menus. + + Parameters + ---------- + force: bool, optional + Whether to force rendering the menu. """ - if self._dirty: + if self._dirty or force: self.clear() self._add_missing_actions() diff --git a/spyder/app/tests/conftest.py b/spyder/app/tests/conftest.py index a7944a71b3e..f67a426208f 100755 --- a/spyder/app/tests/conftest.py +++ b/spyder/app/tests/conftest.py @@ -250,7 +250,6 @@ def generate_run_parameters(mainwindow, filename, selected=None, file_run_params = StoredRunConfigurationExecutor( executor=executor, selected=selected, - display_dialog=False ) return {file_uuid: file_run_params} diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 45a11bb7860..fd3c793de84 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -438,9 +438,6 @@ def test_get_help_ipython_console_dot_notation(main_window, qtbot, tmpdir): main_window.editor.load(test_file) code_editor = main_window.editor.get_focus_widget() - run_parameters = generate_run_parameters(main_window, test_file) - CONF.set('run', 'last_used_parameters', run_parameters) - # Run test file qtbot.keyClick(code_editor, Qt.Key_F5) qtbot.wait(500) @@ -483,9 +480,6 @@ def test_get_help_ipython_console_special_characters( main_window.editor.load(test_file) code_editor = main_window.editor.get_focus_widget() - run_parameters = generate_run_parameters(main_window, test_file) - CONF.set('run', 'last_used_parameters', run_parameters) - # Run test file qtbot.keyClick(code_editor, Qt.Key_F5) qtbot.wait(500) @@ -726,7 +720,12 @@ def test_runconfig_workdir(main_window, qtbot, tmpdir): exec_uuid = str(uuid.uuid4()) ext_exec_conf = ExtendedRunExecutionParameters( - uuid=exec_uuid, name='TestConf', params=exec_conf) + uuid=exec_uuid, + name="TestConf", + params=exec_conf, + default=False, + file_uuid=None, + ) ipy_dict = {ipyconsole.NAME: { ('py', RunContext.File): {'params': {exec_uuid: ext_exec_conf}} @@ -809,7 +808,12 @@ def test_dedicated_consoles(main_window, qtbot): exec_uuid = str(uuid.uuid4()) ext_exec_conf = ExtendedRunExecutionParameters( - uuid=exec_uuid, name='TestConf', params=exec_conf) + uuid=exec_uuid, + name="TestConf", + params=exec_conf, + default=False, + file_uuid=None, + ) ipy_dict = {ipyconsole.NAME: { ('py', RunContext.File): {'params': {exec_uuid: ext_exec_conf}} @@ -918,7 +922,12 @@ def test_shell_execution(main_window, qtbot, tmpdir): exec_uuid = str(uuid.uuid4()) ext_exec_conf = ExtendedRunExecutionParameters( - uuid=exec_uuid, name='TestConf', params=exec_conf) + uuid=exec_uuid, + name="TestConf", + params=exec_conf, + default=False, + file_uuid=None, + ) ipy_dict = {external_terminal.NAME: { (ext, RunContext.File): {'params': {exec_uuid: ext_exec_conf}} @@ -946,8 +955,10 @@ def test_shell_execution(main_window, qtbot, tmpdir): @flaky(max_runs=3) -@pytest.mark.skipif(sys.platform.startswith('linux'), - reason="Fails frequently on Linux") +@pytest.mark.skipif( + sys.platform.startswith('linux') and running_in_ci(), + reason="Fails frequently on Linux and CI" +) @pytest.mark.order(after="test_debug_unsaved_function") def test_connection_to_external_kernel(main_window, qtbot): """Test that only Spyder kernels are connected to the Variable Explorer.""" @@ -993,10 +1004,6 @@ def test_connection_to_external_kernel(main_window, qtbot): "print(2 + 1)" ) - file_path = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, file_path) - CONF.set('run', 'last_used_parameters', run_parameters) - # Start running with qtbot.waitSignal(shell.executed): qtbot.mouseClick(main_window.run_button, Qt.LeftButton) @@ -1161,10 +1168,6 @@ def test_run_cython_code(main_window, qtbot): file_path = osp.join(LOCATION, 'pyx_script.pyx') main_window.editor.load(file_path) - # --- Set run options for this file --- - run_parameters = generate_run_parameters(main_window, file_path) - CONF.set('run', 'last_used_parameters', run_parameters) - # Run file qtbot.keyClick(code_editor, Qt.Key_F5) @@ -1188,10 +1191,6 @@ def test_run_cython_code(main_window, qtbot): file_path = osp.join(LOCATION, 'pyx_lib_import.py') main_window.editor.load(file_path) - # --- Set run options for this file -- - run_parameters = generate_run_parameters(main_window, file_path) - CONF.set('run', 'last_used_parameters', run_parameters) - # Run file qtbot.keyClick(code_editor, Qt.Key_F5) @@ -1436,9 +1435,6 @@ def test_run_code(main_window, qtbot, tmpdir): # Get a reference to the namespace browser widget nsb = main_window.variableexplorer.current_widget() - run_parameters = generate_run_parameters(main_window, filepath) - CONF.set('run', 'last_used_parameters', run_parameters) - # ---- Run file ---- with qtbot.waitSignal(shell.executed): qtbot.keyClick(code_editor, Qt.Key_F5) @@ -1847,9 +1843,6 @@ def test_maximize_minimize_plugins(main_window, qtbot): exclude=[Plugins.Editor, Plugins.IPythonConsole] ) qtbot.mouseClick(max_button, Qt.LeftButton) - - run_parameters = generate_run_parameters(main_window, test_file) - CONF.set('run', 'last_used_parameters', run_parameters) qtbot.mouseClick(main_window.run_button, Qt.LeftButton) assert not plugin_3.get_widget().get_maximized_state() @@ -3295,10 +3288,6 @@ def test_preferences_empty_shortcut_regression(main_window, qtbot): code_editor = main_window.editor.get_focus_widget() code_editor.set_text(u'print(0)\n#%%\nprint(ññ)') - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - with qtbot.waitSignal(shell.executed): qtbot.keyClick(code_editor, Qt.Key_Return, modifier=Qt.ShiftModifier) qtbot.waitUntil(lambda: u'print(0)' in shell._control.toPlainText()) @@ -3602,10 +3591,6 @@ def test_varexp_rename(main_window, qtbot, tmpdir): # Get a reference to the namespace browser widget nsb = main_window.variableexplorer.current_widget() - # --- Set run options for this file --- - run_parameters = generate_run_parameters(main_window, filepath) - CONF.set('run', 'last_used_parameters', run_parameters) - # ---- Run file ---- with qtbot.waitSignal(shell.executed): qtbot.mouseClick(main_window.run_button, Qt.LeftButton) @@ -3673,10 +3658,6 @@ def test_varexp_remove(main_window, qtbot, tmpdir): # Get a reference to the namespace browser widget nsb = main_window.variableexplorer.current_widget() - # --- Set run options for this file --- - run_parameters = generate_run_parameters(main_window, filepath) - CONF.set('run', 'last_used_parameters', run_parameters) - # ---- Run file ---- with qtbot.waitSignal(shell.executed, timeout=SHELL_TIMEOUT): qtbot.mouseClick(main_window.run_button, Qt.LeftButton) @@ -3757,10 +3738,6 @@ def test_runcell_edge_cases(main_window, qtbot, tmpdir): lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, timeout=SHELL_TIMEOUT) - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - # call runcell with qtbot.waitSignal(shell.executed): qtbot.mouseClick(main_window.run_cell_and_advance_button, @@ -3810,10 +3787,6 @@ def test_runcell_pdb(main_window, qtbot): code_editor = main_window.editor.get_focus_widget() code_editor.set_text(code) - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - # Start debugging with qtbot.waitSignal(shell.executed, timeout=SHELL_TIMEOUT): qtbot.mouseClick(debug_button, Qt.LeftButton) @@ -3858,10 +3831,6 @@ def test_runcell_cache(main_window, qtbot, debug): code_editor = main_window.editor.get_focus_widget() code_editor.set_text(code) - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - if debug: # Start debugging with qtbot.waitSignal(shell.executed): @@ -4112,10 +4081,6 @@ def test_runcell_after_restart(main_window, qtbot): code_editor = main_window.editor.get_focus_widget() code_editor.set_text(code) - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - # Restart Kernel widget = main_window.ipyconsole.get_widget() with qtbot.waitSignal(shell.sig_prompt_ready, timeout=10000): @@ -4435,10 +4400,6 @@ def test_run_unsaved_file_multiprocessing(main_window, qtbot): code_editor.set_text(text) # This code should run even on windows - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - # Start running qtbot.mouseClick(main_window.run_button, Qt.LeftButton) @@ -5515,10 +5476,6 @@ def test_func(): timeout=SHELL_TIMEOUT) control = main_window.ipyconsole.get_widget().get_focus_widget() - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - main_window.editor.get_widget().update_run_focus_file() qtbot.wait(2000) @@ -5554,10 +5511,6 @@ def crash_func(): timeout=SHELL_TIMEOUT) control = main_window.ipyconsole.get_widget().get_focus_widget() - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - main_window.editor.get_widget().update_run_focus_file() qtbot.wait(2000) @@ -5707,10 +5660,6 @@ def test_debug_unsaved_function(main_window, qtbot): code_editor = main_window.editor.get_focus_widget() code_editor.set_text('def foo():\n print(1)') - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - main_window.editor.get_widget().update_run_focus_file() qtbot.wait(2000) @@ -5757,10 +5706,6 @@ def test_out_runfile_runcell(main_window, qtbot): code_editor = main_window.editor.get_focus_widget() code_editor.set_text(code) - fname = main_window.editor.get_current_filename() - run_parameters = generate_run_parameters(main_window, fname) - CONF.set('run', 'last_used_parameters', run_parameters) - with qtbot.waitSignal(shell.executed): qtbot.mouseClick(main_window.run_cell_button, Qt.LeftButton) @@ -5809,10 +5754,6 @@ def test_print_frames(main_window, qtbot, tmpdir, thread): debugger = main_window.debugger.get_widget() frames_browser = debugger.current_widget().results_browser - # --- Set run options for this file --- - run_parameters = generate_run_parameters(main_window, str(p)) - CONF.set('run', 'last_used_parameters', run_parameters) - # Click the run button qtbot.mouseClick(main_window.run_button, Qt.LeftButton) qtbot.wait(1000) @@ -6496,6 +6437,7 @@ def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path, @flaky(max_runs=10) @pytest.mark.skipif(sys.platform == 'darwin', reason="Fails on Mac") +@pytest.mark.order(before='test_shell_execution') def test_clickable_ipython_tracebacks(main_window, qtbot, tmp_path): """ Test that file names in IPython console tracebacks are clickable. @@ -6526,8 +6468,6 @@ def test_clickable_ipython_tracebacks(main_window, qtbot, tmp_path): qtbot.keyClicks(code_editor, '1/0') # Run test file - run_parameters = generate_run_parameters(main_window, test_file) - CONF.set('run', 'last_used_parameters', run_parameters) qtbot.mouseClick(main_window.run_button, Qt.LeftButton) qtbot.wait(500) diff --git a/spyder/app/utils.py b/spyder/app/utils.py index b13d651dec7..05137b3aa24 100644 --- a/spyder/app/utils.py +++ b/spyder/app/utils.py @@ -187,6 +187,15 @@ def qt_message_handler(msg_type, msg_log_context, msg_string): # This is shown when expanding/collpasing folders in the Files plugin # after spyder-ide/spyder# "QFont::setPixelSize: Pixel size <= 0 (0)", + # These warnings are shown uncollapsing CollapsibleWidget + "QPainter::begin: Paint device returned engine == 0, type: 2", + "QPainter::save: Painter not active", + "QPainter::setPen: Painter not active", + "QPainter::setWorldTransform: Painter not active", + "QPainter::setOpacity: Painter not active", + "QFont::setPixelSize: Pixel size <= 0 (-3)", + "QPainter::setFont: Painter not active", + "QPainter::restore: Unbalanced save/restore", ] if msg_string not in BLACKLIST: print(msg_string) # spyder: test-skip @@ -358,6 +367,14 @@ def create_window(WindowClass, app, splash, options, args): main.show() main.post_visible_setup() + # Add a reference to the main window so it can be accessed from the + # application. + # + # Notes + # ----- + # * **DO NOT** use it to access other plugins functionality through it. + app._main_window = main + if main.console: main.console.start_interpreter(namespace={}) main.console.set_namespace_item('spy', Spy(app=app, window=main)) diff --git a/spyder/config/main.py b/spyder/config/main.py index 0ec7b9af883..0000d45e208 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -643,7 +643,6 @@ ('run', [ 'breakpoints', 'configurations', - 'defaultconfiguration', 'default/wdir/fixed_directory', 'last_used_parameters', 'parameters' diff --git a/spyder/dependencies.py b/spyder/dependencies.py index 4bedc564426..684d7b46f33 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -74,7 +74,7 @@ SETUPTOOLS_REQVER = '>=49.6.0' SPHINX_REQVER = '>=0.6.6' SPYDER_KERNELS_REQVER = '>=3.0.0b6,<3.0.0b7' -SUPERQT_REQVER = '>=0.6.1,<1.0.0' +SUPERQT_REQVER = '>=0.6.2,<1.0.0' TEXTDISTANCE_REQVER = '>=4.2.0' THREE_MERGE_REQVER = '>=0.1.1' WATCHDOG_REQVER = '>=0.10.3' diff --git a/spyder/plugins/debugger/plugin.py b/spyder/plugins/debugger/plugin.py index f1fd0a88d90..00f610c9d3f 100644 --- a/spyder/plugins/debugger/plugin.py +++ b/spyder/plugins/debugger/plugin.py @@ -33,7 +33,7 @@ RunConfiguration, ExtendedRunExecutionParameters, RunExecutor, run_execute, RunContext, RunResult) from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.plugins.ipythonconsole.widgets.config import IPythonConfigOptions +from spyder.plugins.ipythonconsole.widgets.run_conf import IPythonConfigOptions from spyder.plugins.editor.api.run import CellRun, SelectionRun @@ -80,15 +80,9 @@ def on_initialize(self): 'origin': self.NAME, 'extension': 'py', 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Cell' - }, - { - 'name': 'Selection' - }, + {'name': 'File'}, + {'name': 'Cell'}, + {'name': 'Selection'}, ] } @@ -96,24 +90,16 @@ def on_initialize(self): 'origin': self.NAME, 'extension': 'ipy', 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Cell' - }, - { - 'name': 'Selection' - }, + {'name': 'File'}, + {'name': 'Cell'}, + {'name': 'Selection'}, ] } self.executor_configuration = [ { 'input_extension': 'py', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': IPythonConfigOptions, 'requires_cwd': True, @@ -121,9 +107,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': IPythonConfigOptions, 'requires_cwd': True, @@ -131,9 +115,7 @@ def on_initialize(self): }, { 'input_extension': 'py', - 'context': { - 'name': 'Cell' - }, + 'context': {'name': 'Cell'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -141,9 +123,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'Cell' - }, + 'context': {'name': 'Cell'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -151,9 +131,7 @@ def on_initialize(self): }, { 'input_extension': 'py', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -161,9 +139,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, diff --git a/spyder/plugins/editor/confpage.py b/spyder/plugins/editor/confpage.py index 387d932dece..01e6ee15841 100644 --- a/spyder/plugins/editor/confpage.py +++ b/spyder/plugins/editor/confpage.py @@ -245,8 +245,11 @@ def enable_tabwidth_spin(index): # --- Advanced tab --- # -- Templates templates_group = QGroupBox(_('Templates')) - template_btn = self.create_button(_("Edit template for new files"), - self.plugin.edit_template) + template_btn = self.create_button( + text=_("Edit template for new files"), + callback=self.plugin.edit_template, + set_modified_on_click=True + ) templates_layout = QVBoxLayout() templates_layout.addSpacing(3) diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index 8a3200156eb..28f8ca2bfa6 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -201,26 +201,6 @@ def on_initialize(self): lambda: self.switch_to_plugin(force_focus=True) ) - # ---- Run plugin config definitions - widget.supported_run_extensions = [ - { - 'input_extension': 'py', - 'contexts': [ - {'context': {'name': 'File'}, 'is_super': True}, - {'context': {'name': 'Selection'}, 'is_super': False}, - {'context': {'name': 'Cell'}, 'is_super': False} - ] - }, - { - 'input_extension': 'ipy', - 'contexts': [ - {'context': {'name': 'File'}, 'is_super': True}, - {'context': {'name': 'Selection'}, 'is_super': False}, - {'context': {'name': 'Cell'}, 'is_super': False} - ] - }, - ] - @on_plugin_available(plugin=Plugins.Preferences) def on_preferences_available(self): preferences = self.get_plugin(Plugins.Preferences) @@ -273,9 +253,11 @@ def on_run_available(self): self.NAME, unsupported_extensions ) ) - run.register_run_configuration_provider( - self.NAME, widget.supported_run_extensions - ) + + # This is necessary to register run configs that were added before Run + # is available + for extension in widget.supported_run_extensions: + run.register_run_configuration_provider(self.NAME, [extension]) # Buttons creation run.create_run_button( diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index dd6d53de2e6..96277e2c3e3 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -353,6 +353,9 @@ def __init__(self, name, plugin, parent, ignore_last_opened_files=False): self._print_editor = self._create_print_editor() self._print_editor.hide() + # To save run extensions + self.supported_run_extensions = [] + # ---- PluginMainWidget API # ------------------------------------------------------------------------ def get_title(self): @@ -2984,6 +2987,8 @@ def add_supported_run_configuration(self, config: EditorRunConfiguration): input_extension=extension, contexts=ext_contexts) self.supported_run_extensions.append(supported_extension) + # This is necessary for plugins that register run configs after Run + # is available self.sig_register_run_configuration_provider_requested.emit( [supported_extension] ) diff --git a/spyder/plugins/externalterminal/plugin.py b/spyder/plugins/externalterminal/plugin.py index f843ec17874..8d37df13ef7 100644 --- a/spyder/plugins/externalterminal/plugin.py +++ b/spyder/plugins/externalterminal/plugin.py @@ -62,20 +62,14 @@ def on_initialize(self): { 'origin': self.NAME, 'extension': 'py', - 'contexts': [ - { - 'name': 'File' - } - ] + 'contexts': [{'name': 'File'}] } ] self.executor_configuration = [ { 'input_extension': 'py', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': ExternalTerminalPyConfiguration, 'requires_cwd': True, @@ -87,36 +81,27 @@ def on_initialize(self): self.editor_configurations.append({ 'origin': self.NAME, 'extension': 'bat', - 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Selection' - } - ] + 'contexts': [{'name': 'File'}, {'name': 'Selection'}] }) self.executor_configuration.append({ 'input_extension': 'bat', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': ExternalTerminalShConfiguration( - 'cmd.exe', '/K'), + 'cmd.exe', '/K' + ), 'requires_cwd': True, 'priority': 1 }) self.executor_configuration.append({ 'input_extension': 'bat', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': ExternalTerminalShConfiguration( - 'cmd.exe', '/K'), + 'cmd.exe', '/K' + ), 'requires_cwd': True, 'priority': 1 }) @@ -124,36 +109,27 @@ def on_initialize(self): self.editor_configurations.append({ 'origin': self.NAME, 'extension': 'ps1', - 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Selection' - } - ] + 'contexts': [{'name': 'File'}, {'name': 'Selection'}] }) self.executor_configuration.append({ 'input_extension': 'ps1', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': ExternalTerminalShConfiguration( - 'powershell.exe'), + 'powershell.exe' + ), 'requires_cwd': True, 'priority': 1 }) self.executor_configuration.append({ 'input_extension': 'ps1', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': ExternalTerminalShConfiguration( - 'powershell.exe'), + 'powershell.exe' + ), 'requires_cwd': True, 'priority': 1 }) @@ -169,36 +145,27 @@ def on_initialize(self): self.editor_configurations.append({ 'origin': self.NAME, 'extension': 'sh', - 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Selection' - } - ] + 'contexts': [{'name': 'File'}, {'name': 'Selection'}] }) self.executor_configuration.append({ 'input_extension': 'sh', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': ExternalTerminalShConfiguration( - programs.is_program_installed(default_shell)), + programs.is_program_installed(default_shell) + ), 'requires_cwd': True, 'priority': 1 }) self.executor_configuration.append({ 'input_extension': 'sh', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': ExternalTerminalShConfiguration( - programs.is_program_installed(default_shell)), + programs.is_program_installed(default_shell) + ), 'requires_cwd': True, 'priority': 1 }) diff --git a/spyder/plugins/externalterminal/widgets/run_conf.py b/spyder/plugins/externalterminal/widgets/run_conf.py index 3c4fdfc44f9..8e3130558f2 100644 --- a/spyder/plugins/externalterminal/widgets/run_conf.py +++ b/spyder/plugins/externalterminal/widgets/run_conf.py @@ -10,10 +10,19 @@ import os.path as osp # Third-party imports -from qtpy.compat import getexistingdirectory, getopenfilename +from qtpy.compat import getopenfilename +from qtpy.QtCore import QSize from qtpy.QtWidgets import ( - QWidget, QGroupBox, QVBoxLayout, QGridLayout, QCheckBox, QLineEdit, - QHBoxLayout, QLabel) + QCheckBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) # Local imports from spyder.api.translations import _ @@ -23,11 +32,7 @@ RunExecutorConfigurationGroupFactory) from spyder.utils.icon_manager import ima from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import create_toolbutton - - -# Main constants -INTERACT = _("Interact with the Python terminal after execution") +from spyder.utils.stylesheet import AppStyle class ExternalTerminalPyConfiguration(RunExecutorConfigurationGroup): @@ -40,61 +45,55 @@ def __init__(self, parent, context: Context, input_extension: str, self.dir = None # --- Interpreter --- - interpreter_group = QGroupBox(_("Terminal")) + interpreter_group = QGroupBox(_("Python interpreter")) interpreter_layout = QVBoxLayout(interpreter_group) # --- System terminal --- - external_group = QWidget() - + external_group = QWidget(self) external_layout = QGridLayout() external_group.setLayout(external_layout) - self.interact_cb = QCheckBox(INTERACT) + + self.interact_cb = QCheckBox( + _("Interact with the interpreter after execution") + ) external_layout.addWidget(self.interact_cb, 1, 0, 1, -1) - self.pclo_cb = QCheckBox(_("Command line options:")) + self.pclo_cb = QCheckBox(_("Interpreter options:")) external_layout.addWidget(self.pclo_cb, 3, 0) - self.pclo_edit = QLineEdit() + self.pclo_edit = QLineEdit(self) self.pclo_cb.toggled.connect(self.pclo_edit.setEnabled) self.pclo_edit.setEnabled(False) - self.pclo_edit.setToolTip(_("-u<_b> is added to the " - "other options you set here")) + self.pclo_edit.setToolTip( + _("-u is added to the other options you set here") + ) external_layout.addWidget(self.pclo_edit, 3, 1) interpreter_layout.addWidget(external_group) # --- General settings ---- - common_group = QGroupBox(_("Script settings")) - + common_group = QGroupBox(_("Bash/Batch script settings")) common_layout = QGridLayout(common_group) self.clo_cb = QCheckBox(_("Command line options:")) common_layout.addWidget(self.clo_cb, 0, 0) - self.clo_edit = QLineEdit() + self.clo_edit = QLineEdit(self) + self.clo_edit.setMinimumWidth(300) self.clo_cb.toggled.connect(self.clo_edit.setEnabled) self.clo_edit.setEnabled(False) common_layout.addWidget(self.clo_edit, 0, 1) layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(interpreter_group) layout.addWidget(common_group) layout.addStretch(100) - def select_directory(self): - """Select directory""" - basedir = str(self.wd_edit.text()) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - directory = getexistingdirectory(self, _("Select directory"), basedir) - if directory: - self.wd_edit.setText(directory) - self.dir = directory - @staticmethod def get_default_configuration() -> dict: return { 'args_enabled': False, 'args': '', - 'interact': False, + 'interact': True, 'python_args_enabled': False, 'python_args': '', } @@ -135,53 +134,65 @@ def __init__( # --- Interpreter --- interpreter_group = QGroupBox(_("Interpreter")) - interpreter_layout = QGridLayout(interpreter_group) + interpreter_layout = QVBoxLayout(interpreter_group) interpreter_label = QLabel(_("Shell interpreter:")) - interpreter_layout.addWidget(interpreter_label, 0, 0) - edit_layout = QHBoxLayout() - self.interpreter_edit = QLineEdit() - browse_btn = create_toolbutton( - self, - triggered=self.select_directory, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") + self.interpreter_edit = QLineEdit(self) + browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) + browse_btn.setToolTip(_("Select interpreter")) + browse_btn.clicked.connect(self.select_interpreter) + browse_btn.setIconSize( + QSize(AppStyle.ConfigPageIconSize, AppStyle.ConfigPageIconSize) ) - edit_layout.addWidget(self.interpreter_edit) - edit_layout.addWidget(browse_btn) - interpreter_layout.addLayout(edit_layout, 0, 1) + + shell_layout = QHBoxLayout() + shell_layout.addWidget(interpreter_label) + shell_layout.addWidget(self.interpreter_edit) + shell_layout.addWidget(browse_btn) + interpreter_layout.addLayout(shell_layout) self.interpreter_opts_cb = QCheckBox(_("Interpreter arguments:")) - interpreter_layout.addWidget(self.interpreter_opts_cb, 1, 0) - self.interpreter_opts_edit = QLineEdit() + self.interpreter_opts_edit = QLineEdit(self) + self.interpreter_opts_edit.setMinimumWidth(250) self.interpreter_opts_cb.toggled.connect( - self.interpreter_opts_edit.setEnabled) + self.interpreter_opts_edit.setEnabled + ) self.interpreter_opts_edit.setEnabled(False) - interpreter_layout.addWidget(self.interpreter_opts_edit, 1, 1) + + interpreter_opts_layout = QHBoxLayout() + interpreter_opts_layout.addWidget(self.interpreter_opts_cb) + interpreter_opts_layout.addWidget(self.interpreter_opts_edit) + interpreter_layout.addLayout(interpreter_opts_layout) # --- Script --- script_group = QGroupBox(_('Script')) - script_layout = QGridLayout(script_group) + script_layout = QVBoxLayout(script_group) self.script_opts_cb = QCheckBox(_("Script arguments:")) - script_layout.addWidget(self.script_opts_cb, 1, 0) - self.script_opts_edit = QLineEdit() + self.script_opts_edit = QLineEdit(self) self.script_opts_cb.toggled.connect( - self.script_opts_edit.setEnabled) + self.script_opts_edit.setEnabled + ) self.script_opts_edit.setEnabled(False) - script_layout.addWidget(self.script_opts_edit, 1, 1) + + script_args_layout = QHBoxLayout() + script_args_layout.addWidget(self.script_opts_cb) + script_args_layout.addWidget(self.script_opts_edit) + script_layout.addLayout(script_args_layout) self.close_after_exec_cb = QCheckBox( - _('Close terminal after execution')) - script_layout.addWidget(self.close_after_exec_cb, 2, 0) + _("Close terminal after execution") + ) + script_layout.addWidget(self.close_after_exec_cb) layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(interpreter_group) layout.addWidget(script_group) layout.addStretch(100) - def select_directory(self): - """Select directory""" + def select_interpreter(self): + """Select an interpreter.""" basedir = str(self.interpreter_edit.text()) if not osp.isdir(basedir): basedir = getcwd_or_home() diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index ba46e4b4a14..55411653c8d 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -26,7 +26,7 @@ IPythonConsoleWidgetMenus ) from spyder.plugins.ipythonconsole.confpage import IPythonConsoleConfigPage -from spyder.plugins.ipythonconsole.widgets.config import IPythonConfigOptions +from spyder.plugins.ipythonconsole.widgets.run_conf import IPythonConfigOptions from spyder.plugins.ipythonconsole.widgets.main_widget import ( IPythonConsoleWidget ) @@ -249,9 +249,7 @@ def on_initialize(self): 'origin': self.NAME, 'extension': 'pyx', 'contexts': [ - { - 'name': 'File' - } + {'name': 'File'} ] } @@ -259,15 +257,9 @@ def on_initialize(self): 'origin': self.NAME, 'extension': 'py', 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Cell' - }, - { - 'name': 'Selection' - }, + {'name': 'File'}, + {'name': 'Cell'}, + {'name': 'Selection'}, ] } @@ -275,24 +267,16 @@ def on_initialize(self): 'origin': self.NAME, 'extension': 'ipy', 'contexts': [ - { - 'name': 'File' - }, - { - 'name': 'Cell' - }, - { - 'name': 'Selection' - }, + {'name': 'File'}, + {'name': 'Cell'}, + {'name': 'Selection'}, ] } self.executor_configuration = [ { 'input_extension': 'py', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': IPythonConfigOptions, 'requires_cwd': True, @@ -300,9 +284,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': IPythonConfigOptions, 'requires_cwd': True, @@ -310,9 +292,7 @@ def on_initialize(self): }, { 'input_extension': 'py', - 'context': { - 'name': 'Cell' - }, + 'context': {'name': 'Cell'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -320,9 +300,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'Cell' - }, + 'context': {'name': 'Cell'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -330,9 +308,7 @@ def on_initialize(self): }, { 'input_extension': 'py', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -340,9 +316,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'Selection' - }, + 'context': {'name': 'Selection'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': True, @@ -350,9 +324,7 @@ def on_initialize(self): }, { 'input_extension': 'pyx', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': IPythonConfigOptions, 'requires_cwd': True, diff --git a/spyder/plugins/ipythonconsole/widgets/config.py b/spyder/plugins/ipythonconsole/widgets/run_conf.py similarity index 80% rename from spyder/plugins/ipythonconsole/widgets/config.py rename to spyder/plugins/ipythonconsole/widgets/run_conf.py index bcd2bfdfa60..b2d4457af5a 100644 --- a/spyder/plugins/ipythonconsole/widgets/config.py +++ b/spyder/plugins/ipythonconsole/widgets/run_conf.py @@ -12,7 +12,7 @@ # Third-party imports from qtpy.compat import getexistingdirectory from qtpy.QtWidgets import ( - QRadioButton, QGroupBox, QVBoxLayout, QGridLayout, QCheckBox, QLineEdit) + QCheckBox, QGroupBox, QHBoxLayout, QLineEdit, QRadioButton, QVBoxLayout) # Local imports from spyder.api.translations import _ @@ -22,19 +22,11 @@ # Main constants -RUN_DEFAULT_CONFIG = _("Run file with default configuration") -RUN_CUSTOM_CONFIG = _("Run file with custom configuration") CURRENT_INTERPRETER = _("Execute in current console") DEDICATED_INTERPRETER = _("Execute in a dedicated console") -SYSTERM_INTERPRETER = _("Execute in an external system terminal") CLEAR_ALL_VARIABLES = _("Remove all variables before execution") CONSOLE_NAMESPACE = _("Run in console's namespace instead of an empty one") POST_MORTEM = _("Directly enter debugging when errors appear") -INTERACT = _("Interact with the Python console after execution") -FILE_DIR = _("The directory of the file being executed") -CW_DIR = _("The current working directory") -FIXED_DIR = _("The following directory:") -ALWAYS_OPEN_FIRST_RUN = _("Always show %s on a first file run") class IPythonConfigOptions(RunExecutorConfigurationGroup): @@ -48,7 +40,6 @@ def __init__(self, parent, context: Context, input_extension: str, # --- Interpreter --- interpreter_group = QGroupBox(_("Console")) - interpreter_layout = QVBoxLayout(interpreter_group) self.current_radio = QRadioButton(CURRENT_INTERPRETER) @@ -58,27 +49,31 @@ def __init__(self, parent, context: Context, input_extension: str, interpreter_layout.addWidget(self.dedicated_radio) # --- General settings ---- - common_group = QGroupBox(_("General settings")) - - common_layout = QGridLayout(common_group) + common_group = QGroupBox(_("Advanced settings")) + common_layout = QVBoxLayout(common_group) self.clear_var_cb = QCheckBox(CLEAR_ALL_VARIABLES) - common_layout.addWidget(self.clear_var_cb, 0, 0) + common_layout.addWidget(self.clear_var_cb) self.console_ns_cb = QCheckBox(CONSOLE_NAMESPACE) - common_layout.addWidget(self.console_ns_cb, 1, 0) + common_layout.addWidget(self.console_ns_cb) self.post_mortem_cb = QCheckBox(POST_MORTEM) - common_layout.addWidget(self.post_mortem_cb, 2, 0) + common_layout.addWidget(self.post_mortem_cb) self.clo_cb = QCheckBox(_("Command line options:")) - common_layout.addWidget(self.clo_cb, 3, 0) - self.clo_edit = QLineEdit() + self.clo_edit = QLineEdit(self) + self.clo_edit.setMinimumWidth(300) self.clo_cb.toggled.connect(self.clo_edit.setEnabled) self.clo_edit.setEnabled(False) - common_layout.addWidget(self.clo_edit, 3, 1) + + cli_layout = QHBoxLayout() + cli_layout.addWidget(self.clo_cb) + cli_layout.addWidget(self.clo_edit) + common_layout.addLayout(cli_layout) layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(interpreter_group) layout.addWidget(common_group) layout.addStretch(100) diff --git a/spyder/plugins/mainmenu/plugin.py b/spyder/plugins/mainmenu/plugin.py index 2fd5603657a..84148d8f84b 100644 --- a/spyder/plugins/mainmenu/plugin.py +++ b/spyder/plugins/mainmenu/plugin.py @@ -154,6 +154,13 @@ def create_application_menu( if sys.platform == 'darwin': menu.aboutToShow.connect(self._hide_options_menus) + # This is necessary because for some strange reason the + # "Configuration per file" entry disappears after showing other + # dialogs and the only way to make it visible again is by + # re-rendering the menu. + if menu_id == ApplicationMenus.Run: + menu.aboutToShow.connect(lambda: menu.render(force=True)) + if menu_id in self._ITEM_QUEUE: pending_items = self._ITEM_QUEUE.pop(menu_id) for pending in pending_items: diff --git a/spyder/plugins/profiler/plugin.py b/spyder/plugins/profiler/plugin.py index 78e44756341..e315e09f5f7 100644 --- a/spyder/plugins/profiler/plugin.py +++ b/spyder/plugins/profiler/plugin.py @@ -76,9 +76,7 @@ def on_initialize(self): self.executor_configuration = [ { 'input_extension': 'py', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': ProfilerPyConfigurationGroup, 'requires_cwd': True, @@ -86,9 +84,7 @@ def on_initialize(self): }, { 'input_extension': 'ipy', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': ProfilerPyConfigurationGroup, 'requires_cwd': True, diff --git a/spyder/plugins/profiler/widgets/run_conf.py b/spyder/plugins/profiler/widgets/run_conf.py index ffd95e3f061..20ea9c8b3f3 100644 --- a/spyder/plugins/profiler/widgets/run_conf.py +++ b/spyder/plugins/profiler/widgets/run_conf.py @@ -31,18 +31,19 @@ def __init__(self, parent, context: Context, input_extension: str, self.dir = None # --- General settings ---- - common_group = QGroupBox(_("Script settings")) - + common_group = QGroupBox(_("File settings")) common_layout = QGridLayout(common_group) self.clo_cb = QCheckBox(_("Command line options:")) common_layout.addWidget(self.clo_cb, 0, 0) - self.clo_edit = QLineEdit() + self.clo_edit = QLineEdit(self) + self.clo_edit.setMinimumWidth(300) self.clo_cb.toggled.connect(self.clo_edit.setEnabled) self.clo_edit.setEnabled(False) common_layout.addWidget(self.clo_edit, 0, 1) layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(common_group) layout.addStretch(100) diff --git a/spyder/plugins/pylint/plugin.py b/spyder/plugins/pylint/plugin.py index 90ed0841be1..aa0c65f078d 100644 --- a/spyder/plugins/pylint/plugin.py +++ b/spyder/plugins/pylint/plugin.py @@ -90,9 +90,7 @@ def on_initialize(self): self.executor_configuration = [ { 'input_extension': 'py', - 'context': { - 'name': 'File' - }, + 'context': {'name': 'File'}, 'output_formats': [], 'configuration_widget': None, 'requires_cwd': False, diff --git a/spyder/plugins/run/api.py b/spyder/plugins/run/api.py index 1881299b2d4..2e186939f93 100644 --- a/spyder/plugins/run/api.py +++ b/spyder/plugins/run/api.py @@ -10,7 +10,6 @@ from __future__ import annotations import functools -from enum import IntEnum from datetime import datetime import logging from typing import Any, Callable, Set, List, Union, Optional, Dict, TypedDict @@ -23,15 +22,11 @@ logger = logging.getLogger(__name__) -class RunParameterFlags(IntEnum): - SetDefaults = 0 - SwitchValues = 1 - - class RunActions: Run = 'run' Configure = 'configure' ReRun = 're-run last script' + GlobalConfigurations = "global configurations" class RunContextType(dict): @@ -248,6 +243,13 @@ class ExtendedRunExecutionParameters(TypedDict): # The run execution parameters. params: RunExecutionParameters + # The unique identifier for the file to which these parameters correspond + # to, if any. + file_uuid: Optional[str] + + # To identify these parameters as default + default: bool + class StoredRunExecutorParameters(TypedDict): """Per run executor configuration parameters.""" @@ -267,10 +269,6 @@ class StoredRunConfigurationExecutor(TypedDict): # if using default or transient settings. selected: Optional[str] - # If True, then the run dialog will displayed every time the run - # configuration is executed. Otherwise not. - display_dialog: bool - class RunConfigurationProvider(QObject): """ diff --git a/spyder/plugins/run/confpage.py b/spyder/plugins/run/confpage.py index 6b4a44f5201..64cce2b65c1 100644 --- a/spyder/plugins/run/confpage.py +++ b/spyder/plugins/run/confpage.py @@ -14,9 +14,14 @@ # Third party imports from qtpy.QtCore import Qt -from qtpy.QtWidgets import (QGroupBox, QLabel, QVBoxLayout, - QTableView, QAbstractItemView, QPushButton, - QGridLayout, QHeaderView, QWidget) +from qtpy.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QVBoxLayout, + QWidget, +) # Local imports from spyder.api.preferences import PluginConfigPage @@ -29,7 +34,9 @@ RunExecutorNamesListModel, ExecutorRunParametersTableModel) from spyder.plugins.run.widgets import ( ExecutionParametersDialog, RunDialogStatus) - +from spyder.utils.icon_manager import ima +from spyder.utils.stylesheet import AppStyle +from spyder.widgets.helperwidgets import HoverRowsTableView def move_file_to_front(contexts: List[str]) -> List[str]: @@ -38,7 +45,8 @@ def move_file_to_front(contexts: List[str]) -> List[str]: return contexts -class RunParametersTableView(QTableView): +class RunParametersTableView(HoverRowsTableView): + def __init__(self, parent, model): super().__init__(parent) self._parent = parent @@ -53,19 +61,27 @@ def __init__(self, parent, model): self.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.Stretch) + 1, QHeaderView.Stretch + ) self.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) - def focusInEvent(self, e): - """Qt Override.""" - super().focusInEvent(e) - self.selectRow(self.currentIndex().row()) - self.selection(self.currentIndex().row()) - def selection(self, index): self.update() self.isActiveWindow() - self._parent.set_clone_delete_btn_status() + + # Detect if a row corresponds to a set of default parameters to prevent + # users from deleting it. + index = self.currentIndex().row() + is_default = False + if index >= 0: + params_id = self._parent.table_model.params_index[index] + params = self._parent.table_model.executor_conf_params[params_id] + is_default = True if params.get("default") else False + + self._parent.set_buttons_status(is_default=is_default) + + # Always enable edit button + self._parent.edit_configuration_btn.setEnabled(True) def adjust_cells(self): """Adjust column size based on contents.""" @@ -85,8 +101,9 @@ def reset_plain(self): def show_editor(self, new=False, clone=False): extension, context, params = None, None, None - extensions, contexts, executor_params = ( - self._parent.get_executor_configurations()) + extensions, contexts, plugin_name, executor_params = ( + self._parent.get_executor_configurations() + ) if not new: index = self.currentIndex().row() @@ -94,13 +111,25 @@ def show_editor(self, new=False, clone=False): (extension, context, params) = model[index] self.dialog = ExecutionParametersDialog( - self, executor_params, extensions, contexts, params, - extension, context) + self, + plugin_name, + executor_params, + self.model().get_parameter_names(), + extensions, + contexts, + params, + extension, + context, + new + ) self.dialog.setup() self.dialog.finished.connect( - functools.partial(self.process_run_dialog_result, - new=new, clone=clone, params=params)) + functools.partial( + self.process_run_dialog_result, + new=new, clone=clone, params=params + ) + ) if not clone: self.dialog.open() @@ -113,8 +142,11 @@ def process_run_dialog_result(self, result, new=False, if status == RunDialogStatus.Close: return - (extension, context, - new_executor_params) = self.dialog.get_configuration() + conf = self.dialog.get_configuration() + if conf is None: + return + else: + extension, context, new_executor_params = conf if not new and clone: new_executor_params["uuid"] = str(uuid4()) @@ -130,6 +162,12 @@ def process_run_dialog_result(self, result, new=False, def clone_configuration(self): self.show_editor(clone=True) + def focusInEvent(self, e): + """Qt Override.""" + super().focusInEvent(e) + self.selectRow(self.currentIndex().row()) + self.selection(self.currentIndex().row()) + def keyPressEvent(self, event): """Qt Override.""" key = event.key() @@ -149,12 +187,16 @@ class RunConfigPage(PluginConfigPage): """Default Run Settings configuration page.""" def setup_page(self): + self._params_to_delete = {} + + # --- Executors tab --- self.plugin_container: RunContainer = self.plugin.get_container() self.executor_model = RunExecutorNamesListModel( self, self.plugin_container.executor_model) self.table_model = ExecutorRunParametersTableModel(self) self.table_model.sig_data_changed.connect( - lambda: self.set_modified(True)) + self.on_table_data_changed + ) self.all_executor_model: Dict[ str, Dict[Tuple[str, str, str], @@ -165,56 +207,96 @@ def setup_page(self): ExtendedRunExecutionParameters]] = {} about_label = QLabel( - _("The following are the per-executor configuration settings used " - "for running. These options may be overriden using the " - "Configuration per file entry of the Run menu.") + _( + "The following are the global configuration settings of the " + "different plugins that can execute files in Spyder." + ) ) about_label.setWordWrap(True) + # The paremeters table needs to be created before the executor_combo + # below, although is displayed after it. + params_label = QLabel(_('Available parameters:')) + self.params_table = RunParametersTableView(self, self.table_model) + self.params_table.setMaximumHeight(180) + + params_table_layout = QHBoxLayout() + params_table_layout.addSpacing(2 * AppStyle.MarginSize) + params_table_layout.addWidget(self.params_table) + params_table_layout.addSpacing(2 * AppStyle.MarginSize) + + executor_label = QLabel(_("Executor:")) self.executor_combo = SpyderComboBox(self) + self.executor_combo.setMinimumWidth(250) self.executor_combo.currentIndexChanged.connect( - self.executor_index_changed) + self.executor_index_changed + ) self.executor_combo.setModel(self.executor_model) - self.params_table = RunParametersTableView(self, self.table_model) - self.params_table.setMaximumHeight(180) + executor_layout = QHBoxLayout() + executor_layout.addWidget(executor_label) + executor_layout.addWidget(self.executor_combo) + executor_layout.addStretch() - params_group = QGroupBox(_('Available execution parameters')) - params_layout = QVBoxLayout(params_group) - params_layout.addWidget(self.params_table) - - self.new_configuration_btn = QPushButton( - _("Create new parameters")) - self.clone_configuration_btn = QPushButton( - _("Clone currently selected parameters")) - self.delete_configuration_btn = QPushButton( - _("Delete currently selected parameters")) - self.reset_configuration_btn = QPushButton( - _("Reset parameters")) - self.delete_configuration_btn.setEnabled(False) - self.clone_configuration_btn.setEnabled(False) - - self.new_configuration_btn.clicked.connect( - self.create_new_configuration) - self.clone_configuration_btn.clicked.connect( - self.clone_configuration) - self.delete_configuration_btn.clicked.connect( - self.delete_configuration) - self.reset_configuration_btn.clicked.connect(self.reset_to_default) + # Buttons + self.new_configuration_btn = self.create_button( + icon=ima.icon("edit_add"), + callback=self.create_new_configuration, + tooltip=_("New parameters"), + ) + self.edit_configuration_btn = self.create_button( + icon=ima.icon("edit"), + callback=self.edit_configuration, + tooltip=_("Edit selected"), + ) + self.clone_configuration_btn = self.create_button( + icon=ima.icon("editcopy"), + callback=self.clone_configuration, + tooltip=_("Clone selected"), + ) + self.delete_configuration_btn = self.create_button( + icon=ima.icon("editclear"), + callback=self.delete_configuration, + tooltip=_("Delete selected"), + ) + self.reset_changes_btn = self.create_button( + icon=ima.icon("restart"), + callback=self.reset_changes, + tooltip=_("Reset current changes"), + ) + + # Disable edition button at startup + self.set_buttons_status(status=False) # Buttons layout + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + btns = [ self.new_configuration_btn, - self.clone_configuration_btn, + self.edit_configuration_btn, self.delete_configuration_btn, - self.reset_configuration_btn + self.clone_configuration_btn, + self.reset_changes_btn, ] - sn_buttons_layout = QGridLayout() - for i, btn in enumerate(btns): - sn_buttons_layout.addWidget(btn, i, 1) - sn_buttons_layout.setColumnStretch(0, 1) - sn_buttons_layout.setColumnStretch(1, 2) - sn_buttons_layout.setColumnStretch(2, 1) + for btn in btns: + buttons_layout.addWidget(btn) + + buttons_layout.addStretch() + + # Final layout + vlayout = QVBoxLayout() + vlayout.addWidget(about_label) + vlayout.addSpacing(3 * AppStyle.MarginSize) + vlayout.addLayout(executor_layout) + vlayout.addSpacing(3 * AppStyle.MarginSize) + vlayout.addWidget(params_label) + vlayout.addLayout(params_table_layout) + vlayout.addSpacing(AppStyle.MarginSize) + vlayout.addLayout(buttons_layout) + vlayout.addStretch() + executor_widget = QWidget(self) + executor_widget.setLayout(vlayout) # --- Editor interactions tab --- newcb = self.create_checkbox @@ -226,21 +308,11 @@ def setup_page(self): run_layout = QVBoxLayout() run_layout.addWidget(saveall_box) run_layout.addWidget(run_cell_box) - run_widget = QWidget() + run_widget = QWidget(self) run_widget.setLayout(run_layout) - vlayout = QVBoxLayout() - vlayout.addWidget(about_label) - vlayout.addSpacing(9) - vlayout.addWidget(self.executor_combo) - vlayout.addSpacing(9) - vlayout.addWidget(params_group) - vlayout.addLayout(sn_buttons_layout) - vlayout.addStretch(1) - executor_widget = QWidget() - executor_widget.setLayout(vlayout) - - self.create_tab(_("Run executors"), executor_widget) + # --- Tabs --- + self.create_tab(_("Global configurations"), executor_widget) self.create_tab(_("Editor interactions"), run_widget) def executor_index_changed(self, index: int): @@ -254,46 +326,76 @@ def executor_index_changed(self, index: int): executor, available_inputs = self.executor_model.selected_executor( index) container = self.plugin_container + executor_conf_params = self.all_executor_model.get(executor, {}) if executor_conf_params == {}: for (ext, context) in available_inputs: - params = ( - container.get_executor_configuration_parameters( - executor, ext, context)) + params = container.get_executor_configuration_parameters( + executor, ext, context + ) params = params["params"] for exec_params_id in params: exec_params = params[exec_params_id] - executor_conf_params[ - (ext, context, exec_params_id)] = exec_params + + # Don't display configs set for specific files. Here + # users are allowed to configure global configs, i.e. those + # that can be used by any file. + if exec_params.get("file_uuid") is not None: + continue + + params_key = (ext, context, exec_params_id) + executor_conf_params[params_key] = exec_params + self.default_executor_conf_params[executor] = deepcopy( executor_conf_params) + self.all_executor_model[executor] = deepcopy(executor_conf_params) self.table_model.set_parameters(executor_conf_params) self.previous_executor_index = index - self.set_clone_delete_btn_status() + self.set_buttons_status() - def set_clone_delete_btn_status(self): - status = self.table_model.rowCount() != 0 + def on_table_data_changed(self): + # Buttons need to be disabled because the table model is reset when + # data is changed and focus is lost + self.set_buttons_status(False) + self.set_modified(True) + + def set_buttons_status(self, status=None, is_default=False): + # We need to enclose the code below in a try/except because these + # buttons might not be created yet, which gives an AttributeError. try: - self.delete_configuration_btn.setEnabled(status) + if status is None: + status = ( + self.table_model.rowCount() != 0 + and self.params_table.currentIndex().isValid() + ) + + # Don't allow to delete default configurations + if is_default: + self.delete_configuration_btn.setEnabled(False) + else: + self.delete_configuration_btn.setEnabled(status) + + self.edit_configuration_btn.setEnabled(status) self.clone_configuration_btn.setEnabled(status) except AttributeError: - # Buttons might not exist yet pass def get_executor_configurations(self) -> Dict[ str, SupportedExecutionRunConfiguration]: exec_index = self.executor_combo.currentIndex() executor_name, available_inputs = ( - self.executor_model.selected_executor(exec_index)) + self.executor_model.selected_executor(exec_index) + ) executor_params: Dict[str, SupportedExecutionRunConfiguration] = {} extensions: Set[str] = set({}) contexts: Dict[str, List[str]] = {} conf_indices = ( - self.plugin_container.executor_model.executor_configurations) + self.plugin_container.executor_model.executor_configurations + ) for _input in available_inputs: extension, context = _input @@ -306,43 +408,69 @@ def get_executor_configurations(self) -> Dict[ conf = executors[executor_name] executor_params[_input] = conf - contexts = {ext: move_file_to_front(ctx) - for ext, ctx in contexts.items()} - return list(sorted(extensions)), contexts, executor_params + contexts = { + ext: move_file_to_front(ctx) for ext, ctx in contexts.items() + } + + # Localized version of the executor + executor_loc_name = self.main.get_plugin(executor_name).get_name() + + return ( + list(sorted(extensions)), + contexts, + executor_loc_name, + executor_params + ) def create_new_configuration(self): self.params_table.show_editor(new=True) + def edit_configuration(self): + self.params_table.show_editor() + def clone_configuration(self): self.params_table.clone_configuration() def delete_configuration(self): - executor_name, _ = self.executor_model.selected_executor( - self.previous_executor_index) + executor_name, __ = self.executor_model.selected_executor( + self.previous_executor_index + ) index = self.params_table.currentIndex().row() conf_index = self.table_model.get_tuple_index(index) - executor_params = self.all_executor_model[executor_name] + + executor_params = self.table_model.executor_conf_params executor_params.pop(conf_index, None) + + if executor_name not in self._params_to_delete: + self._params_to_delete[executor_name] = [] + self._params_to_delete[executor_name].append(conf_index) + self.table_model.set_parameters(executor_params) self.table_model.reset_model() - self.set_clone_delete_btn_status() - def reset_to_default(self): + self.set_modified(True) + self.set_buttons_status() + + def reset_changes(self): + """Reset changes to the parameters loaded when the page was created.""" self.all_executor_model = deepcopy(self.default_executor_conf_params) executor_name, _ = self.executor_model.selected_executor( - self.previous_executor_index) + self.previous_executor_index + ) executor_params = self.all_executor_model[executor_name] self.table_model.set_parameters(executor_params) self.table_model.reset_model() - self.set_modified(False) - self.set_clone_delete_btn_status() + self.set_modified(True) + self.set_buttons_status() def apply_settings(self): prev_executor_info = self.table_model.get_current_view() previous_executor_name, _ = self.executor_model.selected_executor( - self.previous_executor_index) + self.previous_executor_index + ) self.all_executor_model[previous_executor_name] = prev_executor_info + # Save new parameters for executor in self.all_executor_model: executor_params = self.all_executor_model[executor] stored_execution_params: Dict[ @@ -363,4 +491,16 @@ def apply_settings(self): executor, extension, context, {'params': ext_ctx_list} ) + # Delete removed parameters + for executor in self._params_to_delete: + executor_params_to_delete = self._params_to_delete[executor] + + for key in executor_params_to_delete: + (extension, context, params_id) = key + self.plugin_container.delete_executor_configuration_parameters( + executor, extension, context, params_id + ) + + self._params_to_delete = {} + return {'parameters'} diff --git a/spyder/plugins/run/container.py b/spyder/plugins/run/container.py index 8d57d50f68d..b70d0c93bea 100644 --- a/spyder/plugins/run/container.py +++ b/spyder/plugins/run/container.py @@ -10,6 +10,7 @@ import functools import os.path as osp from typing import Callable, List, Dict, Tuple, Set, Optional +from uuid import uuid4 from weakref import WeakSet, WeakValueDictionary # Third-party imports @@ -37,6 +38,7 @@ class RunContainer(PluginMainContainer): """Non-graphical container used to spawn dialogs and creating actions.""" sig_run_action_created = Signal(str, bool, str) + sig_open_preferences_requested = Signal() # ---- PluginMainContainer API # ------------------------------------------------------------------------- @@ -74,7 +76,7 @@ def setup(self): self.configure_action = self.create_action( RunActions.Configure, - _('&Open run settings'), + _('&Configuration per file'), self.create_icon('run_settings'), tip=_('Run settings'), triggered=functools.partial( @@ -88,6 +90,12 @@ def setup(self): context=Qt.ApplicationShortcut ) + self.create_action( + RunActions.GlobalConfigurations, + _("&Global configurations"), + triggered=self.sig_open_preferences_requested + ) + self.re_run_action = self.create_action( RunActions.ReRun, _('Re-run &last file'), @@ -203,13 +211,8 @@ def run_file(self, selected_uuid=None, selected_executor=None): if not isinstance(selected_uuid, bool) and selected_uuid is not None: self.switch_focused_run_configuration(selected_uuid) - exec_params = self.get_last_used_executor_parameters( - self.currently_selected_configuration) - - display_dialog = exec_params['display_dialog'] - self.edit_run_configurations( - display_dialog=display_dialog, + display_dialog=False, selected_executor=selected_executor) def edit_run_configurations( @@ -219,10 +222,18 @@ def edit_run_configurations( selected_executor=None ): self.dialog = RunDialog( - self, self.metadata_model, self.executor_model, - self.parameter_model, disable_run_btn=disable_run_btn) + self, + self.metadata_model, + self.executor_model, + self.parameter_model, + disable_run_btn=disable_run_btn + ) + self.dialog.setup() self.dialog.finished.connect(self.process_run_dialog_result) + self.dialog.sig_delete_config_requested.connect( + self.delete_executor_configuration_parameters + ) if selected_executor is not None: self.dialog.select_executor(selected_executor) @@ -238,27 +249,37 @@ def process_run_dialog_result(self, result): if status == RunDialogStatus.Close: return - (uuid, executor_name, - ext_params, open_dialog) = self.dialog.get_configuration() + uuid, executor_name, ext_params = self.dialog.get_configuration() if (status & RunDialogStatus.Save) == RunDialogStatus.Save: exec_uuid = ext_params['uuid'] - if exec_uuid is not None: + + # Default parameters should already be saved in our config system. + # So, there is no need to save them again here. + if exec_uuid is not None and not ext_params["default"]: info = self.metadata_model.get_metadata_context_extension(uuid) context, ext = info context_name = context['name'] context_id = getattr(RunContext, context_name) all_exec_params = self.get_executor_configuration_parameters( - executor_name, ext, context_id) + executor_name, + ext, + context_id + ) exec_params = all_exec_params['params'] exec_params[exec_uuid] = ext_params + self.set_executor_configuration_parameters( - executor_name, ext, context_id, all_exec_params) + executor_name, + ext, + context_id, + all_exec_params, + ) last_used_conf = StoredRunConfigurationExecutor( - executor=executor_name, selected=ext_params['uuid'], - display_dialog=open_dialog) + executor=executor_name, selected=ext_params['uuid'] + ) self.set_last_used_execution_params(uuid, last_used_conf) @@ -763,6 +784,21 @@ def register_executor_configuration( ext, context_id, executor_id, config) executor_count += 1 + # Save default configs to our config system so that they are + # displayed in the Run confpage + config_widget = config["configuration_widget"] + default_conf = ( + config_widget.get_default_configuration() + if config_widget + else {} + ) + self._save_default_graphical_executor_configuration( + executor_id, + ext, + context_id, + default_conf + ) + self.executor_use_count[executor_id] = executor_count self.executor_model.set_executor_name(executor_id, executor_name) self.set_actions_status() @@ -884,15 +920,16 @@ def get_executor_configuration_parameters( run configuration. """ - all_executor_params: Dict[ + all_execution_params: Dict[ str, - Dict[Tuple[str, str], - StoredRunExecutorParameters] + Dict[Tuple[str, str], StoredRunExecutorParameters] ] = self.get_conf('parameters', default={}) - executor_params = all_executor_params.get(executor_name, {}) + executor_params = all_execution_params.get(executor_name, {}) params = executor_params.get( - (extension, context_id), StoredRunExecutorParameters(params={})) + (extension, context_id), + StoredRunExecutorParameters(params={}) + ) return params @@ -919,16 +956,68 @@ def set_executor_configuration_parameters( A dictionary containing the run configuration parameters for the given executor. """ - all_executor_params: Dict[ + all_execution_params: Dict[ + str, + Dict[Tuple[str, str], StoredRunExecutorParameters] + ] = self.get_conf('parameters', default={}) + + executor_params = all_execution_params.get(executor_name, {}) + ext_ctx_params = executor_params.get((extension, context_id), {}) + + if ext_ctx_params: + # Update current parameters in case the user has already saved some + # before. + ext_ctx_params['params'].update(params['params']) + else: + # Create a new entry of executor parameters in case there isn't any + executor_params[(extension, context_id)] = params + + all_execution_params[executor_name] = executor_params + + self.set_conf('parameters', all_execution_params) + + def delete_executor_configuration_parameters( + self, + executor_name: str, + extension: str, + context_id: str, + uuid: str + ): + """ + Delete an executor parameter set from our config system. + + Parameters + ---------- + executor_name: str + The identifier of the run executor. + extension: str + The file extension of the configuration parameters to delete. + context_id: str + The context of the configuration parameters to delete. + uuid: str + The run configuration identifier. + """ + all_execution_params: Dict[ str, - Dict[Tuple[str, str], - StoredRunExecutorParameters] + Dict[Tuple[str, str], StoredRunExecutorParameters] ] = self.get_conf('parameters', default={}) - executor_params = all_executor_params.get(executor_name, {}) - executor_params[(extension, context_id)] = params - all_executor_params[executor_name] = executor_params - self.set_conf('parameters', all_executor_params) + executor_params = all_execution_params[executor_name] + ext_ctx_params = executor_params[(extension, context_id)]['params'] + + for params_id in ext_ctx_params: + if params_id == uuid: + # Prevent to remove default parameters + if ext_ctx_params[params_id]["default"]: + return + + ext_ctx_params.pop(params_id, None) + break + + executor_params[(extension, context_id)]['params'] = ext_ctx_params + all_execution_params[executor_name] = executor_params + + self.set_conf('parameters', all_execution_params) def get_last_used_executor_parameters( self, @@ -958,8 +1047,7 @@ def get_last_used_executor_parameters( uuid, StoredRunConfigurationExecutor( executor=None, - selected=None, - display_dialog=False, + selected=None ) ) @@ -996,8 +1084,7 @@ def get_last_used_execution_params( default = StoredRunConfigurationExecutor( executor=executor_name, - selected=None, - display_dialog=False + selected=None ) params = mru_executors_uuids.get(uuid, default) @@ -1031,3 +1118,72 @@ def set_last_used_execution_params( mru_executors_uuids[uuid] = params self.set_conf('last_used_parameters', mru_executors_uuids) + + # ---- Private API + # ------------------------------------------------------------------------- + def _save_default_graphical_executor_configuration( + self, + executor_name: str, + extension: str, + context_id: str, + default_conf: dict, + ): + """ + Save a default executor configuration to our config system. + + Parameters + ---------- + executor_name: str + The identifier of the run executor. + extension: str + The file extension to register the configuration parameters for. + context_id: str + The context to register the configuration parameters for. + default_conf: dict + A dictionary containing the run configuration parameters for the + given executor. + """ + # Check if there's already a default parameter config to not do this + # because it's not necessary. + current_params = self.get_executor_configuration_parameters( + executor_name, + extension, + context_id + ) + + for param in current_params["params"].values(): + if param["default"]: + return + + # Id for this config + uuid = str(uuid4()) + + # Build config + cwd_opts = WorkingDirOpts( + source=WorkingDirSource.ConfigurationDirectory, + path=None + ) + + exec_params = RunExecutionParameters( + working_dir=cwd_opts, executor_params=default_conf + ) + + ext_exec_params = ExtendedRunExecutionParameters( + uuid=uuid, + name=_("Default"), + params=exec_params, + file_uuid=None, + default=True, + ) + + store_params = StoredRunExecutorParameters( + params={uuid: ext_exec_params} + ) + + # Save config + self.set_executor_configuration_parameters( + executor_name, + extension, + context_id, + store_params, + ) diff --git a/spyder/plugins/run/models.py b/spyder/plugins/run/models.py index 1081c308961..ca67fd30bf5 100644 --- a/spyder/plugins/run/models.py +++ b/spyder/plugins/run/models.py @@ -17,10 +17,13 @@ # Local imports from spyder.api.translations import _ from spyder.plugins.run.api import ( - StoredRunExecutorParameters, RunContext, RunConfigurationMetadata, - SupportedExecutionRunConfiguration, RunParameterFlags, - StoredRunConfigurationExecutor, ExtendedRunExecutionParameters, - RunExecutionParameters, WorkingDirOpts, WorkingDirSource) + ExtendedRunExecutionParameters, + RunConfigurationMetadata, + RunContext, + StoredRunConfigurationExecutor, + StoredRunExecutorParameters, + SupportedExecutionRunConfiguration, +) class RunExecutorListModel(QAbstractListModel): @@ -138,7 +141,7 @@ def executor_supports_configuration( return executor in input_executors def data(self, index: QModelIndex, role: int = Qt.DisplayRole): - if role == Qt.DisplayRole: + if role == Qt.DisplayRole or role == Qt.EditRole: executor_indices = self.inverted_pos[self.current_input] executor_id = executor_indices[index.row()] return self.executor_names[executor_id] @@ -206,7 +209,7 @@ def update_index(self, index: int): self.executor_model.switch_input(uuid, (ext, context_id)) def data(self, index: QModelIndex, role: int = Qt.DisplayRole): - if role == Qt.DisplayRole: + if role == Qt.DisplayRole or role == Qt.EditRole: uuid = self.metadata_index[index.row()] metadata = self.run_configurations[uuid] return metadata['name'] @@ -283,53 +286,24 @@ def __init__(self, parent): def data(self, index: QModelIndex, role: int = Qt.DisplayRole): pos = index.row() - total_saved_params = len(self.executor_conf_params) - if pos == total_saved_params: - if role == Qt.DisplayRole: - return _("Default/Transient") - elif role == Qt.ToolTipRole: - return _( - "This configuration will not be saved after execution" - ) - else: - params_id = self.params_index[pos] - params = self.executor_conf_params[params_id] - params_name = params['name'] - if role == Qt.DisplayRole: - return params_name + pos = 0 if pos == -1 else pos + params_id = self.params_index[pos] + params = self.executor_conf_params[params_id] + params_name = params['name'] + if role == Qt.DisplayRole or role == Qt.EditRole: + return params_name def rowCount(self, parent: QModelIndex = ...) -> int: - return len(self.executor_conf_params) + 1 - - def get_executor_parameters( - self, - index: int - ) -> Tuple[RunParameterFlags, RunExecutionParameters]: - - if index == len(self) - 1: - default_working_dir = WorkingDirOpts( - source=WorkingDirSource.ConfigurationDirectory, - path=None) - default_params = RunExecutionParameters( - working_dir=default_working_dir, - executor_params={}) - - return RunParameterFlags.SetDefaults, default_params - else: - params_id = self.params_index[index] - params = self.executor_conf_params[params_id] - actual_params = params['params'] + return len(self.executor_conf_params) - return RunParameterFlags.SwitchValues, actual_params + def get_parameters(self, index: int) -> ExtendedRunExecutionParameters: + params_id = self.params_index[index] + return self.executor_conf_params[params_id] def get_parameters_uuid_name( - self, - index: int + self, index: int ) -> Tuple[Optional[str], Optional[str]]: - if index == len(self) - 1: - return None, None - params_id = self.params_index[index] params = self.executor_conf_params[params_id] return params['uuid'], params['name'] @@ -341,17 +315,60 @@ def set_parameters( self.beginResetModel() self.executor_conf_params = parameters self.params_index = dict(enumerate(self.executor_conf_params)) - self.inverse_index = {self.params_index[k]: k - for k in self.params_index} + self.inverse_index = { + self.params_index[k]: k for k in self.params_index + } self.endResetModel() - def get_parameters_index(self, parameters_name: Optional[str]) -> int: - index = self.inverse_index.get(parameters_name, - len(self.executor_conf_params)) + def get_parameters_index_by_uuid( + self, parameters_uuid: Optional[str] + ) -> int: + return self.inverse_index.get(parameters_uuid, 0) + + def get_parameters_index_by_name(self, parameters_name: str) -> int: + index = -1 + for id_, idx in self.inverse_index.items(): + if self.executor_conf_params[id_]['name'] == parameters_name: + index = idx + break + return index + def get_parameter_names(self, filter_global: bool = False) -> List[str]: + """ + Get all parameter names for this executor. + + Parameters + ---------- + filter_global: bool, optional + Whether to filter global parameters from the results. + """ + names = [] + for params in self.executor_conf_params.values(): + if filter_global: + if params["file_uuid"] is not None: + names.append(params["name"]) + else: + names.append(params["name"]) + + return names + + def get_number_of_custom_params(self, global_params_name: str) -> int: + """ + Get the number of custom parameters derived from a set of global ones. + + Parameters + ---------- + global_params_name: str + Name of the global parameters. + """ + names = self.get_parameter_names(filter_global=True) + return len( + [name for name in names if name.startswith(global_params_name)] + ) + def __len__(self) -> int: - return len(self.executor_conf_params) + 1 + return len(self.executor_conf_params) class RunExecutorNamesListModel(QAbstractListModel): @@ -382,7 +399,7 @@ def __init__(self, parent, executor_model: RunExecutorListModel): def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> str: row = index.row() - if role == Qt.DisplayRole: + if role == Qt.DisplayRole or role == Qt.EditRole: executor_id = self.executor_indexed_list[row] return self.executor_model.executor_names[executor_id] @@ -396,16 +413,17 @@ def selected_executor( class ExecutorRunParametersTableModel(QAbstractTableModel): - EXTENSION = 0 - CONTEXT = 1 - NAME = 2 + NAME = 0 + EXTENSION = 1 + CONTEXT = 2 sig_data_changed = Signal() def __init__(self, parent): super().__init__(parent) self.executor_conf_params: Dict[ - Tuple[str, str, str], ExtendedRunExecutionParameters] = {} + Tuple[str, str, str], ExtendedRunExecutionParameters + ] = {} self.params_index: Dict[int, Tuple[str, str, str]] = {} self.inverse_index: Dict[Tuple[str, str, str], int] = {} @@ -419,13 +437,13 @@ def data(self, index: QModelIndex, (extension, context, __) = params_idx params = self.executor_conf_params[params_idx] - if role == Qt.DisplayRole: + if role == Qt.DisplayRole or role == Qt.EditRole: if column == self.EXTENSION: return extension elif column == self.CONTEXT: return context elif column == self.NAME: - return params['name'] + return _("Default") if params['default'] else params['name'] elif role == Qt.TextAlignmentRole: return int(Qt.AlignHCenter | Qt.AlignVCenter) elif role == Qt.ToolTipRole: @@ -443,14 +461,14 @@ def headerData( return int(Qt.AlignHCenter | Qt.AlignVCenter) return int(Qt.AlignRight | Qt.AlignVCenter) - if role == Qt.DisplayRole: + if role == Qt.DisplayRole or role == Qt.EditRole: if orientation == Qt.Horizontal: if section == self.EXTENSION: return _('File extension') elif section == self.CONTEXT: - return _('Context name') + return _('Context') elif section == self.NAME: - return _('Run parameters name') + return _('Parameters name') def rowCount(self, parent: QModelIndex = None) -> int: return len(self.params_index) @@ -463,9 +481,24 @@ def set_parameters( params: Dict[Tuple[str, str, str], ExtendedRunExecutionParameters] ): self.beginResetModel() + + # Reorder params so that Python and IPython extensions are shown first + # and second by default, respectively. + ordered_params = [] + for k, v in params.items(): + if k[0] == "py": + ordered_params.insert(0, (k, v)) + elif k[0] == "ipy": + ordered_params.insert(1, (k, v)) + else: + ordered_params.append((k, v)) + params = dict(ordered_params) + + # Update attributes self.executor_conf_params = params self.params_index = dict(enumerate(params)) self.inverse_index = {v: k for k, v in self.params_index.items()} + self.endResetModel() def get_current_view( @@ -524,9 +557,22 @@ def apply_changes( self.params_index = dict(enumerate(self.executor_conf_params)) self.inverse_index = {v: k for k, v in self.params_index.items()} + self.endResetModel() + self.sig_data_changed.emit() + def get_parameter_names(self) -> Dict[Tuple[str, str], List[str]]: + """Get all parameter names per extension and context.""" + names = {} + for k, v in self.executor_conf_params.items(): + extension_context = (k[0], k[1]) + current_names = names.get(extension_context, []) + current_names.append(_("Default") if v["default"] else v["name"]) + names[extension_context] = current_names + + return names + def __len__(self): return len(self.inverse_index) @@ -539,5 +585,5 @@ def __getitem__( ) -> Tuple[str, str, ExtendedRunExecutionParameters]: tuple_index = self.params_index[index] - (ext, context, _) = tuple_index + (ext, context, __) = tuple_index return (ext, context, self.executor_conf_params[tuple_index]) diff --git a/spyder/plugins/run/plugin.py b/spyder/plugins/run/plugin.py index 507abd10ab1..4ed75f90c5d 100644 --- a/spyder/plugins/run/plugin.py +++ b/spyder/plugins/run/plugin.py @@ -88,7 +88,11 @@ def on_initialize(self): container = self.get_container() container.sig_run_action_created.connect( - self.register_action_shortcuts) + self.register_action_shortcuts + ) + container.sig_open_preferences_requested.connect( + self._open_run_preferences + ) @on_plugin_available(plugin=Plugins.WorkingDirectory) def on_working_directory_available(self): @@ -101,7 +105,12 @@ def on_working_directory_available(self): def on_main_menu_available(self): main_menu = self.get_plugin(Plugins.MainMenu) - for action in [RunActions.Run, RunActions.ReRun, RunActions.Configure]: + for action in [ + RunActions.Run, + RunActions.ReRun, + RunActions.Configure, + RunActions.GlobalConfigurations, + ]: main_menu.add_item_to_application_menu( self.get_action(action), ApplicationMenus.Run, @@ -154,7 +163,12 @@ def on_working_directory_teardown(self): def on_main_menu_teardown(self): main_menu = self.get_plugin(Plugins.MainMenu) - for action in [RunActions.Run, RunActions.ReRun, RunActions.Configure]: + for action in [ + RunActions.Run, + RunActions.ReRun, + RunActions.Configure, + RunActions.GlobalConfigurations, + ]: main_menu.remove_item_from_application_menu( action, ApplicationMenus.Run @@ -800,3 +814,12 @@ def register_action_shortcuts( else: self.pending_shortcut_actions.append( (action, shortcut_context, action_name)) + + def _open_run_preferences(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.open_dialog() + + container = preferences.get_container() + dlg = container.dialog + index = dlg.get_index_by_name("run") + dlg.set_current_index(index) diff --git a/spyder/plugins/run/tests/test_run.py b/spyder/plugins/run/tests/test_run.py index a4f35592bd7..871c6caa597 100644 --- a/spyder/plugins/run/tests/test_run.py +++ b/spyder/plugins/run/tests/test_run.py @@ -25,10 +25,10 @@ # Local imports from spyder.plugins.run.api import ( RunExecutor, RunConfigurationProvider, RunConfigurationMetadata, Context, - RunConfiguration, SupportedExtensionContexts, + RunConfiguration, SupportedExtensionContexts, RunExecutionParameters, RunExecutorConfigurationGroup, ExtendedRunExecutionParameters, PossibleRunResult, RunContext, ExtendedContext, RunActions, run_execute, - WorkingDirSource) + WorkingDirOpts, WorkingDirSource, StoredRunExecutorParameters) from spyder.plugins.run.plugin import Run @@ -597,9 +597,9 @@ def test_run_plugin(qtbot, run_mock): assert test_executor_name == executor_name assert handler == 'both' - # Ensure that the executor run configuration is transient - assert exec_conf['uuid'] is None - assert exec_conf['name'] is None + # Ensure that the executor run configuration was saved + assert exec_conf['uuid'] is not None + assert exec_conf['name'] == "Default (custom)" # Check that the configuration parameters are the ones defined by the # dialog @@ -612,24 +612,37 @@ def test_run_plugin(qtbot, run_mock): # Assert that the run_exec dispatcher works for the specific combination assert handler_name == f'{ext}_{context["identifier"]}' - # Assert that the run configuration gets registered without executor params + # Assert that the run configuration gets registered stored_run_params = run.get_last_used_executor_parameters(run_conf_uuid) + current_exec_uuid = exec_conf['uuid'] assert stored_run_params['executor'] == test_executor_name - assert stored_run_params['selected'] is None - assert not stored_run_params['display_dialog'] + assert stored_run_params['selected'] == current_exec_uuid - # The configuration gets run again - with qtbot.waitSignal(executor_1.sig_run_invocation) as sig: - run_act.trigger() + # Spawn the configuration dialog + run_act = run.get_action(RunActions.Run) + run_act.trigger() + + dialog = container.dialog + with qtbot.waitSignal(dialog.finished, timeout=200000): + # Select the first configuration again + conf_combo.setCurrentIndex(0) + + # Change some options + conf_widget = dialog.current_widget + conf_widget.widgets['opt1'].setChecked(False) + + # Execute the configuration + buttons = dialog.bbox.buttons() + run_btn = buttons[2] + with qtbot.waitSignal(executor_1.sig_run_invocation) as sig: + qtbot.mouseClick(run_btn, Qt.LeftButton) - # Assert that the transient run executor parameters reverted to the - # default ones + # Assert that changes took effect and that the run executor parameters were + # saved in the Custom config _, _, _, exec_conf = sig.args[0] params = exec_conf['params'] - working_dir = params['working_dir'] - assert working_dir['source'] == WorkingDirSource.ConfigurationDirectory - assert working_dir['path'] == '' - assert params['executor_params'] == default_conf + assert params['executor_params']['opt1'] is False + assert exec_conf['uuid'] == current_exec_uuid # Focus into another configuration exec_provider_2.switch_focus('ext3', 'AnotherSuperContext') @@ -729,6 +742,7 @@ def test_run_plugin(qtbot, run_mock): # Switch to other run configuration and register a set of custom run # executor parameters exec_provider_2.switch_focus('ext3', 'AnotherSuperContext') + dialog.exec_() # Spawn the configuration dialog run_act = run.get_action(RunActions.Run) @@ -738,8 +752,7 @@ def test_run_plugin(qtbot, run_mock): with qtbot.waitSignal(dialog.finished, timeout=200000): conf_combo = dialog.configuration_combo exec_combo = dialog.executor_combo - store_params_cb = dialog.store_params_cb - store_params_text = dialog.store_params_text + name_params_text = dialog.name_params_text # Modify some options conf_widget = dialog.current_widget @@ -747,8 +760,7 @@ def test_run_plugin(qtbot, run_mock): conf_widget.widgets['name_2'].setChecked(False) # Make sure that the custom configuration is stored - store_params_cb.setChecked(True) - store_params_text.setText('CustomParams') + name_params_text.setText('CustomParams') # Execute the configuration buttons = dialog.bbox.buttons() @@ -762,7 +774,7 @@ def test_run_plugin(qtbot, run_mock): saved_params = run.get_executor_configuration_parameters( executor_name, 'ext3', RunContext.AnotherSuperContext) executor_params = saved_params['params'] - assert len(executor_params) == 1 + assert len(executor_params) == 2 # Check that the configuration passed to the executor is the same that was # saved. @@ -778,7 +790,60 @@ def test_run_plugin(qtbot, run_mock): stored_run_params = run.get_last_used_executor_parameters(run_conf_uuid) assert stored_run_params['executor'] == executor_name assert stored_run_params['selected'] == exec_conf_uuid - assert not stored_run_params['display_dialog'] + + # Check deleting a parameters config + current_exec_params = container.get_conf('parameters')[executor_name] + current_ext_ctx_params = ( + current_exec_params[('ext3', RunContext.AnotherSuperContext)]['params'] + ) + assert current_ext_ctx_params != {} # Check params to delete are present + + container.delete_executor_configuration_parameters( + executor_name, 'ext3', RunContext.AnotherSuperContext, exec_conf_uuid + ) + + new_exec_params = container.get_conf('parameters')[executor_name] + new_ext_ctx_params = ( + new_exec_params[('ext3', RunContext.AnotherSuperContext)]['params'] + ) + assert len(new_ext_ctx_params) == 1 + + # Check that adding new parameters preserves the previous ones + current_exec_params = container.get_conf('parameters')[executor_name] + assert ( + len( + current_exec_params[("ext1", RunContext.RegisteredContext)][ + "params" + ] + ) + == 2 + ) # Check that we have one config in this context + + new_exec_conf_uuid = str(uuid4()) + new_params = StoredRunExecutorParameters( + params={ + new_exec_conf_uuid: ExtendedRunExecutionParameters( + uuid=new_exec_conf_uuid, + name='Foo', + params=RunExecutionParameters( + WorkingDirOpts(source=WorkingDirSource.CurrentDirectory) + ), + file_uuid=None + ) + } + ) + + container.set_executor_configuration_parameters( + executor_name, 'ext1', RunContext.RegisteredContext, new_params + ) + + new_exec_params = container.get_conf('parameters')[executor_name] + + assert ( + len( + new_exec_params[('ext1', RunContext.RegisteredContext)]['params'] + ) == 3 + ) # Now we should have two configs in the same context # Test teardown functions executor_1.on_run_teardown(run) diff --git a/spyder/plugins/run/widgets.py b/spyder/plugins/run/widgets.py index 5cc0aa5d29e..f607ff268d3 100644 --- a/spyder/plugins/run/widgets.py +++ b/spyder/plugins/run/widgets.py @@ -7,61 +7,66 @@ """Run dialogs and widgets and data models.""" # Standard library imports -from datetime import datetime import os.path as osp +import textwrap from typing import Optional, Tuple, List, Dict from uuid import uuid4 # Third party imports from qtpy.compat import getexistingdirectory from qtpy.QtCore import QSize, Qt, Signal -from qtpy.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QLayout, - QRadioButton, QStackedWidget, QVBoxLayout, QWidget) +from qtpy.QtGui import QFontMetrics +from qtpy.QtWidgets import ( + QAction, + QDialog, + QDialogButtonBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QLayout, + QMessageBox, + QRadioButton, + QPushButton, + QStackedWidget, + QVBoxLayout, +) +import qstylizer.style # Local imports from spyder.api.translations import _ +from spyder.config.base import running_under_pytest +from spyder.api.config.fonts import SpyderFontType, SpyderFontsMixin from spyder.api.widgets.comboboxes import SpyderComboBox from spyder.api.widgets.dialogs import SpyderDialogButtonBox from spyder.plugins.run.api import ( - RunParameterFlags, WorkingDirSource, WorkingDirOpts, - RunExecutionParameters, ExtendedRunExecutionParameters, - RunExecutorConfigurationGroup, SupportedExecutionRunConfiguration) + ExtendedRunExecutionParameters, + RunExecutorConfigurationGroup, + RunExecutionParameters, + SupportedExecutionRunConfiguration, + WorkingDirOpts, + WorkingDirSource, +) from spyder.utils.icon_manager import ima from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import create_toolbutton +from spyder.utils.qthelpers import qapplication +from spyder.utils.stylesheet import AppStyle, MAC +from spyder.widgets.collapsible import CollapsibleWidget +from spyder.widgets.helperwidgets import TipWidget - -# Main constants -RUN_DEFAULT_CONFIG = _("Run file with default configuration") -RUN_CUSTOM_CONFIG = _("Run file with custom configuration") -CURRENT_INTERPRETER = _("Execute in current console") -DEDICATED_INTERPRETER = _("Execute in a dedicated console") -SYSTERM_INTERPRETER = _("Execute in an external system terminal") - -CURRENT_INTERPRETER_OPTION = 'default/interpreter/current' -DEDICATED_INTERPRETER_OPTION = 'default/interpreter/dedicated' -SYSTERM_INTERPRETER_OPTION = 'default/interpreter/systerm' - -WDIR_USE_SCRIPT_DIR_OPTION = 'default/wdir/use_script_directory' -WDIR_USE_CWD_DIR_OPTION = 'default/wdir/use_cwd_directory' -WDIR_USE_FIXED_DIR_OPTION = 'default/wdir/use_fixed_directory' -WDIR_FIXED_DIR_OPTION = 'default/wdir/fixed_directory' - -ALWAYS_OPEN_FIRST_RUN = _("Always show %s for this run configuration") -ALWAYS_OPEN_FIRST_RUN_OPTION = 'open_on_firstrun' - -CLEAR_ALL_VARIABLES = _("Remove all variables before execution") -CONSOLE_NAMESPACE = _("Run in console's namespace instead of an empty one") -POST_MORTEM = _("Directly enter debugging when errors appear") -INTERACT = _("Interact with the Python console after execution") - -FILE_DIR = _("The directory of the configuration being executed") +# ---- Main constants +# ----------------------------------------------------------------------------- +FILE_DIR = _("The directory of the file being executed") CW_DIR = _("The current working directory") FIXED_DIR = _("The following directory:") - -STORE_PARAMS = _('Store current configuration as:') +EMPTY_NAME = _("Provide a name for this configuration") +REPEATED_NAME = _("Select a different name for this configuration") +SAME_PARAMETERS = _( + "You are trying to save a configuration that is exactly the same as the " + "current one" +) class RunDialogStatus: @@ -70,6 +75,8 @@ class RunDialogStatus: Run = 2 +# ---- Base class +# ----------------------------------------------------------------------------- class BaseRunConfigDialog(QDialog): """Run configuration dialog box, base widget""" size_change = Signal(QSize) @@ -90,9 +97,13 @@ def __init__(self, parent=None, disable_run_btn=False): self.setLayout(layout) self.disable_run_btn = disable_run_btn + # Style that will be set by children + self._css = qstylizer.style.StyleSheet() + def add_widgets(self, *widgets_or_spacings): """Add widgets/spacing to dialog vertical layout""" layout = self.layout() + for widget_or_spacing in widgets_or_spacings: if isinstance(widget_or_spacing, int): layout.addSpacing(widget_or_spacing) @@ -100,6 +111,7 @@ def add_widgets(self, *widgets_or_spacings): layout.addLayout(widget_or_spacing) else: layout.addWidget(widget_or_spacing) + return layout def add_button_box(self, stdbtns): @@ -114,13 +126,14 @@ def add_button_box(self, stdbtns): reset_deafults_btn = self.bbox.addButton( _('Reset'), QDialogButtonBox.ResetRole) reset_deafults_btn.clicked.connect(self.reset_btn_clicked) + + # Align this button to the text above it + reset_deafults_btn.setStyleSheet("margin-left: 5px") + self.bbox.accepted.connect(self.accept) self.bbox.rejected.connect(self.reject) - btnlayout = QHBoxLayout() - btnlayout.addStretch(1) - btnlayout.addWidget(self.bbox) - self.layout().addLayout(btnlayout) + self.layout().addWidget(self.bbox) def resizeEvent(self, event): """ @@ -147,54 +160,124 @@ def setup(self): raise NotImplementedError +# ---- Dialogs +# ----------------------------------------------------------------------------- class ExecutionParametersDialog(BaseRunConfigDialog): """Run execution parameters edition dialog.""" def __init__( self, parent, - executor_params: Dict[Tuple[str, str], SupportedExecutionRunConfiguration], + executor_name, + executor_params: Dict[ + Tuple[str, str], SupportedExecutionRunConfiguration + ], + param_names: Dict[Tuple[str, str], List[str]], extensions: Optional[List[str]] = None, contexts: Optional[Dict[str, List[str]]] = None, default_params: Optional[ExtendedRunExecutionParameters] = None, extension: Optional[str] = None, - context: Optional[str] = None + context: Optional[str] = None, + new_config: bool = False ): super().__init__(parent, True) + self.executor_name = executor_name self.executor_params = executor_params + self.param_names = param_names self.default_params = default_params self.extensions = extensions or [] self.contexts = contexts or {} self.extension = extension self.context = context + self.new_config = new_config self.parameters_name = None if default_params is not None: - self.parameters_name = default_params['name'] + self.parameters_name = ( + _("Default") + if default_params["default"] + else default_params["name"] + ) self.current_widget = None self.status = RunDialogStatus.Close + self.saved_conf = None + # ---- Public methods + # ------------------------------------------------------------------------- def setup(self): - ext_combo_label = QLabel(_("Select a file extension:")) - context_combo_label = QLabel(_("Select a run context:")) + # --- Configuration name + if self.new_config: + params_name_text = _("Save configuration as:") + else: + params_name_text = _("Configuration name:") + + params_name_label = QLabel(params_name_text) + self.store_params_text = QLineEdit(self) + self.store_params_text.setMinimumWidth(250) + store_params_layout = QHBoxLayout() + store_params_layout.addWidget(params_name_label) + store_params_layout.addWidget(self.store_params_text) + store_params_layout.addStretch(1) + + # This action needs to be added before setting an icon for it so that + # it doesn't show up in the line edit (despite being set as not visible + # below). That's probably a Qt bug. + status_action = QAction(self) + self.store_params_text.addAction( + status_action, QLineEdit.TrailingPosition + ) + self.store_params_text.status_action = status_action + + status_action.setIcon(ima.icon("error")) + status_action.setVisible(False) + + # This is necessary to fix the style of the tooltip shown inside the + # lineedit + store_params_css = qstylizer.style.StyleSheet() + store_params_css["QLineEdit QToolTip"].setValues( + padding="1px 2px", + ) + self.store_params_text.setStyleSheet(store_params_css.toString()) + + # --- Extension and context widgets + ext_combo_label = QLabel(_("File extension:")) + context_combo_label = QLabel(_("Run context:")) self.extension_combo = SpyderComboBox(self) + self.extension_combo.addItems(self.extensions) self.extension_combo.currentIndexChanged.connect( self.extension_changed) self.context_combo = SpyderComboBox(self) self.context_combo.currentIndexChanged.connect(self.context_changed) - self.stack = QStackedWidget() - self.executor_group = QGroupBox(_("Executor parameters")) - executor_layout = QVBoxLayout(self.executor_group) - executor_layout.addWidget(self.stack) + self.extension_combo.setMinimumWidth(150) + self.context_combo.setMinimumWidth(150) - self.wdir_group = QGroupBox(_("Working directory settings")) + ext_context_g_layout = QGridLayout() + ext_context_g_layout.addWidget(ext_combo_label, 0, 0) + ext_context_g_layout.addWidget(self.extension_combo, 0, 1) + ext_context_g_layout.addWidget(context_combo_label, 1, 0) + ext_context_g_layout.addWidget(self.context_combo, 1, 1) + + ext_context_layout = QHBoxLayout() + ext_context_layout.addLayout(ext_context_g_layout) + ext_context_layout.addStretch(1) + # --- Runner settings + self.stack = QStackedWidget(self) + + # --- Working directory settings + self.wdir_group = QGroupBox(_("Working directory settings")) wdir_layout = QVBoxLayout(self.wdir_group) + wdir_layout.setContentsMargins( + 3 * AppStyle.MarginSize, + 3 * AppStyle.MarginSize, + 3 * AppStyle.MarginSize, + AppStyle.MarginSize if MAC else 0, + ) self.file_dir_radio = QRadioButton(FILE_DIR) wdir_layout.addWidget(self.file_dir_radio) @@ -202,64 +285,69 @@ def setup(self): self.cwd_radio = QRadioButton(CW_DIR) wdir_layout.addWidget(self.cwd_radio) - fixed_dir_layout = QHBoxLayout() self.fixed_dir_radio = QRadioButton(FIXED_DIR) - fixed_dir_layout.addWidget(self.fixed_dir_radio) - - self.wd_edit = QLineEdit() + self.wd_edit = QLineEdit(self) self.fixed_dir_radio.toggled.connect(self.wd_edit.setEnabled) self.wd_edit.setEnabled(False) + browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) + browse_btn.setToolTip(_("Select directory")) + browse_btn.clicked.connect(self.select_directory) + browse_btn.setIconSize( + QSize(AppStyle.ConfigPageIconSize, AppStyle.ConfigPageIconSize) + ) + + fixed_dir_layout = QHBoxLayout() + fixed_dir_layout.addWidget(self.fixed_dir_radio) fixed_dir_layout.addWidget(self.wd_edit) - browse_btn = create_toolbutton( - self, - triggered=self.select_directory, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") - ) fixed_dir_layout.addWidget(browse_btn) wdir_layout.addLayout(fixed_dir_layout) - params_name_label = QLabel(_('Configuration name:')) - self.store_params_text = QLineEdit() - store_params_layout = QHBoxLayout() - store_params_layout.addWidget(params_name_label) - store_params_layout.addWidget(self.store_params_text) - - self.store_params_text.setPlaceholderText(_('My configuration name')) - - all_group = QVBoxLayout() - all_group.addWidget(self.executor_group) - all_group.addWidget(self.wdir_group) - all_group.addLayout(store_params_layout) - - layout = self.add_widgets(ext_combo_label, self.extension_combo, - context_combo_label, self.context_combo, - 10, all_group) + # --- Final layout + layout = self.add_widgets( + store_params_layout, + 4 * AppStyle.MarginSize, + ext_context_layout, + (3 if MAC else 4) * AppStyle.MarginSize, + self.stack, + self.wdir_group, + (-2 if MAC else 1) * AppStyle.MarginSize, + ) + layout.addStretch() + layout.setContentsMargins(*((AppStyle.InnerContentPadding,) * 4)) - widget_dialog = QWidget() - widget_dialog.setMinimumWidth(600) - widget_dialog.setLayout(layout) - scroll_layout = QVBoxLayout(self) - scroll_layout.addWidget(widget_dialog) self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.setWindowTitle(_("Run parameters")) - - self.extension_combo.addItems(self.extensions) + # --- Settings + self.setWindowTitle( + _("New run configuration for: {}").format(self.executor_name) + ) + self.layout().setSizeConstraint(QLayout.SetFixedSize) extension_index = 0 if self.extension is not None: extension_index = self.extensions.index(self.extension) self.extension_combo.setEnabled(False) + self.extension_combo.setCurrentIndex(extension_index) + + # This is necessary because extension_changed is not triggered + # automatically for this extension_index. + if extension_index == 0: + self.extension_changed(extension_index) + if self.context is not None: self.context_combo.setEnabled(False) - self.extension_combo.setCurrentIndex(extension_index) - if self.parameters_name: self.store_params_text.setText(self.parameters_name) + # Don't allow to change name for default or already saved params. + if self.default_params["default"] or not self.new_config: + self.store_params_text.setEnabled(False) + + # --- Stylesheet + self.setStyleSheet(self._stylesheet) + def extension_changed(self, index: int): if index < 0: return @@ -276,7 +364,7 @@ def extension_changed(self, index: int): context_index = contexts.index(self.context) self.context_combo.setCurrentIndex(context_index) - def context_changed(self, index: int): + def context_changed(self, index: int, reset: bool = False): if index < 0: return @@ -296,9 +384,9 @@ def context_changed(self, index: int): RunExecutorConfigurationGroup) if executor_conf_metadata['configuration_widget'] is None: - self.executor_group.setEnabled(False) + self.stack.setEnabled(False) else: - self.executor_group.setEnabled(True) + self.stack.setEnabled(True) self.wdir_group.setEnabled(requires_cwd) @@ -320,7 +408,11 @@ def context_changed(self, index: int): working_dir_params = params['working_dir'] exec_params = params - params_set = exec_params['executor_params'] or default_params + params_set = ( + default_params + if reset + else (exec_params["executor_params"] or default_params) + ) if params_set.keys() == default_params.keys(): self.current_widget.set_configuration(params_set) @@ -344,8 +436,7 @@ def context_changed(self, index: int): self.fixed_dir_radio.setChecked(True) self.wd_edit.setText(path) - if (not self.executor_group.isEnabled() and not - self.wdir_group.isEnabled()): + if not self.stack.isEnabled() and not self.wdir_group.isEnabled(): ok_btn = self.bbox.button(QDialogButtonBox.Ok) ok_btn.setEnabled(False) @@ -363,7 +454,7 @@ def select_directory(self): def reset_btn_clicked(self): index = self.context_combo.currentIndex() - self.context_changed(index) + self.context_changed(index, reset=True) def run_btn_clicked(self): self.status |= RunDialogStatus.Run @@ -371,9 +462,18 @@ def run_btn_clicked(self): def ok_btn_clicked(self): self.status |= RunDialogStatus.Save + def get_configuration( + self + ) -> Tuple[str, str, ExtendedRunExecutionParameters]: + + return self.saved_conf + + # ---- Qt methods + # ------------------------------------------------------------------------- def accept(self) -> None: self.status |= RunDialogStatus.Save widget_conf = self.current_widget.get_configuration() + self.store_params_text.status_action.setVisible(False) path = None source = None @@ -394,29 +494,58 @@ def accept(self) -> None: uuid = self.default_params['uuid'] else: uuid = str(uuid4()) + + # Validate name only for new configurations name = self.store_params_text.text() - if name == '': - date_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - name = f'Configuration-{date_str}' + if self.new_config: + if name == '': + self.store_params_text.status_action.setVisible(True) + self.store_params_text.status_action.setToolTip( + '\n'.join(textwrap.wrap(EMPTY_NAME, 50)) + ) + return + else: + extension = self.extension_combo.lineEdit().text() + context = self.context_combo.lineEdit().text() + current_names = self.param_names[(extension, context)] + if name in current_names: + self.store_params_text.status_action.setVisible(True) + self.store_params_text.status_action.setToolTip( + '\n'.join(textwrap.wrap(REPEATED_NAME, 50)) + ) + return ext_exec_params = ExtendedRunExecutionParameters( - uuid=uuid, name=name, params=exec_params + uuid=uuid, + name=name, + params=exec_params, + file_uuid=None, + default=False, ) self.saved_conf = (self.selected_extension, self.selected_context, ext_exec_params) + super().accept() - def get_configuration( - self - ) -> Tuple[str, str, ExtendedRunExecutionParameters]: + # ---- Private methods + # ------------------------------------------------------------------------- + @property + def _stylesheet(self): + # This avoids the extra bottom margin added by the config dialog since + # this widget is one of its children + self._css.QGroupBox.setValues( + marginBottom='0px', + ) - return self.saved_conf + return self._css.toString() -class RunDialog(BaseRunConfigDialog): +class RunDialog(BaseRunConfigDialog, SpyderFontsMixin): """Run dialog used to configure run executors.""" + sig_delete_config_requested = Signal(str, str, str, str) + def __init__( self, parent=None, @@ -426,34 +555,116 @@ def __init__( disable_run_btn=False ): super().__init__(parent, disable_run_btn=disable_run_btn) + self.run_conf_model = run_conf_model self.executors_model = executors_model self.parameter_model = parameter_model + self.current_widget = None self.status = RunDialogStatus.Close + self._is_shown = False + # ---- Public methods + # ------------------------------------------------------------------------- def setup(self): - combo_label = QLabel(_("Select a run configuration:")) - executor_label = QLabel(_("Select a run executor:")) + # --- Header + self.header_label = QLabel(self) + self.header_label.setObjectName("run-header-label") + # --- File combobox + # It's hidden by default to decrease the complexity of this dialog self.configuration_combo = SpyderComboBox(self) + self.configuration_combo.hide() + + # --- Executor and parameters widgets + executor_label = QLabel(_("Run this file in:")) self.executor_combo = SpyderComboBox(self) + self.executor_combo.setMinimumWidth(250) + executor_tip = TipWidget( + _( + "This is the plugin that will be used for execution when you " + "click on the Run button" + ), + icon=ima.icon('question_tip'), + hover_icon=ima.icon('question_tip_hover'), + size=23, + wrap_text=True + ) - parameters_label = QLabel(_("Select the run parameters:")) + parameters_label = QLabel(_("Preset configuration:")) self.parameters_combo = SpyderComboBox(self) - self.stack = QStackedWidget() - executor_layout = QVBoxLayout() - executor_layout.addWidget(parameters_label) - executor_layout.addWidget(self.parameters_combo) - executor_layout.addWidget(self.stack) + self.parameters_combo.setMinimumWidth(250) + parameters_tip = TipWidget( + _( + "Select between global or local (i.e. for this file) " + "execution parameters. You can set the latter below" + ), + icon=ima.icon('question_tip'), + hover_icon=ima.icon('question_tip_hover'), + size=23, + wrap_text=True + ) - self.executor_group = QGroupBox(_("Executor parameters")) - self.executor_group.setLayout(executor_layout) + executor_g_layout = QGridLayout() + executor_g_layout.addWidget(executor_label, 0, 0) + executor_g_layout.addWidget(self.executor_combo, 0, 1) + executor_g_layout.addWidget(executor_tip, 0, 2) + executor_g_layout.addWidget(parameters_label, 1, 0) + executor_g_layout.addWidget(self.parameters_combo, 1, 1) + executor_g_layout.addWidget(parameters_tip, 1, 2) + + executor_layout = QHBoxLayout() + executor_layout.addLayout(executor_g_layout) + executor_layout.addStretch() + + # --- Configuration properties + config_props_group = QGroupBox(_("Configuration properties")) + config_props_layout = QGridLayout(config_props_group) + + # Increase margin between title and line edit below so this looks good + config_props_margins = config_props_layout.contentsMargins() + config_props_margins.setTop(12) + config_props_layout.setContentsMargins(config_props_margins) + + # Name to save custom configuration + name_params_label = QLabel(_("Name:")) + self.name_params_text = QLineEdit(self) + self.name_params_text.setPlaceholderText( + _("Set a name for this configuration") + ) + name_params_tip = TipWidget( + _( + "You can set as many configurations as you want by providing " + "different names. Each one will be saved after clicking the " + "Ok button below" + ), + icon=ima.icon('question_tip'), + hover_icon=ima.icon('question_tip_hover'), + size=23, + wrap_text=True + ) - # --- Working directory --- - self.wdir_group = QGroupBox(_("Working directory settings")) - executor_layout.addWidget(self.wdir_group) + # This action needs to be added before setting an icon for it so that + # it doesn't show up in the line edit (despite being set as not visible + # below). That's probably a Qt bug. + status_action = QAction(self) + self.name_params_text.addAction( + status_action, QLineEdit.TrailingPosition + ) + self.name_params_text.status_action = status_action + + status_action.setIcon(ima.icon("error")) + status_action.setVisible(False) + + config_props_layout.addWidget(name_params_label, 0, 0) + config_props_layout.addWidget(self.name_params_text, 0, 1) + config_props_layout.addWidget(name_params_tip, 0, 2) + # --- Runner settings + self.stack = QStackedWidget(self) + + # --- Working directory settings + self.wdir_group = QGroupBox(_("Working directory settings")) wdir_layout = QVBoxLayout(self.wdir_group) self.file_dir_radio = QRadioButton(FILE_DIR) @@ -462,72 +673,88 @@ def setup(self): self.cwd_radio = QRadioButton(CW_DIR) wdir_layout.addWidget(self.cwd_radio) - fixed_dir_layout = QHBoxLayout() self.fixed_dir_radio = QRadioButton(FIXED_DIR) - fixed_dir_layout.addWidget(self.fixed_dir_radio) - self.wd_edit = QLineEdit() + self.wd_edit = QLineEdit(self) self.fixed_dir_radio.toggled.connect(self.wd_edit.setEnabled) self.wd_edit.setEnabled(False) - fixed_dir_layout.addWidget(self.wd_edit) - browse_btn = create_toolbutton( - self, - triggered=self.select_directory, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") + browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) + browse_btn.setToolTip(_("Select directory")) + browse_btn.clicked.connect(self.select_directory) + browse_btn.setIconSize( + QSize(AppStyle.ConfigPageIconSize, AppStyle.ConfigPageIconSize) ) + + fixed_dir_layout = QHBoxLayout() + fixed_dir_layout.addWidget(self.fixed_dir_radio) + fixed_dir_layout.addWidget(self.wd_edit) fixed_dir_layout.addWidget(browse_btn) wdir_layout.addLayout(fixed_dir_layout) - # --- Store new custom configuration - self.store_params_cb = QCheckBox(STORE_PARAMS) - self.store_params_text = QLineEdit() - store_params_layout = QHBoxLayout() - store_params_layout.addWidget(self.store_params_cb) - store_params_layout.addWidget(self.store_params_text) - executor_layout.addLayout(store_params_layout) - - self.store_params_cb.toggled.connect(self.store_params_text.setEnabled) - self.store_params_text.setPlaceholderText(_('My configuration name')) - self.store_params_text.setEnabled(False) - - self.firstrun_cb = QCheckBox(ALWAYS_OPEN_FIRST_RUN % _("this dialog")) + # --- Group all customization widgets into a collapsible one + custom_config = CollapsibleWidget(self, _("Custom configuration")) + custom_config.addWidget(config_props_group) + custom_config.addWidget(self.stack) + custom_config.addWidget(self.wdir_group) + + # Fix bottom and left margins. + custom_config.set_content_bottom_margin(0) + custom_config.set_content_right_margin(AppStyle.MarginSize) + + # Center dialog after custom_config is expanded/collapsed + custom_config._animation.finished.connect(self._center_dialog) + + # --- Final layout + layout = self.add_widgets( + self.header_label, + self.configuration_combo, # Hidden for simplicity + executor_layout, + custom_config, + (-2 if MAC else 1) * AppStyle.MarginSize, + ) + layout.setContentsMargins( + AppStyle.InnerContentPadding, + # This needs to be bigger to make the layout look better + AppStyle.InnerContentPadding + AppStyle.MarginSize, + # This makes the left and right padding be the same + AppStyle.InnerContentPadding + 4, + AppStyle.InnerContentPadding, + ) - layout = self.add_widgets(combo_label, self.configuration_combo, - executor_label, self.executor_combo, - 10, self.executor_group, self.firstrun_cb) + self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.delete_button = QPushButton(_("Delete")) + self.delete_button.clicked.connect(self.delete_btn_clicked) + self.bbox.addButton(self.delete_button, QDialogButtonBox.ActionRole) + # --- Settings self.executor_combo.currentIndexChanged.connect( self.display_executor_configuration) self.executor_combo.setModel(self.executors_model) + # This signal needs to be connected after + # executor_combo.currentIndexChanged and before + # configuration_combo.currentIndexChanged for parameters_combo to be + # updated as expected when opening the dialog. + self.parameters_combo.currentIndexChanged.connect( + self.update_parameter_set + ) + self.parameters_combo.setModel(self.parameter_model) + self.configuration_combo.currentIndexChanged.connect( self.update_configuration_run_index) self.configuration_combo.setModel(self.run_conf_model) self.configuration_combo.setCurrentIndex( self.run_conf_model.get_initial_index()) - - self.configuration_combo.setMaxVisibleItems(20) - self.configuration_combo.view().setVerticalScrollBarPolicy( - Qt.ScrollBarAsNeeded) + self.configuration_combo.setMaxVisibleItems(1) self.executor_combo.setMaxVisibleItems(20) self.executor_combo.view().setVerticalScrollBarPolicy( Qt.ScrollBarAsNeeded) - self.parameters_combo.currentIndexChanged.connect( - self.update_parameter_set) - self.parameters_combo.setModel(self.parameter_model) - - widget_dialog = QWidget() - widget_dialog.setMinimumWidth(600) - widget_dialog.setLayout(layout) - scroll_layout = QVBoxLayout(self) - scroll_layout.addWidget(widget_dialog) - self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.setWindowTitle(_("Run configuration per file")) self.layout().setSizeConstraint(QLayout.SetFixedSize) + self.setStyleSheet(self._stylesheet) + def select_directory(self): """Select directory""" basedir = str(self.wd_edit.text()) @@ -542,24 +769,38 @@ def update_configuration_run_index(self, index: int): self.executor_combo.setCurrentIndex(-1) self.run_conf_model.update_index(index) self.executor_combo.setCurrentIndex( - self.executors_model.get_initial_index()) + self.executors_model.get_initial_index() + ) def update_parameter_set(self, index: int): if index < 0: return - if self.index_to_select is not None: - index = self.index_to_select - self.index_to_select = None - self.parameters_combo.setCurrentIndex(index) + # Get parameters + stored_params = self.parameter_model.get_parameters(index) + global_params = stored_params["file_uuid"] is None - action, params = self.parameter_model.get_executor_parameters(index) - working_dir_params = params['working_dir'] - stored_parameters = params['executor_params'] + # Set parameters name + if global_params: + # We set this name for global params so users don't have to think + # about selecting one when customizing them + custom_name = self._get_auto_custom_name(stored_params["name"]) + self.name_params_text.setText(custom_name) + else: + # We show the actual name for file params + self.name_params_text.setText(stored_params["name"]) + + # Disable delete button for global configs + if global_params: + self.delete_button.setEnabled(False) + else: + self.delete_button.setEnabled(True) - if action == RunParameterFlags.SetDefaults: - stored_parameters = self.current_widget.get_default_configuration() - self.current_widget.set_configuration(stored_parameters) + # Set parameters in their corresponding graphical elements + params = stored_params["params"] + working_dir_params = params['working_dir'] + exec_params = params['executor_params'] + self.current_widget.set_configuration(exec_params) source = working_dir_params['source'] path = working_dir_params['path'] @@ -599,9 +840,9 @@ def display_executor_configuration(self, index: int): RunExecutorConfigurationGroup) if executor_info['configuration_widget'] is None: - self.executor_group.setVisible(False) + self.stack.setVisible(False) else: - self.executor_group.setVisible(True) + self.stack.setVisible(True) metadata = self.run_conf_model.get_selected_metadata() context = metadata['context'] @@ -615,23 +856,24 @@ def display_executor_configuration(self, index: int): if uuid not in self.run_conf_model: return - stored_param = self.run_conf_model.get_run_configuration_parameters( - uuid, executor_name) + stored_params = self.run_conf_model.get_run_configuration_parameters( + uuid, executor_name)['params'] - self.parameter_model.set_parameters(stored_param['params']) + # Only show global parameters (i.e. those with file_uuid = None) or + # those that correspond to the current file. + stored_params = { + k: v for (k, v) in stored_params.items() + if v.get("file_uuid") in [None, uuid] + } + self.parameter_model.set_parameters(stored_params) selected_params = self.run_conf_model.get_last_used_execution_params( uuid, executor_name) - all_selected_params = ( - self.run_conf_model.get_last_used_executor_parameters(uuid)) - re_open_dialog = all_selected_params['display_dialog'] - index = self.parameter_model.get_parameters_index(selected_params) - - if self.parameters_combo.count() == 0: - self.index_to_select = index + params_index = self.parameter_model.get_parameters_index_by_uuid( + selected_params + ) - self.parameters_combo.setCurrentIndex(index) - self.firstrun_cb.setChecked(re_open_dialog) + self.parameters_combo.setCurrentIndex(params_index) self.adjustSize() def select_executor(self, executor_name: str): @@ -639,21 +881,73 @@ def select_executor(self, executor_name: str): self.executors_model.get_run_executor_index(executor_name)) def reset_btn_clicked(self): - self.parameters_combo.setCurrentIndex(-1) - index = self.executor_combo.currentIndex() - self.display_executor_configuration(index) - self.store_params_text.setText('') - self.store_params_cb.setChecked(False) + self.parameters_combo.setCurrentIndex(0) def run_btn_clicked(self): self.status |= RunDialogStatus.Run self.accept() + def delete_btn_clicked(self): + answer = QMessageBox.question( + self, + _("Delete"), + _("Do you want to delete the current configuration?"), + ) + + if answer == QMessageBox.Yes: + # Get executor name + executor_name, __ = self.executors_model.get_selected_run_executor( + self.executor_combo.currentIndex() + ) + + # Get extension and context_id + metadata = self.run_conf_model.get_selected_metadata() + extension = metadata["input_extension"] + context_id = metadata["context"]["identifier"] + + # Get index associated with config + idx = self.parameters_combo.currentIndex() + + # Get config uuid + uuid, __ = self.parameter_model.get_parameters_uuid_name(idx) + + self.sig_delete_config_requested.emit( + executor_name, extension, context_id, uuid + ) + + # Close dialog to not have to deal with the difficult case of + # updating its contents after this config is deleted + self.reject() + + def get_configuration( + self + ) -> Tuple[str, str, ExtendedRunExecutionParameters, bool]: + + return self.saved_conf + + # ---- Qt methods + # ------------------------------------------------------------------------- def accept(self) -> None: self.status |= RunDialogStatus.Save + # Configuration to save/execute widget_conf = self.current_widget.get_configuration() + # Hide status action in case users fix the problem reported through it + # on a successive try + self.name_params_text.status_action.setVisible(False) + + # Detect if the current params are global + current_index = self.parameters_combo.currentIndex() + params = self.parameter_model.get_parameters(current_index) + global_params = params["file_uuid"] is None + + if global_params: + custom_name = self._get_auto_custom_name(params["name"]) + else: + custom_name = "" + + # Working directory params path = None source = None if self.file_dir_radio.isChecked(): @@ -666,38 +960,178 @@ def accept(self) -> None: cwd_opts = WorkingDirOpts(source=source, path=path) + # Execution params exec_params = RunExecutionParameters( - working_dir=cwd_opts, executor_params=widget_conf) - - uuid, name = self.parameter_model.get_parameters_uuid_name( - self.parameters_combo.currentIndex() + working_dir=cwd_opts, executor_params=widget_conf ) - if self.store_params_cb.isChecked(): + # Different validations for the params name + params_name = self.name_params_text.text() + if self.isVisible(): + allow_to_close = True + + if not params_name: + # Don't allow to save params without a name + self.name_params_text.status_action.setVisible(True) + self.name_params_text.status_action.setToolTip( + '\n'.join(textwrap.wrap(EMPTY_NAME, 50)) + ) + allow_to_close = False + elif global_params and params_name == custom_name: + # We don't need to perform a validation in this case because we + # set the params name on behalf of users + pass + elif params_name != self.parameters_combo.lineEdit().text(): + if params_name in self.parameter_model.get_parameter_names(): + # Don't allow to save params with the same name of an + # existing one because it doesn't make sense. + allow_to_close = False + self.name_params_text.status_action.setVisible(True) + self.name_params_text.status_action.setToolTip( + '\n'.join(textwrap.wrap(REPEATED_NAME, 50)) + ) + elif params["params"] == exec_params: + # Don't allow to save params that are exactly the same as + # the current ones. + allow_to_close = False + self.name_params_text.status_action.setVisible(True) + self.name_params_text.status_action.setToolTip( + '\n'.join(textwrap.wrap(SAME_PARAMETERS, 50)) + ) + + if not allow_to_close: + # With this the dialog can be closed when clicking the Cancel + # button + self.status = RunDialogStatus.Close + return + + # Get index associated with config + if params["params"] == exec_params: + # This avoids saving an unnecessary custom config when the current + # parameters haven't been modified with respect to the selected + # config + idx = current_index + else: + idx = self.parameter_model.get_parameters_index_by_name( + params_name + ) + + # Get uuid and name from index + if idx == -1: + # This means that there are no saved parameters for params_name, so + # we need to generate a new uuid for them. uuid = str(uuid4()) - name = self.store_params_text.text() - if name == '': - date_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - name = f'Configuration-{date_str}' + name = params_name + else: + # Retrieve uuid and name from our config system + uuid, name = self.parameter_model.get_parameters_uuid_name(idx) + + # Build configuration to be saved or executed + metadata_info = self.run_conf_model.get_metadata( + self.configuration_combo.currentIndex() + ) ext_exec_params = ExtendedRunExecutionParameters( - uuid=uuid, name=name, params=exec_params + uuid=uuid, + name=name, + params=exec_params, + file_uuid=None + if (global_params and idx >= 0) + else metadata_info["uuid"], + default=True + if (global_params and params["default"] and idx >= 0) + else False, ) - executor_name, _ = self.executors_model.get_selected_run_executor( + + executor_name, __ = self.executors_model.get_selected_run_executor( self.executor_combo.currentIndex() ) - metadata_info = self.run_conf_model.get_metadata( - self.configuration_combo.currentIndex() - ) - open_dialog = self.firstrun_cb.isChecked() + self.saved_conf = ( + metadata_info["uuid"], + executor_name, + ext_exec_params, + ) - self.saved_conf = (metadata_info['uuid'], executor_name, - ext_exec_params, open_dialog) return super().accept() - def get_configuration( - self - ) -> Tuple[str, str, ExtendedRunExecutionParameters, bool]: + def showEvent(self, event): + """Adjustments when the widget is shown.""" + if not self._is_shown: + # Set file name as the header + fname = self.configuration_combo.currentText() + header_font = ( + self.get_font(SpyderFontType.Interface, font_size_delta=1) + ) - return self.saved_conf + # Elide fname in case fname is too long + fm = QFontMetrics(header_font) + text = fm.elidedText( + fname, Qt.ElideLeft, self.header_label.width() + ) + + self.header_label.setFont(header_font) + self.header_label.setAlignment(Qt.AlignCenter) + self.header_label.setText(text) + if text != fname: + self.header_label.setToolTip(fname) + + self._is_shown = True + + super().showEvent(event) + + # ---- Private methods + # ------------------------------------------------------------------------- + @property + def _stylesheet(self): + # --- Style for the header + self._css["QLabel#run-header-label"].setValues( + # Add good enough margin with the widgets below it. + marginBottom=f"{3 * AppStyle.MarginSize}px", + # This is necessary to align the label to the widgets below it. + marginLeft="4px", + ) + + # --- Style for the collapsible + self._css["CollapsibleWidget"].setValues( + # Separate it from the widgets above it + marginTop=f"{3 * AppStyle.MarginSize}px" + ) + + return self._css.toString() + + def _center_dialog(self): + """ + Center dialog relative to the main window after collapsing/expanding + the custom configuration widget. + """ + # This doesn't work in our tests because the main window is usually + # not available in them. + if running_under_pytest(): + return + + qapp = qapplication() + main_window_pos = qapp.get_mainwindow_position() + main_window_height = qapp.get_mainwindow_height() + + # We only center the dialog vertically because there's no need to + # do it horizontally. + x = self.x() + y = main_window_pos.y() + ((main_window_height - self.height()) // 2) + + self.move(x, y) + + def _get_auto_custom_name(self, global_params_name: str) -> str: + """ + Get an auto-generated custom name given the a global parameters one. + """ + n_custom = self.parameter_model.get_number_of_custom_params( + global_params_name + ) + + return ( + global_params_name + + " (" + + _("custom") + + (")" if n_custom == 0 else f" {n_custom})") + ) diff --git a/spyder/plugins/shortcuts/widgets/table.py b/spyder/plugins/shortcuts/widgets/table.py index 3b456269a66..6bfde1fa462 100644 --- a/spyder/plugins/shortcuts/widgets/table.py +++ b/spyder/plugins/shortcuts/widgets/table.py @@ -633,7 +633,7 @@ def reset(self): class ShortcutsTable(HoverRowsTableView): def __init__(self, parent=None): - HoverRowsTableView.__init__(self, parent) + HoverRowsTableView.__init__(self, parent, custom_delegate=True) self._parent = parent self.finder = None self.shortcut_data = None @@ -658,6 +658,7 @@ def __init__(self, parent=None): self.verticalHeader().hide() + # To highlight the entire row on hover self.sig_hover_index_changed.connect( self.itemDelegate().on_hover_index_changed ) diff --git a/spyder/utils/icon_manager.py b/spyder/utils/icon_manager.py index 39e3988c919..5728681ef83 100644 --- a/spyder/utils/icon_manager.py +++ b/spyder/utils/icon_manager.py @@ -49,8 +49,7 @@ def __init__(self): self.ICONS_BY_EXTENSION = {} - # Magnification factors for attribute icons - # per platform + # Magnification factors for attribute icons per platform if sys.platform.startswith('linux'): self.BIG_ATTR_FACTOR = 1.0 self.SMALL_ATTR_FACTOR = 0.9 @@ -378,6 +377,9 @@ def __init__(self): # --- Remote connections ---------------------------------------------- 'add_server': [('mdi.server-plus',), {'color': self.MAIN_FG_COLOR}], 'remote_server': [('mdi.server-network',), {'color': self.MAIN_FG_COLOR}], + # --- For our collapsed widget + 'collapsed': [('mdi.chevron-right',), {'color': self.MAIN_FG_COLOR, 'scale_factor': 1.3}], + 'expanded': [('mdi.chevron-down',), {'color': self.MAIN_FG_COLOR, 'scale_factor': 1.3}], } def get_std_icon(self, name, size=None): diff --git a/spyder/utils/qthelpers.py b/spyder/utils/qthelpers.py index f78b8b2850e..6788cdf53a4 100644 --- a/spyder/utils/qthelpers.py +++ b/spyder/utils/qthelpers.py @@ -21,14 +21,37 @@ # Third party imports from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer, - QTranslator, QUrl, Signal, Slot) +from qtpy.QtCore import ( + QEvent, + QLibraryInfo, + QLocale, + QObject, + QPoint, + Qt, + QTimer, + QTranslator, + QUrl, + Signal, + Slot, +) from qtpy.QtGui import ( QDesktopServices, QFontMetrics, QKeyEvent, QKeySequence, QPixmap) -from qtpy.QtWidgets import (QAction, QApplication, QDialog, QHBoxLayout, - QLabel, QLineEdit, QMenu, QPlainTextEdit, - QPushButton, QStyle, QToolButton, QVBoxLayout, - QWidget) +from qtpy.QtWidgets import ( + QAction, + QApplication, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMenu, + QPlainTextEdit, + QPushButton, + QStyle, + QToolButton, + QVBoxLayout, + QWidget, +) # Local imports from spyder.api.config.fonts import SpyderFontsMixin, SpyderFontType @@ -762,6 +785,9 @@ def __init__(self, *args): self._pending_file_open = [] self._original_handlers = {} + # This is filled at startup in spyder.app.utils.create_window + self._main_window: QMainWindow = None + def event(self, event): if sys.platform == 'darwin' and event.type() == QEvent.FileOpen: @@ -835,6 +861,18 @@ def set_monospace_interface_font(self, app_font): self.set_conf('monospace_app_font/size', monospace_size, section='appearance') + def get_mainwindow_position(self) -> QPoint: + """Get main window position.""" + return self._main_window.pos() + + def get_mainwindow_width(self) -> int: + """Get main window width.""" + return self._main_window.width() + + def get_mainwindow_height(self) -> int: + """Get main window height.""" + return self._main_window.height() + def restore_launchservices(): """Restore LaunchServices to the previous state""" diff --git a/spyder/utils/stylesheet.py b/spyder/utils/stylesheet.py index d551ad4dd5b..930ad28d15d 100644 --- a/spyder/utils/stylesheet.py +++ b/spyder/utils/stylesheet.py @@ -66,6 +66,9 @@ def ComboBoxMinHeight(cls): return min_height + # Padding for content inside an element of higher hierarchy + InnerContentPadding = 5 * MarginSize + # ============================================================================= # ---- Base stylesheet class @@ -270,14 +273,16 @@ def _customize_stylesheet(self): minHeight=f'{AppStyle.ComboBoxMinHeight - 0.25}em' ) - # Change QGroupBox style to avoid the "boxes within boxes" antipattern - # in Preferences + # Remove border in QGroupBox to avoid the "boxes within boxes" + # antipattern. Also, increase its title font in one point to make it + # more relevant. css.QGroupBox.setValues( border='0px', - marginBottom='15px', fontSize=f'{font_size + 1}pt', ) + # Increase separation between title and content of QGroupBoxes and fix + # its alignment. css['QGroupBox::title'].setValues( paddingTop='-0.3em', left='0px', @@ -309,11 +314,6 @@ def _customize_stylesheet(self): padding="1px 2px", ) - # Substract extra padding that comes from QLineEdit - css["QLineEdit QToolTip"].setValues( - padding="-2px -3px", - ) - # Add padding to tree widget items to make them look better css["QTreeWidget::item"].setValues( padding=f"{AppStyle.MarginSize - 1}px 0px", @@ -713,7 +713,7 @@ def set_stylesheet(self): # Remove border and add padding for content inside tabs css['QTabWidget::pane'].setValues( border='0px', - padding='15px', + padding=f'{AppStyle.InnerContentPadding}px', ) diff --git a/spyder/widgets/collapsible.py b/spyder/widgets/collapsible.py new file mode 100644 index 00000000000..2ed377421c9 --- /dev/null +++ b/spyder/widgets/collapsible.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Collapsible widget to hide and show child widgets.""" + +import qstylizer.style +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QPushButton +from superqt import QCollapsible + +from spyder.utils.icon_manager import ima +from spyder.utils.palette import SpyderPalette +from spyder.utils.stylesheet import AppStyle + + +class CollapsibleWidget(QCollapsible): + """Collapsible widget to hide and show child widgets.""" + + def __init__(self, parent=None, title=""): + super().__init__(title=title, parent=parent) + + # Align widget to the left to text before or after it (don't know why + # this is necessary). + self.layout().setContentsMargins(5, 0, 0, 0) + + # Remove spacing between toggle button and contents area + self.layout().setSpacing(0) + + # Set icons + self.setCollapsedIcon(ima.icon("collapsed")) + self.setExpandedIcon(ima.icon("expanded")) + + # To change the style only of these widgets + self._toggle_btn.setObjectName("collapsible-toggle") + self.content().setObjectName("collapsible-content") + + # Add padding to the inside content + self.content().layout().setContentsMargins( + *((AppStyle.InnerContentPadding,) * 4) + ) + + # Set stylesheet + self._css = self._generate_stylesheet() + self.setStyleSheet(self._css.toString()) + + # Signals + self.toggled.connect(self._on_toggled) + + # Set our properties for the toggle button + self._set_toggle_btn_properties() + + def set_content_bottom_margin(self, bottom_margin): + """Set bottom margin of the content area to `bottom_margin`.""" + margins = self.content().layout().contentsMargins() + margins.setBottom(bottom_margin) + self.content().layout().setContentsMargins(margins) + + def set_content_right_margin(self, right_margin): + """Set right margin of the content area to `right_margin`.""" + margins = self.content().layout().contentsMargins() + margins.setRight(right_margin) + self.content().layout().setContentsMargins(margins) + + def _generate_stylesheet(self): + """Generate base stylesheet for this widget.""" + css = qstylizer.style.StyleSheet() + + # --- Style for the header button + css["QPushButton#collapsible-toggle"].setValues( + # Increase padding (the default one is too small). + padding=f"{2 * AppStyle.MarginSize}px", + # Make it a bit different from a default QPushButton to not drag + # the same amount of attention to it. + backgroundColor=SpyderPalette.COLOR_BACKGROUND_3 + ) + + # Make hover color match the change of background color above + css["QPushButton#collapsible-toggle:hover"].setValues( + backgroundColor=SpyderPalette.COLOR_BACKGROUND_4, + ) + + # --- Style for the contents area + css["QWidget#collapsible-content"].setValues( + # Remove top border to make it appear attached to the header button + borderTop="0px", + # Add border to the other edges + border=f'1px solid {SpyderPalette.COLOR_BACKGROUND_4}', + # Add border radius to the bottom to make it match the style of our + # other widgets. + borderBottomLeftRadius=f'{SpyderPalette.SIZE_BORDER_RADIUS}', + borderBottomRightRadius=f'{SpyderPalette.SIZE_BORDER_RADIUS}', + ) + + return css + + def _on_toggled(self, state): + """Adjustments when the button is toggled.""" + if state: + # Remove bottom rounded borders from the header when the widget is + # expanded. + self._css["QPushButton#collapsible-toggle"].setValues( + borderBottomLeftRadius='0px', + borderBottomRightRadius='0px', + ) + else: + # Restore bottom rounded borders to the header when the widget is + # collapsed. + self._css["QPushButton#collapsible-toggle"].setValues( + borderBottomLeftRadius=f'{SpyderPalette.SIZE_BORDER_RADIUS}', + borderBottomRightRadius=f'{SpyderPalette.SIZE_BORDER_RADIUS}', + ) + + self.setStyleSheet(self._css.toString()) + + def _set_toggle_btn_properties(self): + """Set properties for the toogle button.""" + + def enter_event(event): + self.setCursor(Qt.PointingHandCursor) + super(QPushButton, self._toggle_btn).enterEvent(event) + + def leave_event(event): + self.setCursor(Qt.ArrowCursor) + super(QPushButton, self._toggle_btn).leaveEvent(event) + + self.toggleButton().enterEvent = enter_event + self.toggleButton().leaveEvent = leave_event diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index fbba71d0610..1c98b677001 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -2192,7 +2192,7 @@ def __init__(self): 'ddataframe': test_df, 'None': None, 'unsupported1': np.arccos, - 'unsupported2': np.cast, + 'unsupported2': np.asarray, # Test for spyder-ide/spyder#3518. 'big_struct_array': np.zeros(1000, dtype=[('ID', 'f8'), ('param1', 'f8', 5000)]), diff --git a/spyder/widgets/config.py b/spyder/widgets/config.py index 86678dc7681..68bb6e8ca24 100644 --- a/spyder/widgets/config.py +++ b/spyder/widgets/config.py @@ -1041,12 +1041,33 @@ def create_fontgroup(self, option=None, text=None, title=None, return widget - def create_button(self, text, callback): - btn = QPushButton(text) + def create_button( + self, + callback, + text=None, + icon=None, + tooltip=None, + set_modified_on_click=False, + ): + if icon is not None: + btn = QPushButton(icon, "", parent=self) + btn.setIconSize( + QSize(AppStyle.ConfigPageIconSize, AppStyle.ConfigPageIconSize) + ) + else: + btn = QPushButton(text, parent=self) + btn.clicked.connect(callback) - btn.clicked.connect( - lambda checked=False, opt='': self.has_been_modified( - self.CONF_SECTION, opt)) + if tooltip is not None: + btn.setToolTip(tooltip) + + if set_modified_on_click: + btn.clicked.connect( + lambda checked=False, opt="": self.has_been_modified( + self.CONF_SECTION, opt + ) + ) + return btn def create_tab(self, name, widgets): diff --git a/spyder/widgets/elementstable.py b/spyder/widgets/elementstable.py index 8a01dcda146..39c8aa68624 100644 --- a/spyder/widgets/elementstable.py +++ b/spyder/widgets/elementstable.py @@ -154,7 +154,7 @@ def get_info_repr(self, element: Element) -> str: class ElementsTable(HoverRowsTableView): def __init__(self, parent: Optional[QWidget], elements: List[Element]): - HoverRowsTableView.__init__(self, parent) + HoverRowsTableView.__init__(self, parent, custom_delegate=True) self.elements = elements # Check for additional features diff --git a/spyder/widgets/helperwidgets.py b/spyder/widgets/helperwidgets.py index a943a145e83..6bcca5774f1 100644 --- a/spyder/widgets/helperwidgets.py +++ b/spyder/widgets/helperwidgets.py @@ -56,6 +56,7 @@ QHBoxLayout, QLabel, QFrame, + QItemDelegate, ) # Local imports @@ -731,14 +732,7 @@ def _stop_spinner(self): class HoverRowsTableView(QTableView): - """ - QTableView subclass that can highlight an entire row when hovered. - - Notes - ----- - * Classes that inherit from this one need to connect a slot to - sig_hover_index_changed that handles how the row is painted. - """ + """QTableView subclass that can highlight an entire row when hovered.""" sig_hover_index_changed = Signal(object) """ @@ -750,7 +744,7 @@ class HoverRowsTableView(QTableView): QModelIndex that has changed on hover. """ - def __init__(self, parent): + def __init__(self, parent, custom_delegate=False): QTableView.__init__(self, parent) # For mouseMoveEvent @@ -760,10 +754,13 @@ def __init__(self, parent): # over the widget. css = qstylizer.style.StyleSheet() css["QTableView::item"].setValues( - backgroundColor=f"{SpyderPalette.COLOR_BACKGROUND_1}" + backgroundColor=SpyderPalette.COLOR_BACKGROUND_1 ) self._stylesheet = css.toString() + if not custom_delegate: + self._set_delegate() + # ---- Qt methods def mouseMoveEvent(self, event): self._inform_hover_index_changed(event) @@ -787,6 +784,36 @@ def _inform_hover_index_changed(self, event): self.sig_hover_index_changed.emit(index) self.viewport().update() + def _set_delegate(self): + """ + Set a custom item delegate that can highlight the current row when + hovered. + """ + + class HoverRowDelegate(QItemDelegate): + + def __init__(self, parent): + super().__init__(parent) + self._hovered_row = -1 + + def on_hover_index_changed(self, index): + self._hovered_row = index.row() + + def paint(self, painter, option, index): + # This paints the entire row associated to the delegate when + # it's hovered. + if index.row() == self._hovered_row: + painter.fillRect( + option.rect, QColor(SpyderPalette.COLOR_BACKGROUND_3) + ) + + super().paint(painter, option, index) + + self.setItemDelegate(HoverRowDelegate(self)) + self.sig_hover_index_changed.connect( + self.itemDelegate().on_hover_index_changed + ) + class TipWidget(QLabel): """Icon widget to show information as a tooltip when clicked.""" diff --git a/spyder/widgets/sidebardialog.py b/spyder/widgets/sidebardialog.py index 8b2e6567f27..1da9af01cb8 100644 --- a/spyder/widgets/sidebardialog.py +++ b/spyder/widgets/sidebardialog.py @@ -477,6 +477,21 @@ def _main_stylesheet(self): border='0px', ) + # Add more spacing between QGroupBoxes than normal. + css.QGroupBox.setValues( + marginBottom='15px', + ) + + # Substract extra padding + css["QToolTip"].setValues( + paddingRight="-2px", + ) + + # Substract extra padding that comes from QLineEdit + css["QLineEdit QToolTip"].setValues( + padding="-2px -3px", + ) + return css.toString() def _generate_contents_stylesheet(self): diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py index 18d8defa30a..8a65b279e93 100644 --- a/spyder/widgets/tests/test_collectioneditor.py +++ b/spyder/widgets/tests/test_collectioneditor.py @@ -20,6 +20,7 @@ # Third party imports import numpy +from packaging.version import parse import pandas import pytest from flaky import flaky @@ -351,6 +352,9 @@ def test_shows_dataframeeditor_when_editing_index(monkeypatch): def test_sort_numpy_numeric_collectionsmodel(): + if parse(numpy.__version__) >= parse("2.0.0"): + numpy.set_printoptions(legacy="1.25") + var_list = [ numpy.float64(1e16), numpy.float64(10), numpy.float64(1), numpy.float64(0.1), numpy.float64(1e-6),