Source code for bci_framework.framework.editor.editor

"""
=========
BCIEditor
=========

"""

import os
import ast
import json
from typing import Literal, TypeVar

from PySide6.QtWidgets import QTextEdit, QCompleter
from PySide6.QtGui import QTextOption
from PySide6.QtCore import Qt

from .highlighters import PythonHighlighter, CSSHighlighter


AUTOCOMPLETER = TypeVar('Autocompleter')


########################################################################
[docs]class BCIEditor(QTextEdit): """Custom QTextEdit with autocompleter and linenumbers. Parameters ---------- linenumber QTextEdit object that will be updated with linenumbers. extension To set the highlighter. """ # ---------------------------------------------------------------------- def __init__(self, linenumber: QTextEdit, extension: Literal['.py', '.css'] = '.py', *args, **kwargs): """""" super().__init__(*args, **kwargs) self.set_options() self.linenumber = linenumber self.temporal_vars = [] if 'light' in os.environ.get('QTMATERIAL_THEME'): font_color = 'black' else: font_color = 'white' f = self.fontMetrics() font_size = f.height() self.setStyleSheet(f""" QTextEdit {{ background-color: {os.environ.get('QTMATERIAL_SECONDARYDARKCOLOR')}; color: {font_color}; font-weight: normal; font-family: 'DejaVu Sans Mono'; font-size: {font_size}px; line-height: {font_size}px; border: 1px solid {os.environ.get('QTMATERIAL_SECONDARYDARKCOLOR')}; border-radius: 4px; padding: 0px; padding-top: 8px; }} """) linenumber.setStyleSheet(f""" QTextEdit.linenumber {{ background-color: {os.environ.get('QTMATERIAL_SECONDARYDARKCOLOR')}; color: {os.environ.get('QTMATERIAL_SECONDARYTEXTCOLOR')}; font-weight: normal; font-family: 'DejaVu Sans Mono'; font-size: {font_size}px; line-height: {font_size}px; border: 0px solid {os.environ.get('QTMATERIAL_SECONDARYLIGHTCOLOR')}; border-right: 10px solid {os.environ.get('QTMATERIAL_SECONDARYLIGHTCOLOR')}; border-radius: 0px; }} """) if extension == '.py': PythonHighlighter(self.document()) elif extension == '.css': CSSHighlighter(self.document()) self.connect_() self.completer = None # ----------------------------------------------------------------------
[docs] def connect_(self) -> None: """Update linenumber.""" self.textChanged.connect(self.update_linenumber)
# ----------------------------------------------------------------------
[docs] def wheelEvent(self, evt): """Update the offset of the linenumber.""" if self.linenumber: self.linenumber.wheelEvent(evt) return super().wheelEvent(evt)
# ----------------------------------------------------------------------
[docs] def update_linenumber(self) -> None: """Update linenumber.""" lines = len(self.toPlainText().split('\n')) lines_ln = len(self.linenumber.toPlainText().split('\n')) if lines != lines_ln: content = [f'{l}' for l in range(1, lines + 1)] self.linenumber.setHtml( f'<p style="text-align: right">{"<br>".join(content)}</p>') self.linenumber.verticalScrollBar().setValue(self.verticalScrollBar().value())
# ----------------------------------------------------------------------
[docs] def set_options(self) -> None: """Configure QTextEdit.""" document = self.document() option = QTextOption() document.setDefaultTextOption(option) self.setAcceptRichText(False) self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
# ----------------------------------------------------------------------
[docs] def keyPressEvent(self, event): """Process key events.""" completionPrefix = self.text_under_cursor() if self.completer and len(completionPrefix) < 3 and self.completer.popup().isVisible(): self.completer.popup().hide() if event.key() in [Qt.Key_Tab, Qt.Key_Enter, Qt.Key_Enter - 1]: if self.completer and self.completer.popup().isVisible(): self.completer.insertText.emit( self.completer.last_highlighted) self.completer.popup().hide() return if event.key() == Qt.Key_Tab: tc = self.textCursor() tc.insertText(" " * 4) return # Manage {([ for seleceted text for text, *keys in (["({})", Qt.Key_ParenLeft, Qt.Key_ParenRight], ["[{}]", Qt.Key_BracketLeft, Qt.Key_BracketRight], ["{{{}}}", Qt.Key_BraceLeft, Qt.Key_BraceRight], ): if event.key() in keys: tc = self.textCursor() start = tc.selectionStart() if selected := tc.selectedText(): tc.removeSelectedText() tc.insertText(text.format(selected)) if event.key() == keys[0]: tc.setPosition(start, tc.MoveAnchor) self.setTextCursor(tc) return # Manage quotation for selected text for text, *keys in (['"{}"', Qt.Key_QuoteDbl], ["'{}'", Qt.Key_Apostrophe, ] ): if event.key() in keys: tc = self.textCursor() start = tc.selectionStart() if selected := tc.selectedText(): tc.removeSelectedText() tc.insertText(text.format(selected)) self.setTextCursor(tc) return # Toggle comment if (event.key() == Qt.Key_Period) and bool(event.modifiers() & Qt.ControlModifier): tc = self.textCursor() # pos = tc.position() tc.select(tc.LineUnderCursor) line = tc.selectedText() if line.strip() and line.strip()[0] == '#': self._remove_symbol('# ') else: self._insert_symbol('# ') # Comment if (event.key() == Qt.Key_Slash) and bool(event.modifiers() & Qt.ControlModifier): return self._insert_symbol('# ') # Uncomment if (event.key() == Qt.Key_Question) and bool(event.modifiers() & Qt.ControlModifier): return self._remove_symbol('# ') # Unindent if (event.key() == Qt.Key_Less) and bool(event.modifiers() & Qt.ControlModifier): return self._remove_symbol(' ') # Indent if (event.key() == Qt.Key_Greater) and bool(event.modifiers() & Qt.ControlModifier): return self._insert_symbol(' ') # On enter if event.key() in [Qt.Key_Enter, Qt.Key_Enter - 1]: return self._on_enter() # On back space if event.key() == Qt.Key_Backspace: return self._on_back_space(event) super().keyPressEvent(event) if self.completer and event.text(): completionPrefix = self.text_under_cursor() if len(completionPrefix) >= 3: self.show_completer(completionPrefix) elif self.completer.popup().isVisible(): self.completer.popup().hide()
# ---------------------------------------------------------------------- def _on_back_space(self, event): """""" tc = self.textCursor() if tc.selectedText(): return super().keyPressEvent(event) pos = tc.position() tc.movePosition(tc.StartOfLine, tc.KeepAnchor) previous = tc.selectedText() if previous[-4:] == ' ': if d := len(previous) % 4: previous = previous[:-d] else: previous = previous[:-4] tc.insertText(previous) return else: return super().keyPressEvent(event) # ---------------------------------------------------------------------- def _on_enter(self): """""" tc = self.textCursor() pos = tc.position() tc.select(tc.LineUnderCursor) line = tc.selectedText() if line.isspace() or line == "": len_s = len(line) else: normal = line.replace(" ", "") len_s = line.find(normal[0]) if line.strip().endswith(':'): len_s += 4 tc.setPosition(pos) tc.insertText("\n" + " " * len_s) return # ---------------------------------------------------------------------- def _remove_symbol(self, char='#'): """""" tc = self.textCursor() if tc.selectedText(): start = tc.selectionStart() end = tc.selectionEnd() tc.setPosition(start, tc.MoveAnchor) tc.movePosition(tc.StartOfLine, tc.MoveAnchor) start = tc.position() tc.setPosition(end, tc.MoveAnchor) tc.movePosition(tc.EndOfLine, tc.MoveAnchor) end = tc.position() tc.setPosition(start, tc.MoveAnchor) tc.setPosition(end, tc.KeepAnchor) selected = tc.selectedText() tc.removeSelectedText() uncommented = [] for line in selected.split('\u2029'): if line.strip(): start_ = line.find(line.replace(' ', '')[0]) if char.isspace(): uncommented.append(line.replace(f'{char}', '', 1)) else: if line[start_:start_ + len(char)] == f'{char}': uncommented.append( line.replace(f'{char}', '', 1)) else: tc.insertText(selected) return end -= len(char) else: uncommented.append(line) if uncommented: tc.insertText('\u2029'.join(uncommented)) tc.setPosition(start, tc.MoveAnchor) tc.setPosition(end, tc.KeepAnchor) self.setTextCursor(tc) else: tc.insertText(selected) else: tc.select(tc.LineUnderCursor) if line := tc.selectedText(): tc.removeSelectedText() if char.isspace(): tc.insertText(line.replace(f'{char}', '', 1)) else: start = line.find(line.replace(' ', '')[0]) if line[start:start + len(char)] == f'{char}': tc.insertText(line.replace(f'{char}', '', 1)) else: tc.insertText(line) # ---------------------------------------------------------------------- def _insert_symbol(self, char='#'): """""" tc = self.textCursor() if tc.selectedText(): start = tc.selectionStart() end = tc.selectionEnd() tc.setPosition(start, tc.MoveAnchor) tc.movePosition(tc.StartOfLine, tc.MoveAnchor) start = tc.position() tc.setPosition(end, tc.MoveAnchor) tc.movePosition(tc.EndOfLine, tc.MoveAnchor) end = tc.position() tc.setPosition(start, tc.MoveAnchor) tc.setPosition(end, tc.KeepAnchor) selected = tc.selectedText() tc.removeSelectedText() commented = [] for line in selected.split('\u2029'): if line.strip(): start_ = line.find(line.replace(' ', '')[0]) commented.append( " " * start_ + f'{char}' + line[start_:]) end += len(char) else: commented.append(line) tc.insertText('\u2029'.join(commented)) tc.setPosition(start, tc.MoveAnchor) tc.setPosition(end, tc.KeepAnchor) self.setTextCursor(tc) else: tc.select(tc.LineUnderCursor) if line := tc.selectedText(): if line.strip(): tc.removeSelectedText() start = line.find(line.replace(' ', '')[0]) tc.insertText(" " * start + f'{char}' + line[start:]) # ----------------------------------------------------------------------
[docs] def set_completer(self, completer: AUTOCOMPLETER) -> None: """Update the autocompleter used.""" if self.completer: self.disconnect(self.completer) if not completer: return completer.setWidget(self) completer.setCompletionMode(QCompleter.PopupCompletion) completer.setCaseSensitivity(Qt.CaseInsensitive) self.completer = completer self.completer.insertText.connect(self.insert_completion)
# ----------------------------------------------------------------------
[docs] def insert_completion(self, completion: str) -> None: """Process and inset the desired option.""" tc = self.textCursor() self.text_under_cursor(tc) tc.removeSelectedText() pos = tc.position() if completion in self.completer.snippets: completion = self.completer.snippets[completion] extra = completion text_position = extra.find("[!]") extra = extra.replace("[!]", "") position_in_line = tc.positionInBlock() extra = extra.replace("\n", "\n" + " " * position_in_line) tc.insertText(extra) if text_position > 0: tc.setPosition(pos + text_position + (4 * extra[:text_position].count('\n'))) self.setTextCursor(tc)
# ----------------------------------------------------------------------
[docs] def text_under_cursor(self, tc=None) -> str: """Return de text under cursor.""" if tc is None: tc = self.textCursor() # word like: cdc| tc.movePosition(tc.WordLeft, tc.KeepAnchor) # word like: cdc.| if tc.selectedText().startswith("."): tc.movePosition(tc.WordLeft, tc.KeepAnchor) # word like: cdc.pri| tc.movePosition(tc.WordLeft, tc.KeepAnchor) if tc.selectedText().startswith("."): tc.movePosition(tc.WordLeft, tc.KeepAnchor) else: tc.movePosition(tc.WordRight, tc.KeepAnchor) # tc.select(QTextCursor.WordUnderCursor) completionPrefix = tc.selectedText() if completionPrefix.endswith('.') and completionPrefix.count('.') > 1: index = completionPrefix.rfind( '.', 0, completionPrefix.rfind('.')) completionPrefix = completionPrefix[index + 1:] for _ in range(tc.selectedText().count('.') - 1): tc.movePosition(tc.WordRight, tc.KeepAnchor) tc.movePosition(tc.WordRight, tc.KeepAnchor) return completionPrefix
# ----------------------------------------------------------------------
[docs] def focusInEvent(self, event) -> None: """Keep the focus on the editor.""" if self.completer: self.completer.setWidget(self) QTextEdit.focusInEvent(self, event)
# ----------------------------------------------------------------------
[docs] def show_completer(self, completion_prefix: str) -> None: """Show better options for current text.""" # self.completer.set_temporal(self.get_variables()) self.completer.setCompletionPrefix(completion_prefix) popup = self.completer.popup() popup.setCurrentIndex(self.completer.completionModel().index(0, 0)) cr = self.cursorRect() cr.setWidth(self.completer.popup().sizeHintForColumn( 0) + self.completer.popup().verticalScrollBar().sizeHint().width() + 15) cr.setHeight(30) self.completer.complete(cr)
# # ---------------------------------------------------------------------- # def get_variables(self): # """""" # try: # root = ast.parse(self.toPlainText()) # self.temporal_vars = sorted({node.id for node in ast.walk( # root) if isinstance(node, ast.Name)}) # except: # pass # return self.temporal_vars