diff --git a/pyqode/core/api/code_edit.py b/pyqode/core/api/code_edit.py index 3cb8a794..1034155a 100644 --- a/pyqode/core/api/code_edit.py +++ b/pyqode/core/api/code_edit.py @@ -183,7 +183,8 @@ def font_name(self, value): if value == "": value = self._DEFAULT_FONT self._font_family = value - self._reset_stylesheet() + if self._auto_reset_stylesheet: + self._reset_stylesheet() for c in self.clones: c.font_name = value @@ -217,7 +218,8 @@ def font_size(self): @font_size.setter def font_size(self, value): self._font_size = value - self._reset_stylesheet() + if self._auto_reset_stylesheet: + self._reset_stylesheet() for c in self.clones: c.font_size = value @@ -231,7 +233,8 @@ def background(self): @background.setter def background(self, value): self._background = value - self._reset_stylesheet() + if self._auto_reset_stylesheet: + self._reset_stylesheet() for c in self.clones: c.background = value @@ -245,7 +248,8 @@ def foreground(self): @foreground.setter def foreground(self, value): self._foreground = value - self._reset_stylesheet() + if self._auto_reset_stylesheet: + self._reset_stylesheet() for c in self.clones: c.foreground = value @@ -275,7 +279,8 @@ def selection_background(self): @selection_background.setter def selection_background(self, value): self._sel_background = value - self._reset_stylesheet() + if self._auto_reset_stylesheet: + self._reset_stylesheet() for c in self.clones: c.selection_background = value @@ -434,8 +439,10 @@ def __init__(self, parent=None, create_default_actions=True): :attr:`show_menu_enabled` to False. """ super(CodeEdit, self).__init__(parent) + self._auto_reset_stylesheet = False self.installEventFilter(self) self.clones = [] + self._closed = False self._show_ctx_mnu = True self._default_font_size = 10 self._backend = BackendManager(self) @@ -502,6 +509,8 @@ def __init__(self, parent=None, create_default_actions=True): self.setCenterOnScroll(True) self.setLineWrapMode(self.NoWrap) self.setCursorWidth(2) + self._auto_reset_stylesheet = True + self._reset_stylesheet() def __repr__(self): return '%s(path=%r)' % (self.__class__.__name__, self.file.path) @@ -548,15 +557,22 @@ def link(self, clone): clone.file._encoding = self.file.encoding clone.file._mimetype = self.file.mimetype clone.setDocument(self.document()) - for original_mode, mode in zip(list(self.modes), list(clone.modes)): - mode.enabled = original_mode.enabled - mode.clone_settings(original_mode) - for original_panel, panel in zip( - list(self.panels), list(clone.panels)): - panel.enabled = original_panel.isEnabled() - panel.clone_settings(original_panel) + for original_mode in self.modes: + try: + clone_mode = clone.modes.get(original_mode.name) + except KeyError: + continue + clone_mode.enabled = original_mode.enabled + clone_mode.clone_settings(original_mode) + for original_panel in self.panels: + try: + clone_panel = clone.panels.get(original_panel.name) + except KeyError: + continue + clone_panel.enabled = original_panel.isEnabled() + clone_panel.clone_settings(original_panel) if not original_panel.isVisible(): - panel.setVisible(False) + clone_panel.setVisible(False) clone.use_spaces_instead_of_tabs = self.use_spaces_instead_of_tabs clone.tab_length = self.tab_length clone._save_on_focus_out = self._save_on_focus_out @@ -581,6 +597,9 @@ def close(self, clear=True): :param clear: True to clear the editor content before closing. """ + if self._closed: + return + self._closed = True if self._tooltips_runner: self._tooltips_runner.cancel_requests() self._tooltips_runner = None @@ -875,25 +894,29 @@ def eventFilter(self, obj, event): def cut(self): """ - Cuts the selected text or the whole line if no text was selected. + Cuts the selected text or the whole line if no text was selected. When + cutting a full line that consists of only whitespace, the line is only + deleted, to avoid overwriting the clipboard with whitespace. """ + tc = self.textCursor() - helper = TextHelper(self) tc.beginEditBlock() - no_selection = False - sText = tc.selection().toPlainText() - # If only whitespace is selected, simply delete it - if not helper.current_line_text() and not sText.strip(): - tc.deleteChar() + if not tc.hasSelection(): + tc.movePosition(tc.StartOfLine) + tc.movePosition(tc.Left) + tc.movePosition(tc.Right, tc.KeepAnchor) + tc.movePosition(tc.EndOfLine, tc.KeepAnchor) + from_selection = False + else: + from_selection = True + if from_selection or tc.selectedText().strip(): + need_cut = True else: - if not self.textCursor().hasSelection(): - no_selection = True - TextHelper(self).select_whole_line() - super(CodeEdit, self).cut() - if no_selection: - tc.deleteChar() + tc.removeSelectedText() + need_cut = False tc.endEditBlock() self.setTextCursor(tc) + super(CodeEdit, self).cut() def copy(self): """ @@ -929,7 +952,7 @@ def __swapLine(self, up): return # Select the current lines and the line that will be swapped, turn # them into a list, and then perform the swap on this list - helper.select_lines(start_index, end_index) + helper.select_lines(start_index, end_index, select_blocks=True) lines = helper.selected_text().replace(u'\u2029', u'\n').split(u'\n') if up: lines = lines[1:] + [lines[0]] @@ -944,9 +967,11 @@ def __swapLine(self, up): if has_selection: # If text was originally selected, select the range again if up: - helper.select_lines(start_index, end_index - 1) + helper.select_lines(start_index, end_index - 1, + select_blocks=True) else: - helper.select_lines(start_index + 1, end_index) + helper.select_lines(start_index + 1, end_index, + select_blocks=True) else: # Else restore cursor position, while moving with the swap helper.goto_line(line - 1 if up else line + 1, column) @@ -1316,11 +1341,13 @@ def _update_visible_blocks(self, *args): bottom = top + int(self.blockBoundingRect(block).height()) ebottom_top = 0 ebottom_bottom = self.height() + first_block = True while block.isValid(): visible = (top >= ebottom_top and bottom <= ebottom_bottom) - if not visible: + if not visible and not first_block: break - if block.isVisible(): + first_block = False + if visible and block.isVisible(): self._visible_blocks.append((top, block_nbr, block)) block = block.next() top = bottom @@ -1333,8 +1360,11 @@ def _on_text_changed(self): ln = TextHelper(self).cursor_position()[0] self._modified_lines.add(ln) + def _reset_stylesheet(self): """ Resets stylesheet""" + # This function is called very often during initialization, which + # impacts performance. This is a hack to avoid this. self.setFont(QtGui.QFont(self._font_family, self._font_size + self._zoom_level)) flg_stylesheet = hasattr(self, '_flg_stylesheet') @@ -1373,17 +1403,29 @@ def _reset_stylesheet(self): def _do_home_key(self, event=None, select=False): """ Performs home key action """ - # get nb char to first significative char - delta = (self.textCursor().positionInBlock() - - TextHelper(self).line_indent()) cursor = self.textCursor() move = QtGui.QTextCursor.MoveAnchor if select: move = QtGui.QTextCursor.KeepAnchor - if delta > 0: - cursor.movePosition(QtGui.QTextCursor.Left, move, delta) + indent = TextHelper(self).line_indent() + # Scenario 1: We're on an unindented block. In that case, we jump back + # to the start of the visible line, but not all the way to the back of + # the block. This is what you would expect when working with text and + # line wrapping is enabled. + if not indent: + cursor.movePosition(QtGui.QTextCursor.StartOfLine, move) else: - cursor.movePosition(QtGui.QTextCursor.StartOfBlock, move) + delta = self.textCursor().positionInBlock() - indent + # Scenario 2: We're on an indented block. In that case, we move + # back to the indented position. This is what you would expect when + # working with code. + if delta > 0: + cursor.movePosition(QtGui.QTextCursor.Left, move, delta) + # Scenario 3: We're on an indented block, but we're already at the + # start of the indentation. In that case, we jump back to the + # beginning of the block. + else: + cursor.movePosition(QtGui.QTextCursor.StartOfBlock, move) self.setTextCursor(cursor) if event: event.accept() diff --git a/pyqode/core/api/syntax_highlighter.py b/pyqode/core/api/syntax_highlighter.py index c6c162aa..a4c6a62d 100644 --- a/pyqode/core/api/syntax_highlighter.py +++ b/pyqode/core/api/syntax_highlighter.py @@ -254,6 +254,7 @@ def refresh_editor(self, color_scheme): :param color_scheme: new color scheme. """ + self.editor._auto_reset_stylesheet = False self.editor.background = color_scheme.background self.editor.foreground = color_scheme.formats[ 'normal'].foreground().color() @@ -272,6 +273,7 @@ def refresh_editor(self, color_scheme): pass else: mode.refresh_decorations(force=True) + self.editor._auto_reset_stylesheet = True self.editor._reset_stylesheet() def __init__(self, parent, color_scheme=None): diff --git a/pyqode/core/api/utils.py b/pyqode/core/api/utils.py index b42f88dc..0ee2fa1e 100644 --- a/pyqode/core/api/utils.py +++ b/pyqode/core/api/utils.py @@ -293,6 +293,12 @@ def line_text(self, line_nbr): :return: Entire line's text :rtype: str """ + + # Under some (apparent) race conditions, this function can be called + # with a None line number. This should be fixed in a better way, but + # for now we return an empty string to avoid crashes. + if line_nbr is None: + return '' doc = self._editor.document() block = doc.findBlockByNumber(line_nbr) return block.text() @@ -432,7 +438,8 @@ def move_cursor_to(self, line): cursor.setPosition(block.position()) return cursor - def select_lines(self, start=0, end=-1, apply_selection=True): + def select_lines(self, start=0, end=-1, apply_selection=True, + select_blocks=False): """ Selects entire lines between start and end line numbers. @@ -447,6 +454,8 @@ def select_lines(self, start=0, end=-1, apply_selection=True): end of the document :param apply_selection: True to apply the selection before returning the QTextCursor. + :param select_blocks: True to operate on blocks rather than visual + lines. :returns: A QTextCursor that holds the requested selection """ editor = self._editor @@ -455,21 +464,31 @@ def select_lines(self, start=0, end=-1, apply_selection=True): if start < 0: start = 0 text_cursor = self.move_cursor_to(start) + if select_blocks: + move_start = text_cursor.StartOfBlock + move_end = text_cursor.EndOfBlock + move_up = text_cursor.PreviousBlock + move_down = text_cursor.NextBlock + else: + move_start = text_cursor.StartOfLine + move_end = text_cursor.EndOfLine + move_up = text_cursor.Up + move_down = text_cursor.Down if end > start: # Going down - text_cursor.movePosition(text_cursor.Down, + text_cursor.movePosition(move_down, text_cursor.KeepAnchor, end - start) - text_cursor.movePosition(text_cursor.EndOfLine, + text_cursor.movePosition(move_end, text_cursor.KeepAnchor) elif end < start: # going up # don't miss end of line ! - text_cursor.movePosition(text_cursor.EndOfLine, + text_cursor.movePosition(move_end, text_cursor.MoveAnchor) - text_cursor.movePosition(text_cursor.Up, + text_cursor.movePosition(move_up, text_cursor.KeepAnchor, start - end) - text_cursor.movePosition(text_cursor.StartOfLine, + text_cursor.movePosition(move_start, text_cursor.KeepAnchor) else: - text_cursor.movePosition(text_cursor.EndOfLine, + text_cursor.movePosition(move_end, text_cursor.KeepAnchor) if apply_selection: editor.setTextCursor(text_cursor) diff --git a/pyqode/core/managers/panels.py b/pyqode/core/managers/panels.py index d09a816b..cdccae64 100644 --- a/pyqode/core/managers/panels.py +++ b/pyqode/core/managers/panels.py @@ -109,13 +109,13 @@ def keys(self): """ Returns the list of installed panel names. """ - return self._modes.keys() + return self._panels.keys() def values(self): """ Returns the list of installed panels. """ - return self._modes.values() + return self._panels.values() def __iter__(self): lst = [] diff --git a/pyqode/core/modes/autocomplete.py b/pyqode/core/modes/autocomplete.py index 3e67094a..fa072d80 100644 --- a/pyqode/core/modes/autocomplete.py +++ b/pyqode/core/modes/autocomplete.py @@ -22,6 +22,7 @@ def __init__(self): super(AutoCompleteMode, self).__init__() #: Auto complete mapping, maps input key with completion text. self.MAPPING = {'"': '"', "'": "'", "(": ")", "{": "}", "[": "]"} + self.AVOID_DUPLICATES = ')', ']', '}' #: The format to use for each symbol in mapping when there is a selection self.SELECTED_QUOTES_FORMATS = {key: '%s%s%s' for key in self.MAPPING.keys()} #: The format to use for each symbol in mapping when there is no selection @@ -92,15 +93,13 @@ def _on_key_pressed(self, event): tc.endEditBlock() self.editor.setTextCursor(tc) ignore = True - elif txt and next_char == txt and next_char in self.MAPPING: + elif ( + txt and next_char == txt and ( + next_char in self.MAPPING or + txt in self.AVOID_DUPLICATES + ) + ): ignore = True - elif event.text() == ')' or event.text() == ']' or event.text() == '}': - # if typing the same symbol twice, the symbol should not be written - # and the cursor moved just after the char - # e.g. if you type ) just before ), the cursor will just move after - # the existing ) - if next_char == event.text(): - ignore = True if ignore: event.accept() TextHelper(self.editor).clear_selection() diff --git a/pyqode/core/modes/pygments_sh.py b/pyqode/core/modes/pygments_sh.py index 2ddaf6ed..fd0b802e 100644 --- a/pyqode/core/modes/pygments_sh.py +++ b/pyqode/core/modes/pygments_sh.py @@ -174,6 +174,11 @@ def _init_style(self): """ Init pygments style """ self._update_style() + def clone_settings(self, original): + + # The lexer can be shared between clones. + self._lexer = original._lexer + def on_install(self, editor): """ :type editor: pyqode.code.api.CodeEdit @@ -188,6 +193,11 @@ def set_mime_type(self, mime_type): :param mime_type: mime type of the new lexer to setup. """ + + if not mime_type: + # Fall back to TextLexer + self._lexer = TextLexer() + return False try: self.set_lexer_from_mime_type(mime_type) except ClassNotFound: @@ -235,8 +245,14 @@ def set_lexer_from_mime_type(self, mime, **options): :param mime: mime type :param options: optional addtional options. """ - self._lexer = get_lexer_for_mimetype(mime, **options) - _logger().debug('lexer for mimetype (%s): %r', mime, self._lexer) + + try: + self._lexer = get_lexer_for_mimetype(mime, **options) + except (ClassNotFound, ImportError): + print('class not found for mime', mime) + self._lexer = get_lexer_for_mimetype('text/plain') + else: + _logger().debug('lexer for mimetype (%s): %r', mime, self._lexer) def highlight_block(self, text, block): """ diff --git a/pyqode/core/panels/search_and_replace.py b/pyqode/core/panels/search_and_replace.py index 8b995307..ab78eea9 100644 --- a/pyqode/core/panels/search_and_replace.py +++ b/pyqode/core/panels/search_and_replace.py @@ -6,7 +6,6 @@ import sre_constants from pyqode.qt import QtCore, QtGui, QtWidgets - from pyqode.core import icons from pyqode.core._forms.search_panel_ui import Ui_SearchPanel from pyqode.core.api.decoration import TextDecoration @@ -144,6 +143,7 @@ def __init__(self): self._current_occurrence_index = 0 self._bg = None self._fg = None + self._working = False self._update_buttons(txt="") self.lineEditSearch.installEventFilter(self) self.lineEditReplace.installEventFilter(self) @@ -344,6 +344,7 @@ def request_search(self, txt=None): if txt is None or isinstance(txt, int): txt = self.lineEditSearch.text() if txt: + self._working = True self.job_runner.request_job( self._exec_search, txt, self._search_flags()) else: @@ -390,6 +391,10 @@ def select_next(self): :return: True in case of success, false if no occurrence could be selected. """ + + if self._working: + QtCore.QTimer.singleShot(100, self.select_next) + return current_occurence = self._current_occurrence() occurrences = self.get_occurences() if not occurrences: @@ -428,6 +433,10 @@ def select_previous(self): :return: True in case of success, false if no occurrence could be selected. """ + + if self._working: + QtCore.QTimer.singleShot(100, self.select_previous) + return current_occurence = self._current_occurrence() occurrences = self.get_occurences() if not occurrences: @@ -592,6 +601,7 @@ def _update_label_matches(self): self.labelMatches.clear() def _on_search_finished(self): + self._working = False self._clear_decorations() all_occurences = self.get_occurences() occurrences = all_occurences[:self.MAX_HIGHLIGHTED_OCCURENCES] diff --git a/pyqode/core/widgets/splittable_tab_widget.py b/pyqode/core/widgets/splittable_tab_widget.py index e7218cfa..2f0075fe 100644 --- a/pyqode/core/widgets/splittable_tab_widget.py +++ b/pyqode/core/widgets/splittable_tab_widget.py @@ -915,13 +915,20 @@ def current_widget(self): return self._current() return None - def widgets(self, include_clones=False): + def widgets(self, include_clones=False, from_root=False): """ Recursively gets the list of widgets. :param include_clones: True to retrieve all tabs, including clones, otherwise only original widgets are returned. - """ + :param from_root: True to get all widgets, rather than only the widgets + that are under the current splitter and its child splitters. + """ + if from_root and not self.root: + return self.parent().widgets( + include_clones=include_clones, + from_root=True + ) widgets = [] for i in range(self.main_tab_widget.count()): widget = self.main_tab_widget.widget(i) @@ -1239,6 +1246,7 @@ def save_current_as(self): if widget.original: widget = widget.original mem = widget.file.path + old_path = widget.file.path widget.file._path = None widget.file._old_path = mem CodeEditTabWidget.default_directory = os.path.dirname(mem) @@ -1259,16 +1267,17 @@ def save_current_as(self): # Traverse through all splitters and all editors, and change the tab # text whenever the editor is a clone of the current widget or the # current widget itself. - current_document = widget.document() - for splitter in self.get_all_splitters(): - for editor in splitter._tabs: - if editor.document() != current_document: - continue - index = splitter.main_tab_widget.indexOf(editor) - splitter.main_tab_widget.setTabText( - index, - widget.file.name - ) + if old_path != widget.file.path: + current_document = widget.document() + for splitter in self.get_all_splitters(): + for editor in splitter._tabs: + if editor.document() != current_document: + continue + index = splitter.main_tab_widget.indexOf(editor) + splitter.main_tab_widget.setTabText( + index, + widget.file.name + ) return widget.file.path def get_root_splitter(self): @@ -1345,10 +1354,23 @@ def _create_code_edit(self, mimetype, *args, **kwargs): :return: Code editor widget instance. """ if mimetype in self.editors.keys(): - return self.editors[mimetype]( - *args, parent=self.main_tab_widget, **kwargs) - editor = self.fallback_editor(*args, parent=self.main_tab_widget, - **kwargs) + editor = self.editors[mimetype]( + *args, + parent=self.main_tab_widget, + **kwargs + ) + else: + editor = self.fallback_editor( + *args, + parent=self.main_tab_widget, + **kwargs + ) + try: + pygments = editor.modes.get('PygmentsSH') + except KeyError: + pass + else: + pygments.set_mime_type(mimetype) return editor def create_new_document(self, base_name='New Document', @@ -1438,7 +1460,7 @@ def open_document(self, path, encoding=None, replace_tabs_by_spaces=True, name = os.path.split(original_path)[1] use_parent_dir = False - for tab in self.widgets(): + for tab in self.widgets(from_root=True): title = QtCore.QFileInfo(tab.file.path).fileName() if title == name: tw = tab.parent_tab_widget