+# Gcode display / edit widget for QT_VCP
+# Copyright 2016 Chris Morley
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+# This was based on
+# QScintilla sample with PyQt
+# Eli Bendersky (eliben@gmail.com)
+# Which is code in the public domain
+#
+# See also:
+# http://pyqt.sourceforge.net/Docs/QScintilla2/index.html
+# https://qscintilla.com/
+
+import sys
+import os
+
+from qtpy.QtCore import Property, QObject, Slot, QFile, QFileInfo, QTextStream, Signal
+from qtpy.QtGui import QFont, QFontMetrics, QColor
+from qtpy.QtWidgets import QInputDialog, QLineEdit, QDialog, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QCheckBox
+
+from qtpyvcp.utilities import logger
+from qtpyvcp.plugins import getPlugin
+from qtpyvcp.utilities.info import Info
+
+
+LOG = logger.getLogger(__name__)
+
+try:
+ from PyQt5.Qsci import QsciScintilla, QsciLexerCustom
+except ImportError as e:
+ LOG.critical("Can't import QsciScintilla - is package python-pyqt5.qsci installed?", exc_info=e)
+ sys.exit(1)
+
+STATUS = getPlugin('status')
+INFO = Info()
+
+
+# ==============================================================================
+# Simple custom lexer for Gcode
+# ==============================================================================
+[docs]class GcodeLexer(QsciLexerCustom):
+
def __init__(self, parent=None, standalone=False):
+
super(GcodeLexer, self).__init__(parent)
+
+
# This prevents doing unneeded initialization
+
# when QtDesginer loads the plugin.
+
if parent is None and not standalone:
+
return
+
+
self._styles = {
+
0: 'Default',
+
1: 'Comment',
+
2: 'Key',
+
3: 'Assignment',
+
4: 'Value',
+
}
+
for key, value in self._styles.items():
+
setattr(self, value, key)
+
font = QFont()
+
font.setFamily('Courier')
+
font.setFixedPitch(True)
+
font.setPointSize(10)
+
font.setBold(True)
+
self.setFont(font, 2)
+
+
# Paper sets the background color of each style of text
+
def setPaperBackground(self, color, style=None):
+
if style is None:
+
for i in range(0, 5):
+
self.setPaper(color, i)
+
else:
+
self.setPaper(color, style)
+
+
[docs] def description(self, style):
+
return self._styles.get(style, '')
+
+
[docs] def defaultColor(self, style):
+
if style == self.Default:
+
return QColor('#000000') # black
+
elif style == self.Comment:
+
return QColor('#000000') # black
+
elif style == self.Key:
+
return QColor('#0000CC') # blue
+
elif style == self.Assignment:
+
return QColor('#CC0000') # red
+
elif style == self.Value:
+
return QColor('#00CC00') # green
+
return QsciLexerCustom.defaultColor(self, style)
+
+
[docs] def styleText(self, start, end):
+
editor = self.editor()
+
if editor is None:
+
return
+
+
# scintilla works with encoded bytes, not decoded characters.
+
# this matters if the source contains non-ascii characters and
+
# a multi-byte encoding is used (e.g. utf-8)
+
source = ''
+
if end > editor.length():
+
end = editor.length()
+
if end > start:
+
if sys.hexversion >= 0x02060000:
+
# faster when styling big files, but needs python 2.6
+
source = bytearray(end - start)
+
editor.SendScintilla(
+
editor.SCI_GETTEXTRANGE, start, end, source)
+
else:
+
source = str(editor.text()).encode('utf-8')[start:end]
+
if not source:
+
return
+
+
# the line index will also be needed to implement folding
+
index = editor.SendScintilla(editor.SCI_LINEFROMPOSITION, start)
+
if index > 0:
+
# the previous state may be needed for multi-line styling
+
pos = editor.SendScintilla(
+
editor.SCI_GETLINEENDPOSITION, index - 1)
+
state = editor.SendScintilla(editor.SCI_GETSTYLEAT, pos)
+
else:
+
state = self.Default
+
+
set_style = self.setStyling
+
self.startStyling(start, 0x1f)
+
+
# scintilla always asks to style whole lines
+
for line in source.splitlines(True):
+
# print(line)
+
length = len(line)
+
graymode = False
+
msg = ('msg'.encode('utf-8') in line.lower() or 'debug'.encode('utf-8') in line.lower())
+
for char in str(line):
+
# print(char)
+
if char == '(':
+
graymode = True
+
set_style(1, self.Comment)
+
continue
+
elif char == ')':
+
graymode = False
+
set_style(1, self.Comment)
+
continue
+
elif graymode:
+
if msg and char.lower() in ('m', 's', 'g', ',', 'd', 'e', 'b', 'u'):
+
set_style(1, self.Assignment)
+
if char == ',': msg = False
+
else:
+
set_style(1, self.Comment)
+
continue
+
elif char in ('%', '<', '>', '#', '='):
+
state = self.Assignment
+
elif char in ('[', ']'):
+
state = self.Value
+
elif char.isalpha():
+
state = self.Key
+
elif char.isdigit():
+
state = self.Default
+
else:
+
state = self.Default
+
set_style(1, state)
+
+
# folding implementation goes here
+
index += 1
+
+
+# ==============================================================================
+# Base editor class
+# ==============================================================================
+[docs]class EditorBase(QsciScintilla):
+
ARROW_MARKER_NUM = 8
+
+
def __init__(self, parent=None):
+
super(EditorBase, self).__init__(parent)
+
# linuxcnc defaults
+
self.idle_line_reset = False
+
# don't allow editing by default
+
self.setReadOnly(True)
+
# Set the default font
+
font = QFont()
+
font.setFamily('Courier')
+
font.setFixedPitch(True)
+
font.setPointSize(10)
+
self.setFont(font)
+
self.setMarginsFont(font)
+
+
# Margin 0 is used for line numbers
+
fontmetrics = QFontMetrics(font)
+
self.setMarginsFont(font)
+
self.setMarginWidth(0, fontmetrics.width("0000") + 6)
+
self.setMarginLineNumbers(0, True)
+
self.setMarginsBackgroundColor(QColor("#cccccc"))
+
+
# Clickable margin 1 for showing markers
+
self.setMarginSensitivity(1, True)
+
# setting marker margin width to zero make the marker highlight line
+
self.setMarginWidth(1, 10)
+
self.marginClicked.connect(self.on_margin_clicked)
+
self.markerDefine(QsciScintilla.RightArrow,
+
self.ARROW_MARKER_NUM)
+
self.setMarkerBackgroundColor(QColor("#ffe4e4"),
+
self.ARROW_MARKER_NUM)
+
+
# Brace matching: enable for a brace immediately before or after
+
# the current position
+
#
+
self.setBraceMatching(QsciScintilla.SloppyBraceMatch)
+
+
# Current line visible with special background color
+
self.setCaretLineVisible(True)
+
self.setCaretLineBackgroundColor(QColor("#ffe4e4"))
+
+
# Set custom gcode lexer
+
self.lexer = GcodeLexer(self)
+
self.lexer.setDefaultFont(font)
+
self.setLexer(self.lexer)
+
+
# default gray background
+
self.set_background_color('#C0C0C0')
+
+
self.highlit = None
+
+
# not too small
+
# self.setMinimumSize(200, 100)
+
+
[docs] def find_text_occurences(self, text):
+
"""Return byte positions of start and end of all 'text' occurences in the document"""
+
+
text_len = len(text)
+
end_pos = self.SendScintilla(QsciScintilla.SCI_GETLENGTH)
+
self.SendScintilla(QsciScintilla.SCI_SETTARGETSTART, 0)
+
self.SendScintilla(QsciScintilla.SCI_SETTARGETEND, end_pos)
+
+
occurences = []
+
+
match = self.SendScintilla(QsciScintilla.SCI_SEARCHINTARGET, text_len, text)
+
print(match)
+
while match != -1:
+
match_end = self.SendScintilla(QsciScintilla.SCI_GETTARGETEND)
+
occurences.append((match, match_end))
+
# -- if there's a match, the target is modified so we shift its start
+
# -- and restore its end --
+
self.SendScintilla(QsciScintilla.SCI_SETTARGETSTART, match_end)
+
self.SendScintilla(QsciScintilla.SCI_SETTARGETEND, end_pos)
+
# -- find it again in the new (reduced) target --
+
match = self.SendScintilla(QsciScintilla.SCI_SEARCHINTARGET, text_len, text)
+
+
return occurences
+
+
def highlight_occurences(self, text):
+
+
occurences = self.find_text_occurences(text)
+
text_len = len(text)
+
self.SendScintilla(QsciScintilla.SCI_SETSTYLEBITS, 8)
+
for occs in occurences:
+
self.SendScintilla(QsciScintilla.SCI_SETINDICATORCURRENT, 0)
+
self.SendScintilla(QsciScintilla.SCI_INDICATORFILLRANGE,
+
occs[0], text_len)
+
+
# -- this is somewhat buggy : it was meant to change the color
+
# -- but somewhy the colouring suddenly changes colour.
+
+
# self.SendScintilla(Qsci.QsciScintilla.SCI_STARTSTYLING, occs[0], 0xFF)
+
# self.SendScintilla(Qsci.QsciScintilla.SCI_SETSTYLING,
+
# textLen,
+
# styles["HIGHLIGHT"][0])
+
+
self.highlit = occurences
+
+
def clear_highlights(self):
+
if self.highlit is None:
+
return
+
+
for occs in self.highlit:
+
self.SendScintilla(QsciScintilla.SCI_SETINDICATORCURRENT, 0)
+
self.SendScintilla(QsciScintilla.SCI_INDICATORCLEARRANGE,
+
occs[0], occs[1] - occs[0])
+
self.highlit = None
+
+
def text_search(self, text, from_start, highlight_all, re=False,
+
cs=True, wo=False, wrap=True, forward=True,
+
line=-1, index=-1, show=True):
+
+
if text is not None:
+
if highlight_all:
+
self.clear_highlights()
+
self.highlight_occurences(text)
+
+
if from_start:
+
self.setCursorPosition(0, 0)
+
+
match = self.findFirst(text, re, cs, wo, wrap, forward, line, index, show)
+
+
def text_replace(self, text, sub, from_start, re=False,
+
cs=True, wo=False, wrap=True, forward=True,
+
line=-1, index=-1, show=True):
+
+
if text is not None and sub is not None:
+
self.clear_highlights()
+
self.highlight_occurences(text)
+
+
if from_start:
+
self.setCursorPosition(0, 0)
+
+
match = self.findFirst(text, re, cs, wo, wrap, forward, line, index, show)
+
if match:
+
self.replace(sub)
+
+
def text_replace_all(self, text, sub, from_start, re=False,
+
cs=True, wo=False, wrap=True, forward=True,
+
line=-1, index=-1, show=True):
+
+
if text is not None and sub is not None:
+
self.clear_highlights()
+
+
self.SendScintilla(QsciScintilla.SCI_SETTARGETSTART, 0)
+
end_pos = self.SendScintilla(QsciScintilla.SCI_GETLENGTH)
+
self.SendScintilla(QsciScintilla.SCI_SETTARGETEND, end_pos)
+
+
print((self.SendScintilla(QsciScintilla.SCI_SEARCHINTARGET, len(text), text)))
+
+
# match = self.findFirst(text, re, cs, wo, wrap, forward, line, index, show)
+
# if match:
+
# self.replace(sub)
+
+
# must set lexer paper background color _and_ editor background color it seems
+
def set_background_color(self, color):
+
self.SendScintilla(QsciScintilla.SCI_STYLESETBACK, QsciScintilla.STYLE_DEFAULT, QColor(color))
+
self.lexer.setPaperBackground(QColor(color))
+
+
def set_margin_background_color(self, color):
+
self.setMarginsBackgroundColor(QColor(color))
+
+
def on_margin_clicked(self, nmargin, nline, modifiers):
+
# Toggle marker for the line the margin was clicked on
+
if self.markersAtLine(nline) != 0:
+
self.markerDelete(nline, self.ARROW_MARKER_NUM)
+
else:
+
self.markerAdd(nline, self.ARROW_MARKER_NUM)
+
+
+# ==============================================================================
+# Gcode widget
+# ==============================================================================
+[docs]class GcodeEditor(EditorBase, QObject):
+
ARROW_MARKER_NUM = 8
+
+
somethingHasChanged = Signal(bool)
+
+
def __init__(self, parent=None):
+
super(GcodeEditor, self).__init__(parent)
+
+
self.filename = ""
+
self._last_filename = None
+
self.auto_show_mdi = True
+
self.last_line = None
+
# self.setEolVisibility(True)
+
+
self.is_editor = False
+
self.text_before_edit = ''
+
+
self.dialog = FindReplaceDialog(parent=self)
+
+
# QSS Hack
+
+
self.backgroundcolor = ''
+
self.marginbackgroundcolor = ''
+
+
# register with the status:task_mode channel to
+
# drive the mdi auto show behaviour
+
#STATUS.task_mode.notify(self.onMdiChanged)
+
#self.prev_taskmode = STATUS.task_mode
+
+
#self.cursorPositionChanged.connect(self.line_changed)
+
self.somethingHasChanged.emit(False)
+
+
@Slot(bool)
+
def setEditable(self, state):
+
if state:
+
self.setReadOnly(False)
+
self.setCaretLineVisible(True)
+
if self.text_before_edit != '':
+
self.text_before_edit = self.text()
+
self.somethingHasChanged.emit(False)
+
else:
+
self.setReadOnly(True)
+
self.setCaretLineVisible(False)
+
self.somethingHasChanged.emit(self.text_before_edit != self.text())
+
+
@Slot(str)
+
def setFilename(self, path):
+
self.filename = path
+
+
@Slot()
+
def save(self):
+
save_file = QFile(str(STATUS.file))
+
+
result = save_file.open(QFile.WriteOnly)
+
if result:
+
LOG.debug("---self.text(): {}".format(self.text()))
+
save_stream = QTextStream(save_file)
+
save_stream << self.text()
+
save_file.close()
+
self.text_before_edit = ''
+
self.somethingHasChanged.emit(False)
+
else:
+
LOG.debug("---save error")
+
+
@Slot()
+
def saveAs(self):
+
file_name = self.save_as_dialog(self.filename)
+
+
if file_name is False:
+
print("saveAs file name error")
+
return
+
self.filename = str(STATUS.file)
+
+
original_file = QFileInfo(self.filename)
+
path = original_file.path()
+
+
new_absolute_path = os.path.join(path, file_name)
+
new_file = QFile(new_absolute_path)
+
+
result = new_file.open(QFile.WriteOnly)
+
if result:
+
save_stream = QTextStream(new_file)
+
save_stream << self.text()
+
new_file.close()
+
self.text_before_edit = ''
+
self.somethingHasChanged.emit(False)
+
+
@Slot()
+
def find_replace(self):
+
self.dialog.show()
+
+
def search_text(self, find_text, highlight_all):
+
from_start = False
+
if find_text != "":
+
self.text_search(find_text, from_start, highlight_all)
+
+
def replace_text(self, find_text, replace_text):
+
from_start = False
+
if find_text != "" and replace_text != "":
+
self.text_replace(find_text, replace_text, from_start)
+
+
def replace_all_text(self, find_text, replace_text):
+
from_start = True
+
if find_text != "" and replace_text != "":
+
self.text_replace_all(find_text, find_text, from_start)
+
+
@Property(bool)
+
def is_editor(self):
+
return self._is_editor
+
+
@is_editor.setter
+
def is_editor(self, enabled):
+
self._is_editor = enabled
+
if not self._is_editor:
+
STATUS.file.notify(self.load_program)
+
STATUS.motion_line.onValueChanged(self.highlight_line)
+
+
# STATUS.connect('line-changed', self.highlight_line)
+
# if self.idle_line_reset:
+
# STATUS.connect('interp_idle', lambda w: self.set_line_number(None, 0))
+
+
@Property(str)
+
def backgroundcolor(self):
+
"""Property to set the background color of the GCodeEditor (str).
+
+
sets the background color of the GCodeEditor
+
"""
+
return self._backgroundcolor
+
+
@backgroundcolor.setter
+
def backgroundcolor(self, color):
+
self._backgroundcolor = color
+
self.set_background_color(color)
+
+
@Property(str)
+
def marginbackgroundcolor(self):
+
"""Property to set the background color of the GCodeEditor margin (str).
+
+
sets the background color of the GCodeEditor margin
+
"""
+
return self._marginbackgroundcolor
+
+
@marginbackgroundcolor.setter
+
def marginbackgroundcolor(self, color):
+
self._marginbackgroundcolor = color
+
self.set_margin_background_color(color)
+
+
def load_program(self, fname=None):
+
if fname is None:
+
fname = self._last_filename
+
else:
+
self._last_filename = fname
+
self.load_text(fname)
+
# self.zoomTo(6)
+
self.setCursorPosition(0, 0)
+
+
+
def load_text(self, fname):
+
try:
+
fp = os.path.expanduser(fname)
+
self.setText(open(fp).read())
+
except:
+
LOG.error('File path is not valid: {}'.format(fname))
+
self.setText('')
+
return
+
+
self.last_line = None
+
self.ensureCursorVisible()
+
self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET)
+
+
def highlight_line(self, line):
+
# if STATUS.is_auto_running():
+
# if not STATUS.old['file'] == self._last_filename:
+
# LOG.debug('should reload the display')
+
# self.load_text(STATUS.old['file'])
+
# self._last_filename = STATUS.old['file']
+
self.markerAdd(line, self.ARROW_MARKER_NUM)
+
if self.last_line:
+
self.markerDelete(self.last_line, self.ARROW_MARKER_NUM)
+
self.setCursorPosition(line, 0)
+
self.ensureCursorVisible()
+
self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET)
+
self.last_line = line
+
+
def set_line_number(self, line):
+
pass
+
+
def select_lineup(self):
+
line, col = self.getCursorPosition()
+
LOG.debug(line)
+
self.setCursorPosition(line - 1, 0)
+
self.highlight_line(line - 1)
+
+
def select_linedown(self):
+
line, col = self.getCursorPosition()
+
LOG.debug(line)
+
self.setCursorPosition(line + 1, 0)
+
self.highlight_line(line + 1)
+
+
+
# 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
+
+
+# more complex dialog required by find replace
+[docs]class FindReplaceDialog(QDialog):
+
def __init__(self, parent):
+
super(FindReplaceDialog, self).__init__(parent)
+
+
self.parent = parent
+
self.setWindowTitle("Find Replace")
+
self.setFixedSize(400, 200)
+
+
main_layout = QVBoxLayout()
+
+
find_layout = QHBoxLayout()
+
replace_layout = QHBoxLayout()
+
options_layout = QHBoxLayout()
+
buttons_layout = QHBoxLayout()
+
+
find_label = QLabel()
+
find_label.setText("Find:")
+
+
self.find_input = QLineEdit()
+
+
find_layout.addWidget(find_label)
+
find_layout.addWidget(self.find_input)
+
+
replace_label = QLabel()
+
replace_label.setText("Replace:")
+
+
self.replace_input = QLineEdit()
+
+
replace_layout.addWidget(replace_label)
+
replace_layout.addWidget(self.replace_input)
+
+
self.close_button = QPushButton()
+
self.close_button.setText("Close")
+
+
self.find_button = QPushButton()
+
self.find_button.setText("Find")
+
+
self.replace_button = QPushButton()
+
self.replace_button.setText("Replace")
+
+
self.all_button = QPushButton()
+
self.all_button.setText("Replace All")
+
+
buttons_layout.addWidget(self.close_button)
+
buttons_layout.addWidget(self.find_button)
+
buttons_layout.addWidget(self.replace_button)
+
buttons_layout.addWidget(self.all_button)
+
+
self.highlight_result = QCheckBox()
+
self.highlight_result.setText("highlight results")
+
+
options_layout.addWidget(self.highlight_result)
+
+
main_layout.addLayout(find_layout)
+
main_layout.addLayout(replace_layout)
+
main_layout.addLayout(options_layout)
+
main_layout.addLayout(buttons_layout)
+
+
self.setLayout(main_layout)
+
+
self.find_button.clicked.connect(self.find_text)
+
self.replace_button.clicked.connect(self.replace_text)
+
self.all_button.clicked.connect(self.replace_all_text)
+
self.close_button.clicked.connect(self.hide_dialog)
+
+
def find_text(self):
+
find_text = self.find_input.text()
+
highlight = self.highlight_result.isChecked()
+
+
self.parent.search_text(find_text, highlight)
+
+
def replace_text(self):
+
find_text = self.find_input.text()
+
replace_text = self.replace_input.text()
+
+
self.parent.replace_text(find_text, replace_text)
+
+
def replace_all_text(self):
+
find_text = self.find_input.text()
+
replace_text = self.replace_input.text()
+
+
if find_text == "":
+
return
+
+
self.parent.replace_all_text(find_text, replace_text)
+
+
def hide_dialog(self):
+
self.hide()
+
+# ==============================================================================
+# For testing
+# ==============================================================================
+# if __name__ == "__main__":
+# from qtpy.QtGui import QApplication
+#
+# app = QApplication(sys.argv)
+# editor = GcodeEditor(standalone=True)
+# editor.show()
+#
+# editor.setText(open(sys.argv[0]).read())
+# app.exec_()
+