"""
GcodeTextEdit
-------------
QPlainTextEdit based G-code editor with syntax highlighting.
"""
import os
import yaml
from qtpy.QtCore import (Qt, QRect, QRegularExpression, QEvent, Slot, Signal,
Property, QFile, QTextStream)
from qtpy.QtGui import (QFont, QColor, QPainter, QSyntaxHighlighter,
QTextDocument, QTextOption, QTextFormat,
QTextCharFormat, QTextCursor)
from qtpy.QtWidgets import (QApplication, QInputDialog, QTextEdit, QLineEdit,
QPlainTextEdit, QWidget, QMenu,
QPlainTextDocumentLayout)
from qtpyvcp import DEFAULT_CONFIG_FILE
from qtpyvcp.plugins import getPlugin
from qtpyvcp.actions import program_actions
from qtpyvcp.utilities.info import Info
from qtpyvcp.utilities.logger import getLogger
from qtpyvcp.utilities.encode_utils import allEncodings
from qtpyvcp.widgets.dialogs.find_replace_dialog import FindReplaceDialog
LOG = getLogger(__name__)
INFO = Info()
STATUS = getPlugin('status')
YAML_DIR = os.path.dirname(DEFAULT_CONFIG_FILE)
[docs]class GcodeSyntaxHighlighter(QSyntaxHighlighter):
def __init__(self, document, font):
super(GcodeSyntaxHighlighter, self).__init__(document)
self.font = font
self.rules = []
self.char_fmt = QTextCharFormat()
self._abort = False
self.loadSyntaxFromYAML()
def loadSyntaxFromYAML(self):
if INFO.getGcodeSyntaxFile() is not None:
YAML_DIR = os.environ['CONFIG_DIR']
gcode_syntax_file = INFO.getGcodeSyntaxFile()
else:
YAML_DIR = os.path.dirname(DEFAULT_CONFIG_FILE)
gcode_syntax_file = 'gcode_syntax.yml'
with open(os.path.join(YAML_DIR, gcode_syntax_file)) as fh:
syntax_specs = yaml.load(fh, Loader=yaml.FullLoader)
cio = QRegularExpression.CaseInsensitiveOption
for lang_name, language in list(syntax_specs.items()):
definitions = language.get('definitions', {})
default_fmt_spec = definitions.get('default', {}).get('textFormat', {})
for context_name, spec in list(definitions.items()):
base_fmt = default_fmt_spec.copy()
fmt_spec = spec.get('textFormat', {})
# update the default fmt spec
base_fmt.update(fmt_spec)
char_fmt = self.charFormatFromSpec(fmt_spec)
patterns = spec.get('match', [])
for pattern in patterns:
self.rules.append([QRegularExpression(pattern, cio), char_fmt])
def charFormatFromSpec(self, fmt_spec):
char_fmt = self.defaultCharFormat()
for option, value in list(fmt_spec.items()):
if value is None:
continue
if option in ['foreground', 'background']:
value = QColor(value)
if isinstance(value, str) and value.startswith('QFont:'):
value = getattr(QFont, value[6:])
attr = 'set' + option[0].capitalize() + option[1:]
getattr(char_fmt, attr)(value)
return char_fmt
def defaultCharFormat(self):
char_fmt = QTextCharFormat()
char_fmt.setFont(self.font())
return char_fmt
[docs] def highlightBlock(self, text):
"""Apply syntax highlighting to the given block of text.
"""
QApplication.processEvents()
LOG.debug(f'Highlight light block: {text}')
for regex, fmt in self.rules:
nth = 0
match = regex.match(text, offset=0)
index = match.capturedStart()
while index >= 0:
# We actually want the index of the nth match
index = match.capturedStart(nth)
length = match.capturedLength(nth)
self.setFormat(index, length, fmt)
# check the rest of the string
match = regex.match(text, offset=index + length)
index = match.capturedStart()
[docs]class GcodeTextEdit(QPlainTextEdit):
"""G-code Text Edit
QPlainTextEdit based G-code editor with syntax heightening.
"""
focusLine = Signal(int)
def __init__(self, parent=None):
super(GcodeTextEdit, self).__init__(parent)
self.parent = parent
self.setCenterOnScroll(True)
self.setGeometry(50, 50, 800, 640)
self.setWordWrapMode(QTextOption.NoWrap)
self.block_number = None
self.focused_line = 1
self.current_line_background = QColor(self.palette().alternateBase())
self.readonly = False
self.syntax_highlighting = False
self.old_docs = []
# set the custom margin
self.margin = NumberMargin(self)
# set the syntax highlighter # Fixme un needed init here
self.gCodeHighlighter = None
if parent is not None:
self.find_case = None
self.find_words = None
self.search_term = ""
self.replace_term = ""
# context menu
self.menu = QMenu(self)
self.menu.addAction(self.tr("Run from line {}".format(self.focused_line)), self.runFromHere)
self.menu.addSeparator()
self.menu.addAction(self.tr('Cut'), self.cut)
self.menu.addAction(self.tr('Copy'), self.copy)
self.menu.addAction(self.tr('Paste'), self.paste)
self.menu.addAction(self.tr('Find'), self.findForward)
self.menu.addAction(self.tr('Find All'), self.findAll)
self.menu.addAction(self.tr('Replace'), self.replace)
self.menu.addAction(self.tr('Replace All'), self.replace)
# FixMe: Picks the first action run from here, should not be by index
self.run_action = self.menu.actions()[0]
self.run_action.setEnabled(program_actions.run_from_line.ok())
program_actions.run_from_line.bindOk(self.run_action)
self.dialog = FindReplaceDialog(parent=self)
# connect signals
self.cursorPositionChanged.connect(self.onCursorChanged)
# connect status signals
STATUS.file.notify(self.loadProgramFile)
STATUS.motion_line.onValueChanged(self.setCurrentLine)
@Slot(str)
def set_search_term(self, text):
LOG.debug(f"Set search term :{text}")
self.search_term = text
@Slot(str)
def set_replace_term(self, text):
LOG.debug(f"Set replace term :{text}")
self.replace_term = text
@Slot()
def findDialog(self):
LOG.debug("Show find dialog")
self.dialog.show()
@Slot(bool)
def findCase(self, enabled):
LOG.debug(f"Find case sensitive :{enabled}")
self.find_case = enabled
@Slot(bool)
def findWords(self, enabled):
LOG.debug(f"Find whole words :{enabled}")
self.find_words = enabled
def findAllText(self, text):
flags = QTextDocument.FindFlag(0)
if self.find_case:
flags |= QTextDocument.FindCaseSensitively
if self.find_words:
flags |= QTextDocument.FindWholeWords
searching = True
cursor = self.textCursor()
while searching:
found = self.find(text, flags)
if found:
cursor = self.textCursor()
else:
searching = False
if cursor.hasSelection():
self.setTextCursor(cursor)
def findForwardText(self, text):
flags = QTextDocument.FindFlag()
if self.find_case:
flags |= QTextDocument.FindCaseSensitively
if self.find_words:
flags |= QTextDocument.FindWholeWords
found = self.find(text, flags)
# if found:
# cursor = self.document().find(text, flags)
# if cursor.position() > 0:
# self.setTextCursor(cursor)
def findBackwardText(self, text):
flags = QTextDocument.FindFlag()
flags |= QTextDocument.FindBackward
if self.find_case:
flags |= QTextDocument.FindCaseSensitively
if self.find_words:
flags |= QTextDocument.FindWholeWords
found = self.find(text, flags)
# if found:
# cursor = self.document().find(text, flags)
# if cursor.position() > 0:
# self.setTextCursor(cursor)
def replaceText(self, search, replace):
flags = QTextDocument.FindFlag()
if self.find_case:
flags |= QTextDocument.FindCaseSensitively
if self.find_words:
flags |= QTextDocument.FindWholeWords
found = self.find(search, flags)
if found:
cursor = self.textCursor()
cursor.beginEditBlock()
if cursor.hasSelection():
cursor.insertText(replace)
cursor.endEditBlock();
def replaceAllText(self, search, replace):
flags = QTextDocument.FindFlag()
if self.find_case:
flags |= QTextDocument.FindCaseSensitively
if self.find_words:
flags |= QTextDocument.FindWholeWords
searching = True
while searching:
found = self.find(search, flags)
if found:
cursor = self.textCursor()
cursor.beginEditBlock()
if cursor.hasSelection():
cursor.insertText(replace)
cursor.endEditBlock();
else:
searching = False
@Slot()
def findAll(self):
text = self.search_term
LOG.debug(f"Find all text :{text}")
self.findAllText(text)
@Slot()
def findForward(self):
text = self.search_term
LOG.debug(f"Find forward :{text}")
self.findForwardText(text)
@Slot()
def findBackward(self):
text = self.search_term
LOG.debug(f"Find backwards :{text}")
self.findBackwardText(text)
@Slot()
def replace(self):
search_text = self.search_term
replace_text = self.replace_term
LOG.debug(f"Replace text :{search_text} with {replace_text}")
self.replaceText(search_text, replace_text)
@Slot()
def replaceAll(self):
search_text = self.search_term
replace_text = self.replace_term
LOG.debug(f"Replace all text :{search_text} with {replace_text}")
self.replaceAllText(search_text, replace_text)
@Slot()
def saveFile(self, save_file_name = None):
if save_file_name == None:
save_file = QFile(str(STATUS.file))
else:
save_file = QFile(str(save_file_name))
result = save_file.open(QFile.WriteOnly)
if result:
LOG.debug(f'---Save file: {save_file.fileName()}')
save_stream = QTextStream(save_file)
save_stream << self.toPlainText()
save_file.close()
else:
LOG.debug("---save error")
# simple input dialog for save as
def save_as_dialog(self, filename):
text, ok_pressed = QInputDialog.getText(self, "Save as", "New name:", QLineEdit.Normal, filename)
if ok_pressed and text != '':
return text
else:
return False
@Slot()
def saveFileAs(self):
open_file = QFile(str(STATUS.file))
if open_file == None:
return
save_file = self.save_as_dialog(open_file.fileName())
self.saveFile(save_file)
[docs] def keyPressEvent(self, event):
# keep the cursor centered
if event.key() == Qt.Key_Up:
self.moveCursor(QTextCursor.Up)
self.centerCursor()
elif event.key() == Qt.Key_Down:
self.moveCursor(QTextCursor.Down)
self.centerCursor()
else:
super(GcodeTextEdit, self).keyPressEvent(event)
[docs] def changeEvent(self, event):
if event.type() == QEvent.FontChange:
# Update syntax highlighter with new font
self.gCodeHighlighter = GcodeSyntaxHighlighter(self.document(), self.font)
super(GcodeTextEdit, self).changeEvent(event)
[docs] @Slot(bool)
def syntaxHighlightingOnOff(self, state):
"""Toggle syntax highlighting on/off"""
pass
@Property(bool)
def syntaxHighlighting(self):
return self.syntax_highlighting
@syntaxHighlighting.setter
def syntaxHighlighting(self, state):
self.syntax_highlighting = state
[docs] def setPlainText(self, p_str):
# FixMe: Keep a reference to old QTextDocuments form previously loaded
# files. This is needed to prevent garbage collection which results in a
# seg fault if the document is discarded while still being highlighted.
self.old_docs.append(self.document())
LOG.debug('setPlanText')
doc = QTextDocument()
doc.setDocumentLayout(QPlainTextDocumentLayout(doc))
doc.setPlainText(p_str)
# start syntax highlighting
if self.syntax_highlighting == True:
self.gCodeHighlighter = GcodeSyntaxHighlighter(doc, self.font)
LOG.debug('Syntax highlighting enabled.')
self.setDocument(doc)
self.margin.updateWidth()
LOG.debug('Document set with text.')
# start syntax highlighting
# self.gCodeHighlighter = GcodeSyntaxHighlighter(self)
[docs] @Slot(bool)
def EditorReadOnly(self, state):
"""Set to Read Only to disable editing"""
if state:
self.setReadOnly(True)
else:
self.setReadOnly(False)
self.readonly = state
[docs] @Slot(bool)
def EditorReadWrite(self, state):
"""Set to Read Only to disable editing"""
if state:
self.setReadOnly(False)
else:
self.setReadOnly(True)
self.readonly != state
@Property(bool)
def readOnly(self):
return self.readonly
@readOnly.setter
def readOnly(self, state):
if state:
self.setReadOnly(True)
else:
self.setReadOnly(False)
self.readonly = state
@Property(QColor)
def currentLineBackground(self):
return self.current_line_background
@currentLineBackground.setter
def currentLineBackground(self, color):
self.current_line_background = color
# Hack to get background to update
self.setCurrentLine(2)
self.setCurrentLine(1)
@Property(QColor)
def marginBackground(self):
return self.margin.background
@marginBackground.setter
def marginBackground(self, color):
self.margin.background = color
self.margin.update()
@Property(QColor)
def marginCurrentLineBackground(self):
return self.margin.highlight_background
@marginCurrentLineBackground.setter
def marginCurrentLineBackground(self, color):
self.margin.highlight_background = color
self.margin.update()
@Property(QColor)
def marginColor(self):
return self.margin.color
@marginColor.setter
def marginColor(self, color):
self.margin.color = color
self.margin.update()
@Property(QColor)
def marginCurrentLineColor(self):
return self.margin.highlight_color
@marginCurrentLineColor.setter
def marginCurrentLineColor(self, color):
self.margin.highlight_color = color
self.margin.update()
@Slot(str)
@Slot(object)
def loadProgramFile(self, fname=None):
if fname:
encodings = allEncodings()
enc = None
for enc in encodings:
try:
with open(fname, 'r', encoding=enc) as f:
gcode = f.read()
break
except Exception as e:
# LOG.debug(e)
LOG.info(f"File encoding doesn't match {enc}, trying others")
LOG.info(f"File encoding: {enc}")
# set the syntax highlighter
self.setPlainText(gcode)
# self.gCodeHighlighter = GcodeSyntaxHighlighter(self.document(), self.font)
@Slot(int)
@Slot(object)
def setCurrentLine(self, line):
cursor = QTextCursor(self.document().findBlockByLineNumber(line - 1))
self.setTextCursor(cursor)
self.centerCursor()
def getCurrentLine(self):
return self.textCursor().blockNumber() + 1
def onCursorChanged(self):
# highlights current line, find a way not to use QTextEdit
block_number = self.textCursor().blockNumber()
if block_number != self.block_number:
self.block_number = block_number
selection = QTextEdit.ExtraSelection()
selection.format.setBackground(self.current_line_background)
selection.format.setProperty(QTextFormat.FullWidthSelection, True)
selection.cursor = self.textCursor()
selection.cursor.clearSelection()
self.setExtraSelections([selection])
# emit signals for backplot etc.
self.focused_line = block_number + 1
self.focusLine.emit(self.focused_line)
def runFromHere(self, *args, **kwargs):
line = self.getCurrentLine()
program_actions.run(line)
[docs] def resizeEvent(self, *e):
cr = self.contentsRect()
rec = QRect(cr.left(), cr.top(), self.margin.getWidth(), cr.height())
self.margin.setGeometry(rec)
QPlainTextEdit.resizeEvent(self, *e)
[docs]class NumberMargin(QWidget):
def __init__(self, parent):
QWidget.__init__(self, parent)
self.parent = parent
# this only happens when lines are added or subtracted
self.parent.blockCountChanged.connect(self.updateWidth)
# this happens quite often
self.parent.updateRequest.connect(self.updateContents)
self.background = QColor('#e8e8e8')
self.highlight_background = QColor('#e8e8e8')
self.color = QColor('#717171')
self.highlight_color = QColor('#000000')
def getWidth(self):
blocks = self.parent.blockCount()
return self.parent.fontMetrics().width(str(blocks)) + 5
def updateWidth(self): # check the number column width and adjust
width = self.getWidth()
if self.width() != width:
self.setFixedWidth(width)
self.parent.setViewportMargins(width, 0, 0, 0)
def updateContents(self, rect, scroll):
if scroll:
self.scroll(0, scroll)
else:
self.update(0, rect.y(), self.width(), rect.height())
if rect.contains(self.parent.viewport().rect()):
self.updateWidth()
[docs] def paintEvent(self, event): # this puts the line numbers in the margin
painter = QPainter(self)
painter.fillRect(event.rect(), self.background)
block = self.parent.firstVisibleBlock()
font = self.parent.font()
while block.isValid():
block_num = block.blockNumber()
block_top = self.parent.blockBoundingGeometry(block).translated(self.parent.contentOffset()).top()
# if the block is not visible stop wasting time
if not block.isVisible() or block_top >= event.rect().bottom():
break
if block_num == self.parent.textCursor().blockNumber():
font.setBold(True)
painter.setFont(font)
painter.setPen(self.highlight_color)
background = self.highlight_background
else:
font.setBold(False)
painter.setFont(font)
painter.setPen(self.color)
background = self.background
paint_rec = QRect(0, int(block_top), self.width(),
self.parent.fontMetrics().height())
text_rec = QRect(0, int(block_top), self.width() -
4, self.parent.fontMetrics().height())
painter.fillRect(paint_rec, background)
painter.drawText(text_rec, Qt.AlignRight, str(block_num + 1))
block = block.next()
painter.end()
QWidget.paintEvent(self, event)