def test_deleted_on_close(self): mock_mfp = MockMultiFileInterpreter() view = CodeEditorTabWidget(mock_mfp) self.assert_widget_created() view.close() QApplication.processEvents() self.assert_widget_not_present(CodeEditorTabWidget.__name__) # closing our local mock should leave the QApplication without any widgets mock_mfp.close() QApplication.processEvents() self.assert_no_toplevel_widgets()
def __init__(self, font=None, default_content=None, parent=None): """ :param font: An optional font to override the default editor font :param default_content: str, if provided this will populate any new editor that is created :param parent: An optional parent widget """ super(MultiPythonFileInterpreter, self).__init__(parent) # attributes self.default_content = default_content self.default_font = font self.prev_session_tabs = None self.whitespace_visible = False self.setAttribute(Qt.WA_DeleteOnClose, True) # widget setup layout = QVBoxLayout(self) self._tabs = CodeEditorTabWidget(self) layout.addWidget(self._tabs) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) # add a single editor by default self.append_new_editor() # setting defaults self.confirm_on_save = True
def test_widget_connections_exist(self): mock_mfp = MockMultiFileInterpreter() view = CodeEditorTabWidget(mock_mfp) self.assert_widget_created() self.assert_object_connected_once(view, view.SHOW_IN_EXPLORER_ACTION_OBJECT_NAME, QAction, "triggered") self.assert_object_connected_once(view, view.ABORT_BUTTON_OBJECT_NAME) self.assert_object_connected_once(view, view.NEW_EDITOR_PLUS_BTN_OBJECT_NAME) self.assert_object_connected_once(view, view.RUN_BUTTON_OBJECT_NAME) # options button is not connected because it uses the internal Qt triggers # to show the popup menu self.assert_object_not_connected(view, view.OPTIONS_BUTTON_OBJECT_NAME) view.close() QApplication.processEvents() self.assert_widget_not_present(CodeEditorTabWidget.__name__) # closing our local mock should leave the QApplication without any widgets mock_mfp.close() QApplication.processEvents() self.assert_no_toplevel_widgets()
class MultiPythonFileInterpreter(QWidget): """Provides a tabbed widget for editing multiple files""" def __init__(self, font=None, default_content=None, parent=None): """ :param font: An optional font to override the default editor font :param default_content: str, if provided this will populate any new editor that is created :param parent: An optional parent widget """ super(MultiPythonFileInterpreter, self).__init__(parent) # attributes self.default_content = default_content self.default_font = font self.prev_session_tabs = None self.whitespace_visible = False self.setAttribute(Qt.WA_DeleteOnClose, True) # widget setup layout = QVBoxLayout(self) self._tabs = CodeEditorTabWidget(self) layout.addWidget(self._tabs) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) # add a single editor by default self.append_new_editor() # setting defaults self.confirm_on_save = True def closeEvent(self, event): self.deleteLater() super(MultiPythonFileInterpreter, self).closeEvent(event) def load_settings_from_config(self, config): self.confirm_on_save = config.get('project', 'prompt_save_editor_modified') @property def editor_count(self): return self._tabs.count() @property def tab_filepaths(self): file_paths = [] for idx in range(self.editor_count): file_path = self._tabs.widget(idx).filename if file_path: file_paths.append(file_path) return file_paths def append_new_editor(self, font=None, content=None, filename=None): """ Appends a new editor the tabbed widget :param font: A reference to the font to be used by the editor. If None is given then self.default_font is used :param content: An optional string containing content to be placed into the editor on opening. If None then self.default_content is used :param filename: An optional string containing the filename of the editor if applicable. :return: """ if content is None: content = self.default_content if font is None: font = self.default_font interpreter = PythonFileInterpreter(font, content, filename=filename, parent=self) if self.whitespace_visible: interpreter.set_whitespace_visible() # monitor future modifications interpreter.sig_editor_modified.connect(self.mark_current_tab_modified) interpreter.sig_filename_modified.connect(self.on_filename_modified) tab_title, tab_tooltip = _tab_title_and_toolip(filename) tab_idx = self._tabs.addTab(interpreter, tab_title) self._tabs.setTabToolTip(tab_idx, tab_tooltip) self._tabs.setCurrentIndex(tab_idx) return tab_idx def abort_current(self): """Request that that the current execution be cancelled""" self.current_editor().abort() @Slot() def abort_all(self): """Request that all executing tabs are cancelled""" for ii in range(0, len(self._tabs)): editor = self.editor_at(ii) editor.abort() def close_all(self): """ Close all tabs :return: True if all tabs are closed, False if cancelled """ for idx in reversed(range(self.editor_count)): if not self.close_tab(idx, allow_zero_tabs=True): return False return True def close_tab(self, idx, allow_zero_tabs=False): """ Close the tab at the given index. :param idx: The tab index :param allow_zero_tabs: If True then closing the last tab does not add a new empty tab. :return: True if tab is to be closed, False if cancelled """ if idx >= self.editor_count: return True # Make the current tab active so that it is clear what you # are being prompted to save self._tabs.setCurrentIndex(idx) if self.current_editor().confirm_close(): widget = self._tabs.widget(idx) # note: this does not close the widget, that is why we manually close it self._tabs.removeTab(idx) widget.close() else: return False if (not allow_zero_tabs) and self.editor_count == 0: self.append_new_editor() return True def current_editor(self): return self._tabs.currentWidget() def editor_at(self, idx): """Return the editor at the given index. Must be in range""" return self._tabs.widget(idx) def execute_current_async(self): """ Execute content of the current file. If a selection is active then only this portion of code is executed, this is completed asynchronously """ return self.current_editor().execute_async() def execute_async(self): """ Execute ALL the content in the current file. Selection is ignored. This is completed asynchronously. """ return self.current_editor().execute_async(ignore_selection=True) @Slot() def execute_current_async_blocking(self): """Execute content of the current file. If a selection is active then only this portion of code is executed, completed asynchronously which blocks calling thread. """ self.current_editor().execute_async_blocking() def mark_current_tab_modified(self, modified): """Update the current tab title to indicate that the content has been modified""" self.mark_tab_modified(self._tabs.currentIndex(), modified) def mark_tab_modified(self, idx, modified): """Update the tab title to indicate that the content has been modified or not""" title_cur = self._tabs.tabText(idx) if modified: if not title_cur.endswith(MODIFIED_MARKER): title_new = title_cur + MODIFIED_MARKER else: title_new = title_cur else: if title_cur.endswith(MODIFIED_MARKER): title_new = title_cur.rstrip('*') else: title_new = title_cur self._tabs.setTabText(idx, title_new) def on_filename_modified(self, filename): title, tooltip = _tab_title_and_toolip(filename) idx_cur = self._tabs.currentIndex() self._tabs.setTabText(idx_cur, title) self._tabs.setTabToolTip(idx_cur, tooltip) @Slot(str) def open_file_in_new_tab(self, filepath, startup=False): """Open the existing file in a new tab in the editor :param filepath: A path to an existing file :param startup: Flag for if function is being called on startup """ with open(filepath, 'r') as code_file: content = code_file.read() self.append_new_editor(content=content, filename=filepath) if startup is False and mantid_api_import_needed(content) is True: add_mantid_api_import(self.current_editor().editor, content) def open_files_in_new_tabs(self, filepaths): for filepath in filepaths: self.open_file_in_new_tab(filepath) def plus_button_clicked(self, _): """Add a new tab when the plus button is clicked""" self.append_new_editor() def restore_session_tabs(self): if self.prev_session_tabs is not None: try: self.open_files_in_new_tabs(self.prev_session_tabs) except IOError: pass self.close_tab(0) # close default empty script def save_current_file(self): """Save the current file""" self.current_editor().save(force_save=True) def save_current_file_as(self): saved, filename = self.current_editor().save_as() if saved: self.current_editor().close() self.open_file_in_new_tab(filename) def spaces_to_tabs_current(self): self.current_editor().replace_spaces_with_tabs() def tabs_to_spaces_current(self): self.current_editor().replace_tabs_with_spaces() def toggle_comment_current(self): self.current_editor().toggle_comment() def toggle_find_replace_dialog(self): self.current_editor().show_find_replace_dialog() def toggle_whitespace_visible_all(self): if self.whitespace_visible: for idx in range(self.editor_count): self.editor_at(idx).set_whitespace_invisible() self.whitespace_visible = False else: for idx in range(self.editor_count): self.editor_at(idx).set_whitespace_visible() self.whitespace_visible = True
class MultiPythonFileInterpreter(QWidget): """Provides a tabbed widget for editing multiple files""" sig_code_exec_start = Signal(str) sig_file_name_changed = Signal(str, str) sig_current_tab_changed = Signal(str) def __init__(self, font=None, default_content=None, parent=None): """ :param font: An optional font to override the default editor font :param default_content: str, if provided this will populate any new editor that is created :param parent: An optional parent widget """ super(MultiPythonFileInterpreter, self).__init__(parent) # attributes self.default_content = default_content self.default_font = font self.prev_session_tabs = None self.whitespace_visible = False self.setAttribute(Qt.WA_DeleteOnClose, True) # widget setup layout = QVBoxLayout(self) self._tabs = CodeEditorTabWidget(self) self._tabs.currentChanged.connect(self._emit_current_tab_changed) layout.addWidget(self._tabs) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) self.zoom_level = 0 # add a single editor by default self.append_new_editor() # setting defaults self.confirm_on_save = True def _tab_title_and_tooltip(self, filename): """Create labels for the tab title and tooltip from a filename""" if filename is None: title = NEW_TAB_TITLE i = 1 while title in self.stripped_tab_titles: title = "{} ({})".format(NEW_TAB_TITLE, i) i += 1 return title, title else: return osp.basename(filename), filename @property def stripped_tab_titles(self): tab_text = [self._tabs.tabText(i) for i in range(self.editor_count)] tab_text = [txt.rstrip('*') for txt in tab_text] # Some DEs (such as KDE) will automatically assign keyboard shortcuts using the Qt & annotation # see Qt Docs - qtabwidget#addTab tab_text = [txt.replace('&', '') for txt in tab_text] return tab_text def closeEvent(self, event): self.deleteLater() super(MultiPythonFileInterpreter, self).closeEvent(event) def load_settings_from_config(self, config): self.confirm_on_save = config.get('project', 'prompt_save_editor_modified') @property def editor_count(self): return self._tabs.count() @property def tab_filepaths(self): file_paths = [] for idx in range(self.editor_count): file_path = self.editor_at(idx).filename if file_path: file_paths.append(file_path) return file_paths def append_new_editor(self, font=None, content=None, filename=None): """ Appends a new editor the tabbed widget :param font: A reference to the font to be used by the editor. If None is given then self.default_font is used :param content: An optional string containing content to be placed into the editor on opening. If None then self.default_content is used :param filename: An optional string containing the filename of the editor if applicable. :return: """ if content is None: content = self.default_content if font is None: font = self.default_font if self.editor_count > 0: # If there are other tabs open the same zoom level # as these is used. current_zoom = self._tabs.widget(0).editor.getZoom() else: # Otherwise the zoom level of the last tab closed is used # Or the default (0) if this is the very first tab current_zoom = self.zoom_level interpreter = PythonFileInterpreter(font, content, filename=filename, parent=self) interpreter.editor.zoomTo(current_zoom) if self.whitespace_visible: interpreter.set_whitespace_visible() # monitor future modifications interpreter.sig_editor_modified.connect(self.mark_current_tab_modified) interpreter.sig_filename_modified.connect(self.on_filename_modified) interpreter.editor.textZoomedIn.connect(self.zoom_in_all_tabs) interpreter.editor.textZoomedOut.connect(self.zoom_out_all_tabs) tab_title, tab_tooltip = self._tab_title_and_tooltip(filename) tab_idx = self._tabs.addTab(interpreter, tab_title) self._tabs.setTabToolTip(tab_idx, tab_tooltip) self._tabs.setCurrentIndex(tab_idx) return tab_idx def abort_current(self): """Request that that the current execution be cancelled""" self.current_editor().abort() @Slot() def abort_all(self): """Request that all executing tabs are cancelled""" for ii in range(0, len(self._tabs)): editor = self.editor_at(ii) editor.abort() def close_all(self): """ Close all tabs :return: True if all tabs are closed, False if cancelled """ for idx in reversed(range(self.editor_count)): if not self.close_tab(idx, allow_zero_tabs=True): return False return True def close_tab(self, idx, allow_zero_tabs=False): """ Close the tab at the given index. :param idx: The tab index :param allow_zero_tabs: If True then closing the last tab does not add a new empty tab. :return: True if tab is to be closed, False if cancelled """ if idx >= self.editor_count: return True # Make the current tab active so that it is clear what you # are being prompted to save self._tabs.setCurrentIndex(idx) if self.current_editor().confirm_close(): # If the last editor tab is being closed, its zoom level # is saved for the new tab which opens automatically. if self.editor_count == 1: self.zoom_level = self.current_editor().editor.getZoom() widget = self.editor_at(idx) # note: this does not close the widget, that is why we manually close it self._tabs.removeTab(idx) widget.close() else: return False if (not allow_zero_tabs) and self.editor_count == 0: self.append_new_editor() return True def current_editor(self): return self._tabs.currentWidget() def editor_at(self, idx): """Return the editor at the given index. Must be in range""" return self._tabs.widget(idx) def _emit_current_tab_changed(self, index): if index == -1: self.sig_current_tab_changed.emit("") else: self.sig_current_tab_changed.emit(self.current_tab_filename) def _emit_code_exec_start(self): """Emit signal that code execution has started""" if not self.current_editor().filename: filename = self._tabs.tabText( self._tabs.currentIndex()).rstrip('*') self.sig_code_exec_start.emit(filename) else: self.sig_code_exec_start.emit(self.current_editor().filename) @property def current_tab_filename(self): if not self.current_editor().filename: return self._tabs.tabText(self._tabs.currentIndex()).rstrip('*') return self.current_editor().filename def execute_current_async(self): """ Execute content of the current file. If a selection is active then only this portion of code is executed, this is completed asynchronously """ self._emit_code_exec_start() return self.current_editor().execute_async() def execute_async(self): """ Execute ALL the content in the current file. Selection is ignored. This is completed asynchronously. """ self._emit_code_exec_start() return self.current_editor().execute_async(ignore_selection=True) @Slot() def execute_current_async_blocking(self): """ Execute content of the current file. If a selection is active then only this portion of code is executed, completed asynchronously which blocks calling thread. """ self._emit_code_exec_start() self.current_editor().execute_async_blocking() def mark_current_tab_modified(self, modified): """Update the current tab title to indicate that the content has been modified""" self.mark_tab_modified(self._tabs.currentIndex(), modified) def mark_tab_modified(self, idx, modified): """Update the tab title to indicate that the content has been modified or not""" title_cur = self._tabs.tabText(idx) if modified: if not title_cur.endswith(MODIFIED_MARKER): title_new = title_cur + MODIFIED_MARKER else: title_new = title_cur else: if title_cur.endswith(MODIFIED_MARKER): title_new = title_cur.rstrip('*') else: title_new = title_cur self._tabs.setTabText(idx, title_new) def on_filename_modified(self, filename): old_filename = self._tabs.tabToolTip( self._tabs.currentIndex()).rstrip('*') if not filename: filename = self._tabs.tabText( self._tabs.currentIndex()).rstrip('*') self.sig_file_name_changed.emit(old_filename, filename) title, tooltip = self._tab_title_and_tooltip(filename) idx_cur = self._tabs.currentIndex() self._tabs.setTabText(idx_cur, title) self._tabs.setTabToolTip(idx_cur, tooltip) @Slot(str) def open_file_in_new_tab(self, filepath, startup=False): """Open the existing file in a new tab in the editor :param filepath: A path to an existing file :param startup: Flag for if function is being called on startup """ with open(filepath, 'r') as code_file: content = code_file.read() self.append_new_editor(content=content, filename=filepath) if startup is False and mantid_api_import_needed(content) is True: add_mantid_api_import(self.current_editor().editor, content) def open_files_in_new_tabs(self, filepaths): for filepath in filepaths: self.open_file_in_new_tab(filepath) def plus_button_clicked(self, _): """Add a new tab when the plus button is clicked""" self.append_new_editor() def restore_session_tabs(self): if self.prev_session_tabs is not None: try: self.open_files_in_new_tabs(self.prev_session_tabs) except IOError: pass self.close_tab(0) # close default empty script def save_current_file(self): """Save the current file""" self.current_editor().save(force_save=True) def save_current_file_as(self): previous_filename = self.current_editor().filename saved, filename = self.current_editor().save_as() if saved: self.current_editor().close() self.open_file_in_new_tab(filename) if previous_filename: self.sig_file_name_changed.emit(previous_filename, filename) def spaces_to_tabs_current(self): self.current_editor().replace_spaces_with_tabs() def tabs_to_spaces_current(self): self.current_editor().replace_tabs_with_spaces() def toggle_comment_current(self): self.current_editor().toggle_comment() def toggle_find_replace_dialog(self): self.current_editor().show_find_replace_dialog() def toggle_whitespace_visible_all(self): if self.whitespace_visible: for idx in range(self.editor_count): self.editor_at(idx).set_whitespace_invisible() self.whitespace_visible = False else: for idx in range(self.editor_count): self.editor_at(idx).set_whitespace_visible() self.whitespace_visible = True def zoom_in_all_tabs(self): current_tab_index = self._tabs.currentIndex() for i in range(self.editor_count): if i == current_tab_index: continue self.editor_at(i).editor.zoomIn() def zoom_out_all_tabs(self): current_tab_index = self._tabs.currentIndex() for i in range(self.editor_count): if i == current_tab_index: continue self.editor_at(i).editor.zoomOut()