# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright © Spyder Project Contributors # # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ---------------------------------------------------------------------------- """ Simple code editor with syntax highlighting and line number area. Adapted from: https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html """ # Third party imports from qtpy.QtCore import QPoint, QRect, QSize, Qt, Signal from qtpy.QtGui import QColor, QPainter, QTextCursor, QTextFormat, QTextOption from qtpy.QtWidgets import QPlainTextEdit, QTextEdit, QWidget # Local imports import spyder.utils.syntaxhighlighters as sh from spyder.widgets.mixins import BaseEditMixin # Constants LANGUAGE_EXTENSIONS = { 'Python': ('py', 'pyw', 'python', 'ipy'), 'Cython': ('pyx', 'pxi', 'pxd'), 'Enaml': ('enaml',), 'Fortran77': ('f', 'for', 'f77'), 'Fortran': ('f90', 'f95', 'f2k', 'f03', 'f08'), 'Idl': ('pro',), 'Diff': ('diff', 'patch', 'rej'), 'GetText': ('po', 'pot'), 'Nsis': ('nsi', 'nsh'), 'Html': ('htm', 'html'), 'Cpp': ('c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx'), 'OpenCL': ('cl',), 'Yaml': ('yaml', 'yml'), 'Markdown': ('md', 'mdw'), # Every other language 'None': ('', ), } class LineNumberArea(QWidget): """ Adapted from: https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html """ def __init__(self, code_editor=None): super().__init__(code_editor) self._editor = code_editor self._left_padding = 6 # Pixels self._right_padding = 3 # Pixels # --- Qt overrides # ------------------------------------------------------------------------ def sizeHint(self): return QSize(self._editor.linenumberarea_width(), 0) def paintEvent(self, event): self._editor.linenumberarea_paint_event(event) class SimpleCodeEditor(QPlainTextEdit, BaseEditMixin): """Simple editor with highlight features.""" LANGUAGE_HIGHLIGHTERS = { 'Python': (sh.PythonSH, '#'), 'Cython': (sh.CythonSH, '#'), 'Fortran77': (sh.Fortran77SH, 'c'), 'Fortran': (sh.FortranSH, '!'), 'Idl': (sh.IdlSH, ';'), 'Diff': (sh.DiffSH, ''), 'GetText': (sh.GetTextSH, '#'), 'Nsis': (sh.NsisSH, '#'), 'Html': (sh.HtmlSH, ''), 'Yaml': (sh.YamlSH, '#'), 'Cpp': (sh.CppSH, '//'), 'OpenCL': (sh.OpenCLSH, '//'), 'Enaml': (sh.EnamlSH, '#'), 'Markdown': (sh.MarkdownSH, '#'), # Every other language 'None': (sh.TextSH, ''), } # --- Signals # ------------------------------------------------------------------------ sig_focus_changed = Signal() """ This signal when the focus of the editor changes, either by a `focusInEvent` or `focusOutEvent` event. """ def __init__(self, parent=None): super().__init__(parent) # Variables self._linenumber_enabled = None self._color_scheme = "spyder/dark" self._language = None self._blanks_enabled = None self._scrollpastend_enabled = None self._wrap_mode = None self._highlight_current_line = None self.supported_language = False # Widgets self._highlighter = None self.linenumberarea = LineNumberArea(self) # Widget setup self.setObjectName(self.__class__.__name__ + str(id(self))) self.update_linenumberarea_width(0) self._apply_current_line_highlight() # Signals self.blockCountChanged.connect(self.update_linenumberarea_width) self.updateRequest.connect(self.update_linenumberarea) self.cursorPositionChanged.connect(self._apply_current_line_highlight) # --- Private API # ------------------------------------------------------------------------ def _apply_color_scheme(self): hl = self._highlighter if hl is not None: hl.setup_formats(self.font()) if self._color_scheme is not None: hl.set_color_scheme(self._color_scheme) self._set_palette(background=hl.get_background_color(), foreground=hl.get_foreground_color()) def _set_palette(self, background, foreground): style = ("QPlainTextEdit#%s {background: %s; color: %s;}" % (self.objectName(), background.name(), foreground.name())) self.setStyleSheet(style) self.rehighlight() def _apply_current_line_highlight(self): if self._highlighter and self._highlight_current_line: extra_selections = [] selection = QTextEdit.ExtraSelection() line_color = self._highlighter.get_currentline_color() selection.format.setBackground(line_color) selection.format.setProperty(QTextFormat.FullWidthSelection, True) selection.cursor = self.textCursor() selection.cursor.clearSelection() extra_selections.append(selection) self.setExtraSelections(extra_selections) else: self.setExtraSelections([]) # --- Qt Overrides # ------------------------------------------------------------------------ def focusInEvent(self, event): self.sig_focus_changed.emit() super().focusInEvent(event) def focusOutEvent(self, event): self.sig_focus_changed.emit() super().focusInEvent(event) def resizeEvent(self, event): super().resizeEvent(event) if self._linenumber_enabled: cr = self.contentsRect() self.linenumberarea.setGeometry( QRect( cr.left(), cr.top(), self.linenumberarea_width(), cr.height(), ) ) # --- Public API # ------------------------------------------------------------------------ def setup_editor(self, linenumbers=True, color_scheme="spyder/dark", language="py", font=None, show_blanks=False, wrap=False, highlight_current_line=True, scroll_past_end=False): """ Setup editor options. Parameters ---------- color_scheme: str, optional Default is "spyder/dark". language: str, optional Default is "py". font: QFont or None Default is None. show_blanks: bool, optional Default is False/ wrap: bool, optional Default is False. highlight_current_line: bool, optional Default is True. scroll_past_end: bool, optional Default is False """ if font: self.set_font(font) self.set_highlight_current_line(highlight_current_line) self.set_blanks_enabled(show_blanks) self.toggle_line_numbers(linenumbers) self.set_scrollpastend_enabled(scroll_past_end) self.set_language(language) self.set_color_scheme(color_scheme) self.toggle_wrap_mode(wrap) def set_font(self, font): """ Set the editor font. Parameters ---------- font: QFont Font to use. """ if font: self.setFont(font) self._apply_color_scheme() def set_color_scheme(self, color_scheme): """ Set the editor color scheme. Parameters ---------- color_scheme: str Color scheme to use. """ self._color_scheme = color_scheme self._apply_color_scheme() def set_language(self, language): """ Set current syntax highlighting to use `language`. Parameters ---------- language: str or None Language name or known extensions. """ sh_class = sh.TextSH language = str(language).lower() self.supported_language = False for (key, value) in LANGUAGE_EXTENSIONS.items(): if language in (key.lower(), ) + value: sh_class, __ = self.LANGUAGE_HIGHLIGHTERS[key] self._language = key self.supported_language = True self._highlighter = sh_class( self.document(), self.font(), self._color_scheme) self._apply_color_scheme() def toggle_line_numbers(self, state): """ Set visibility of line number area Parameters ---------- state: bool Visible state of the line number area. """ self._linenumber_enabled = state self.linenumberarea.setVisible(state) self.update_linenumberarea_width(()) def set_scrollpastend_enabled(self, state): """ Set scroll past end state. Parameters ---------- state: bool Scroll past end state. """ self._scrollpastend_enabled = state self.setCenterOnScroll(state) self.setDocument(self.document()) def toggle_wrap_mode(self, state): """ Set line wrap.. Parameters ---------- state: bool Wrap state. """ self.set_wrap_mode('word' if state else None) def set_wrap_mode(self, mode=None): """ Set line wrap mode. Parameters ---------- mode: str or None, optional "word", or "character". Default is None. """ if mode == 'word': wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere elif mode == 'character': wrap_mode = QTextOption.WrapAnywhere else: wrap_mode = QTextOption.NoWrap self.setWordWrapMode(wrap_mode) def set_highlight_current_line(self, value): """ Set if the current line is highlighted. Parameters ---------- value: bool The value of the current line highlight option. """ self._highlight_current_line = value self._apply_current_line_highlight() def set_blanks_enabled(self, state): """ Show blank spaces. Parameters ---------- state: bool Blank spaces visibility. """ self._blanks_enabled = state option = self.document().defaultTextOption() option.setFlags(option.flags() | QTextOption.AddSpaceForLineAndParagraphSeparators) if self._blanks_enabled: option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) else: option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) self.document().setDefaultTextOption(option) # Rehighlight to make the spaces less apparent. self.rehighlight() # --- Line number area # ------------------------------------------------------------------------ def linenumberarea_paint_event(self, event): """ Paint the line number area. """ if self._linenumber_enabled: painter = QPainter(self.linenumberarea) painter.fillRect( event.rect(), self._highlighter.get_sideareas_color(), ) block = self.firstVisibleBlock() block_number = block.blockNumber() top = round(self.blockBoundingGeometry(block).translated( self.contentOffset()).top()) bottom = top + round(self.blockBoundingRect(block).height()) font = self.font() active_block = self.textCursor().block() active_line_number = active_block.blockNumber() + 1 while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): number = block_number + 1 if number == active_line_number: font.setWeight(font.Bold) painter.setFont(font) painter.setPen( self._highlighter.get_foreground_color()) else: font.setWeight(font.Normal) painter.setFont(font) painter.setPen(QColor(Qt.darkGray)) right_padding = self.linenumberarea._right_padding painter.drawText( 0, top, self.linenumberarea.width() - right_padding, self.fontMetrics().height(), Qt.AlignRight, str(number), ) block = block.next() top = bottom bottom = top + round(self.blockBoundingRect(block).height()) block_number += 1 def linenumberarea_width(self): """ Return the line number area width. Returns ------- int Line number are width in pixels. Notes ----- If the line number area is disabled this will return zero. """ width = 0 if self._linenumber_enabled: digits = 1 count = max(1, self.blockCount()) while count >= 10: count /= 10 digits += 1 fm = self.fontMetrics() width = (self.linenumberarea._left_padding + self.linenumberarea._right_padding + fm.width('9') * digits) return width def update_linenumberarea_width(self, new_block_count=None): """ Update the line number area width based on the number of blocks in the document. Parameters ---------- new_block_count: int The current number of blocks in the document. """ self.setViewportMargins(self.linenumberarea_width(), 0, 0, 0) def update_linenumberarea(self, rect, dy): """ Update scroll position of line number area. """ if self._linenumber_enabled: if dy: self.linenumberarea.scroll(0, dy) else: self.linenumberarea.update( 0, rect.y(), self.linenumberarea.width(), rect.height()) if rect.contains(self.viewport().rect()): self.update_linenumberarea_width(0) # --- Text and cursor handling # ------------------------------------------------------------------------ def set_selection(self, start, end): """ Set current text selection. Parameters ---------- start: int Selection start position. end: int Selection end position. """ cursor = self.textCursor() cursor.setPosition(start) cursor.setPosition(end, QTextCursor.KeepAnchor) self.setTextCursor(cursor) def stdkey_backspace(self): if not self.has_selected_text(): self.moveCursor(QTextCursor.PreviousCharacter, QTextCursor.KeepAnchor) self.remove_selected_text() def restrict_cursor_position(self, position_from, position_to): """ Restrict the cursor from being inside from and to positions. Parameters ---------- position_from: int Selection start position. position_to: int Selection end position. """ position_from = self.get_position(position_from) position_to = self.get_position(position_to) cursor = self.textCursor() cursor_position = cursor.position() if cursor_position < position_from or cursor_position > position_to: self.set_cursor_position(position_to) def truncate_selection(self, position_from): """ Restrict the cursor selection to start from the given position. Parameters ---------- position_from: int Selection start position. """ position_from = self.get_position(position_from) cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() if start < end: start = max([position_from, start]) else: end = max([position_from, end]) self.set_selection(start, end) def set_text(self, text): """ Set `text` of the document. Parameters ---------- text: str Text to set. """ self.setPlainText(text) def append(self, text): """ Add `text` to the end of the document. Parameters ---------- text: str Text to append. """ cursor = self.textCursor() cursor.movePosition(QTextCursor.End) cursor.insertText(text) def get_visible_block_numbers(self): """Get the first and last visible block numbers.""" first = self.firstVisibleBlock().blockNumber() bottom_right = QPoint(self.viewport().width() - 1, self.viewport().height() - 1) last = self.cursorForPosition(bottom_right).blockNumber() return (first, last) # --- Syntax highlighter # ------------------------------------------------------------------------ def rehighlight(self): """ Reapply syntax highligthing to the document. """ if self._highlighter: self._highlighter.rehighlight() if __name__ == "__main__": from spyder.utils.qthelpers import qapplication app = qapplication() editor = SimpleCodeEditor() editor.setup_editor(language="markdown") editor.set_text("# Hello!") editor.show() app.exec_()