Files
linuxcncAllWasm/lib/python/qtvcp/widgets/gcode_editor.py
2026-08-20 06:17:39 +08:00

1231 lines
44 KiB
Python

#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
# 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/
# https://qscintilla.com/simple-example/
import sys
import os
import re
from qtpy.QtCore import Property, Signal, QSize
from qtpy.QtGui import QFont, QFontMetrics, QColor, QIcon
from qtpy.QtWidgets import QWidget, QAction,\
QVBoxLayout, QToolBar, QLineEdit, QHBoxLayout, QMessageBox, \
QFrame, QLabel, QStyle
from qtvcp.widgets.widget_baseclass import _HalWidgetBase
from qtvcp.core import Status, Info, Action
from qtvcp import logger
# Instantiate the libraries with global reference
# INFO holds INI file details
# STATUS gives us status messages from linuxcnc
# LOG is for running code logging
STATUS = Status()
INFO = Info()
ACTION = Action()
LOG = logger.getLogger(__name__)
# Force the log level for this module
# LOG.setLevel(logger.DEBUG) # One of DEBUG, INFO, WARNING, ERROR, CRITICAL
# load this after Logging set up so we get a nice dialog.
import qtpy
try:
from qtpy.Qsci import QsciScintilla, QsciLexerCustom, QsciLexerPython
except ImportError:
if qtpy.PYQT5:
try:
from PyQt5.Qsci import QsciScintilla, QsciLexerCustom, QsciLexerPython
except ImportError as e:
LOG.critical("Can't import QsciScintilla - is package python3-pyqt5.qsci installed?", exc_info=e)
sys.exit(1)
else:
try:
from PyQt6.Qsci import QsciScintilla, QsciLexerCustom, QsciLexerPython
except ImportError as e:
LOG.critical("Can't import QsciScintilla - is package python3-pyqt6.qsci installed?", exc_info=e)
sys.exit(1)
class GcodeLexer(QsciLexerCustom):
""" QSciLexer for parsing and highlighting G-code """
def __init__(self, parent):
super(GcodeLexer, self).__init__(parent)
# Style/Token Types (Used for Styling & Regex)
self._styles = {
0: 'Default',
1: 'Comment',
2: 'Gcode',
3: 'Mcode',
4: 'Axis',
5: 'Other',
6: 'AxisValue',
7: 'OtherValue',
}
for key, value in self._styles.items():
setattr(self, value, key)
def language(self):
return "G-code"
def description(self, style):
return self._styles.get(style, "")
def styleText(self, start, end):
editor = self.editor()
if editor is None:
return
self.startStyling(start)
# 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)
source = source.decode("utf-8", "ignore")
else:
source = str(editor.text())[start:end]
if not source:
return
re_tokens = {
1: r"(?:[N]\d+|\(.*?\)|;.*)", # LineNo and Comment
2: r"[G]\s*\d{1,2}\.\d\s|[G]\s*\d{1,2}", # Gcode
3: r"[M]\s*\d{1,3}", # Mcode
4: r"[XYZABCUVW]{1}(?:[+-]?[\d\.\s\][+-]+|\#\<.*\>|\[.*\]|\#\d+)", # Axis
5: r"[EFHIJKDQLRPST$]{1}(?:[+-]?[\d\.\s]+|\#\<.*\>|\[.*\]|\#\d+)", # Other (feed,rpm,radius,etc)
0: r"\s+|\w+|\W", # Default (fallback)
}
re_comment_cmd = r"(?:\(\s*(?:print,|debug,|msg,|logopen,|logappend,|logclose|log,|pyrun,|pyreload|abort,|probeopen|probeclose)|^\s*\;py,)"
re_string = "|".join(re_tokens.values())
p = re.compile(re_string, re.IGNORECASE)
for line in source.splitlines(True):
token_list = [(token, len(bytearray(token, "utf-8"))) for token in p.findall(line)]
num_comment_cmds = len(re.findall(re_comment_cmd, line, re.IGNORECASE))
for token in token_list:
if re.match(re_tokens[self.Comment], token[0], re.IGNORECASE):
m = re.search(re_comment_cmd, token[0], re.IGNORECASE)
if m:
num_comment_cmds -= 1
if m and num_comment_cmds == 0:
# Only highlight last comment_cmd on line
self.setStyling(1, self.Comment)
self.setStyling(m.span()[1] - 1, self.Other)
self.setStyling(len(token[0]) - m.span()[1], self.Comment)
else:
self.setStyling(token[1], self.Comment)
elif re.match(re_tokens[self.Gcode], token[0], re.IGNORECASE):
self.setStyling(token[1], self.Gcode)
elif re.match(re_tokens[self.Mcode], token[0], re.IGNORECASE):
self.setStyling(token[1], self.Mcode)
elif re.match(re_tokens[self.Axis], token[0], re.IGNORECASE):
self.setStyling(1, self.Axis)
self.setStyling(token[1] - 1, self.AxisValue)
elif re.match(re_tokens[self.Other], token[0], re.IGNORECASE):
self.setStyling(1, self.Other)
self.setStyling(token[1] - 1, self.OtherValue)
else:
self.setStyling(token[1], self.Default)
##########################################################
# Base editor class
##########################################################
class EditorBase(QsciScintilla):
CURRENT_MARKER_NUM = 0
USER_MARKER_NUM = 1
# Default Styles
# get/set function for font and colors styles will return
# styleFont[0] and _styleColor[0] as defaults when they are not
# set in the dict manually, or by CSS properties.
_styleFont = {
0: QFont("Courier", 11), # Default Font
# 2: QFont("Courier", 20, weight=QFont.Bold), # Gcode
# 5: QFont("Courier", 11, weight=QFont.Bold), # Other
# "Margins": QFont("Courier", 9), # Margins
}
_styleColor = {
0: QColor("#000000"), # Color0 (Default)
1: QColor("#434d3f"), # Color1 (Lexer Comment)
2: QColor("#ba220b"), # Color2 (Lexer Gcode)
3: QColor("#f56b1b"), # Color3 (Lexer Mcode)
4: QColor("#1883c9"), # Color4 (Lexer Axis)
5: QColor("#dd30f0"), # Color5 (Lexer Other)
6: QColor("#0e5482"), # Color6 (Lexer Axis Value)
7: QColor("#a420b3"), # Color7 (Lexer Other Value)
"Margins": QColor("#666769"), # Margins
}
# keep track of the property value
_styleBackgroundColor = QColor("#c0c0c0")
_styleCursorColor = QColor("white")
_styleMarginsBackgroundColor = QColor("#cccccc")
_styleMarkerBackgroundColor = QColor("#a5a526")
_styleSelectionBackgroundColor = QColor("#001111")
_styleSelectionForegroundColor = QColor("#ffffff")
_styleSyntaxHighlightEnabled = True
def __init__(self, parent=None):
super(EditorBase, self).__init__(parent)
self.lexer = None
self.lexer_num_styles = 0 # updated when lexer is loaded
self._lastUserLine = 0
self.setReadOnly(True) # don't allow editing by default
self.set_lexer("g-code")
self._marginWidth = '00000'
# Margin 0 is used for line numbers
self.setMarginWidth(0, self._marginWidth)
self.linesChanged.connect(self.on_lines_changed)
self.setMarginLineNumbers(0, True)
# Clickable margin for showing markers
self.marginClicked.connect(self.on_margin_clicked)
self.setMarginMarkerMask(0, 0b1111)
self.setMarginSensitivity(0, True)
# setting _marker_ margin width
self.setMarginWidth(1, 0)
# Gcode highlight current line
self.currentHandle = self.markerDefine(QsciScintilla.MarkerSymbol.Background,
self.CURRENT_MARKER_NUM)
self.setColorMarkerBackground(self.getColorMarkerBackground())
# User Highlight line (when clicking margin)
self.userHandle = self.markerDefine(QsciScintilla.MarkerSymbol.Background,
self.USER_MARKER_NUM)
self.setMarkerBackgroundColor(QColor("#ffc0c0"),
self.USER_MARKER_NUM)
# Brace matching: enable for a brace immediately before or after
# the current position
self.setBraceMatching(QsciScintilla.BraceMatch.SloppyBraceMatch)
# Current line visible with special background color
self.setCaretLineVisible(False)
self.SendScintilla(QsciScintilla.SCI_GETCARETLINEVISIBLEALWAYS, True)
self.setCaretLineBackgroundColor(QColor("#ffe4e4"))
self.ensureLineVisible(True)
# Don't want to see the horizontal scrollbar at all
# Use raw message to Scintilla here (all messages are documented
# here: http://www.scintilla.org/ScintillaDoc.html)
#self.SendScintilla(QsciScintilla.SCI_SETHSCROLLBAR, 0)
self.SendScintilla(QsciScintilla.SCI_SETSCROLLWIDTH, 700)
self.SendScintilla(QsciScintilla.SCI_SETSCROLLWIDTHTRACKING)
# not too small
self.setMinimumSize(200, 100)
self.filepath = None
def set_lexer(self, lexer_type=None):
self.lexer = None
self.lexer_num_styles = 0
if lexer_type is None or not self._styleSyntaxHighlightEnabled:
self.lexer = None
elif lexer_type.lower() == "g-code":
self.lexer = GcodeLexer(self)
elif lexer_type.lower() == "python":
self.lexer = QsciLexerPython(self)
# Get Number of lexer styles
if self.lexer is not None:
while self.lexer.description(self.lexer_num_styles) != "":
self.lexer_num_styles += 1
self.setLexer(self.lexer)
self.refresh_styles()
#print("Loaded Lexer {} with {} Styles".format(lexer_type, self.lexer_num_styles))
def refresh_styles(self):
self.setDefaultFont(self.getDefaultFont())
self.set_font_colors()
self.setColorBackground(self.getColorBackground())
self.setColorMarginsBackground(self.getColorMarginsBackground())
self.setSelectionBackgroundColor(self.getColorSelectionBackground())
self.setSelectionForegroundColor(self.getColorSelectionForeground())
def set_font_colors(self):
self.setColor(self.getColor0())
self.setColorMarginsForeground(self.getColorMarginsForeground())
if self.lexer is not None:
for i in range(0, self.lexer_num_styles):
self.lexer.setColor(self._styleColor.get(i, self._styleColor[0]), i)
def set_margin_width(self):
self.setMarginWidth(0, self._marginWidth)
def set_margin_metric(self, width):
fontmetrics = QFontMetrics(self.getFontMargins())
self.setMarginWidth(0, fontmetrics.width("0" * width) + 6)
# reset margin width when number of lines change
def on_lines_changed(self):
if len(str(self.lines())) < 3:
self._marginWidth = '0000'
else:
self._marginWidth = str(self.lines())+'0'
self.setMarginWidth(0, self._marginWidth)
def on_margin_clicked(self, nmargin, nline, modifiers):
# Toggle marker for the line the margin was clicked on
# 2 means it's already there
if self.markersAtLine(nline) != 2:
self.markerDelete(self._lastUserLine, self.USER_MARKER_NUM)
self.markerAdd(nline, self.USER_MARKER_NUM)
elif self._lastUserLine != nline:
self.markerAdd(nline, self.USER_MARKER_NUM)
self.markerDelete(self._lastUserLine, self.USER_MARKER_NUM)
else:
self.markerDelete(self._lastUserLine, self.USER_MARKER_NUM)
self._lastUserLine = 0
return
self._lastUserLine = nline
def mouseDoubleClickEvent(self, event):
pass
def new_text(self):
self.setText('')
def load_text(self, filepath):
self.filepath = filepath
if filepath is None:
return
try:
fp = os.path.expanduser(filepath)
with open(fp) as f:
self.setText(f.read())
except OSError as e:
LOG.error("load_text(): {}".format(e))
self.setText('')
return
except Exception as e:
LOG.error("load_text(): {}".format(e))
self.setText('')
return
self.ensureCursorVisible()
self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET)
self.setModified(False)
def save_text(self):
try:
with open(self.filepath + 'text', "w") as f:
f.write(self.text())
except OSError as e:
LOG.error("save_text(): {}".format(e))
def replace_text(self, text):
self.replace(text)
def search(self, text, re=False, case=False, word=False, wrap=False, fwd=True):
self.findFirst(text, re, case, word, wrap, fwd)
def search_Next(self):
self.SendScintilla(QsciScintilla.SCI_SEARCHANCHOR)
self.findNext()
# Style get/set functions
# Default Color
def getColor0(self):
return self._styleColor[0]
def setColor0(self, value):
self._styleColor[0] = value
self.setColor(value)
if self.lexer is not None:
self.lexer.setColor(value, 0)
styleColor0 = Property(QColor, getColor0, setColor0)
# Lexer Colors
def getColor1(self):
return self._styleColor.get(1, self._styleColor[0])
def setColor1(self, value):
self._styleColor[1] = value
if self.lexer is not None:
self.lexer.setColor(value, 1)
styleColor1 = Property(QColor, getColor1, setColor1)
def getColor2(self):
return self._styleColor.get(2, self._styleColor[0])
def setColor2(self, value):
self._styleColor[2] = value
if self.lexer is not None:
self.lexer.setColor(value, 2)
styleColor2 = Property(QColor, getColor2, setColor2)
def getColor3(self):
return self._styleColor.get(3, self._styleColor[0])
def setColor3(self, value):
self._styleColor[3] = value
if self.lexer is not None:
self.lexer.setColor(value, 3)
styleColor3 = Property(QColor, getColor3, setColor3)
def getColor4(self):
return self._styleColor.get(4, self._styleColor[0])
def setColor4(self, value):
self._styleColor[4] = value
if self.lexer is not None:
self.lexer.setColor(value, 4)
styleColor4 = Property(QColor, getColor4, setColor4)
def getColor5(self):
return self._styleColor.get(5, self._styleColor[0])
def setColor5(self, value):
self._styleColor[5] = value
if self.lexer is not None:
self.lexer.setColor(value, 5)
styleColor5 = Property(QColor, getColor5, setColor5)
def getColor6(self):
return self._styleColor.get(6, self._styleColor[0])
def setColor6(self, value):
self._styleColor[6] = value
if self.lexer is not None:
self.lexer.setColor(value, 6)
styleColor6 = Property(QColor, getColor6, setColor6)
def getColor7(self):
return self._styleColor.get(7, self._styleColor[0])
def setColor7(self, value):
self._styleColor[7] = value
if self.lexer is not None:
self.lexer.setColor(value, 7)
styleColor7 = Property(QColor, getColor7, setColor7)
# Margins Text Color
def getColorMarginsForeground(self):
return self._styleColor.get("Margins", self._styleColor[0])
def setColorMarginsForeground(self, value):
super(EditorBase, self).setMarginsForegroundColor(value)
self._styleColor["Margins"] = value
styleColorMarginText = Property(QColor, getColorMarginsForeground, setColorMarginsForeground)
# Cursor Color
def getColorCursor(self):
return self._styleCursorColor
def setColorCursor(self, color):
self._styleCursorColor = color
super(EditorBase, self).setCaretForegroundColor(color)
styleColorCursor = Property(QColor, getColorCursor, setColorCursor)
# Backgrounds
def getColorBackground(self):
return self._styleBackgroundColor
def setColorBackground(self, color):
self._styleBackgroundColor = color
#self.SendScintilla(QsciScintilla.SCI_STYLESETBACK, QsciScintilla.STYLE_DEFAULT, color)
self.setPaper(color)
if self.lexer is not None:
self.lexer.setDefaultPaper(color)
for i in range(0, self.lexer_num_styles):
self.lexer.setPaper(color, i)
styleColorBackground = Property(QColor, getColorBackground, setColorBackground)
# Margins Background
def setColorMarginsBackground(self, color):
super(EditorBase, self).setMarginsBackgroundColor(color)
self._styleMarginsBackgroundColor = color
def getColorMarginsBackground(self):
return self._styleMarginsBackgroundColor
styleColorMarginBackground = Property(QColor, getColorMarginsBackground, setColorMarginsBackground)
# Selection Highlight Background & Foreground
def getColorSelectionBackground(self):
return self._styleSelectionBackgroundColor
def setColorSelectionBackground(self, value):
self._styleSelectionBackgroundColor = value
self.setSelectionBackgroundColor(value)
styleColorSelectionBackground = Property(QColor, getColorSelectionBackground, setColorSelectionBackground)
def getColorSelectionForeground(self):
return self._styleSelectionForegroundColor
def setColorSelectionForeground(self, value):
self._styleSelectionForegroundColor = value
self.setSelectionForegroundColor(value)
styleColorSelectionText = Property(QColor, getColorSelectionForeground, setColorSelectionForeground)
# Current Line Marker Background
def getColorMarkerBackground(self):
return self._styleMarkerBackgroundColor
def setColorMarkerBackground(self, value):
self._styleMarkerBackgroundColor = value
self.setMarkerBackgroundColor(value, self.CURRENT_MARKER_NUM)
styleColorMarkerBackground = Property(QColor, getColorMarkerBackground, setColorMarkerBackground)
# Fonts
def setDefaultFont(self, value):
self._styleFont[0] = value
self.setFont(value)
self.setFontMargins(self.getFontMargins())
if self.lexer is not None:
self.lexer.setFont(value)
for i in range(0, self.lexer_num_styles):
self.lexer.setFont(self._styleFont.get(i, self._styleFont[0]), i)
def getDefaultFont(self):
return self._styleFont[0]
styleFont = Property(QFont, getDefaultFont, setDefaultFont)
def getFont0(self):
return self._styleFont[0]
def setFont0(self, value):
self._styleFont[0] = value
self.setFont(value)
if self.lexer is not None:
self.lexer.setFont(value, 0)
styleFont0 = Property(QFont, getFont0, setFont0)
def getFont1(self):
return self._styleFont.get(1, self._styleFont[0])
def setFont1(self, value):
self._styleFont[1] = value
if self.lexer is not None:
self.lexer.setFont(value, 1)
styleFont1 = Property(QFont, getFont1, setFont1)
def getFont2(self):
return self._styleFont.get(2, self._styleFont[0])
def setFont2(self, value):
self._styleFont[2] = value
if self.lexer is not None:
self.lexer.setFont(value, 2)
styleFont2 = Property(QFont, getFont2, setFont2)
def getFont3(self):
return self._styleFont.get(3, self._styleFont[0])
def setFont3(self, value):
self._styleFont[3] = value
if self.lexer is not None:
self.lexer.setFont(value, 3)
styleFont3 = Property(QFont, getFont3, setFont3)
def getFont4(self):
return self._styleFont.get(4, self._styleFont[0])
def setFont4(self, value):
self._styleFont[4] = value
if self.lexer is not None:
self.lexer.setFont(value, 4)
styleFont4 = Property(QFont, getFont4, setFont4)
def getFont5(self):
return self._styleFont.get(5, self._styleFont[0])
def setFont5(self, value):
self._styleFont[5] = value
if self.lexer is not None:
self.lexer.setFont(value, 5)
styleFont5 = Property(QFont, getFont5, setFont5)
def getFont6(self):
return self._styleFont.get(6, self._styleFont[0])
def setFont6(self, value):
self._styleFont[6] = value
if self.lexer is not None:
self.lexer.setFont(value, 6)
styleFont6 = Property(QFont, getFont6, setFont6)
def getFont7(self):
return self._styleFont.get(7, self._styleFont[0])
def setFont7(self, value):
self._styleFont[7] = value
if self.lexer is not None:
self.lexer.setFont(value, 7)
styleFont7 = Property(QFont, getFont7, setFont7)
def getFontMargins(self):
return self._styleFont.get("Margins", self._styleFont[0])
def setFontMargins(self, value):
self._styleFont["Margins"] = value
self.setMarginsFont(value)
styleFontMargin = Property(QFont, getFontMargins, setFontMargins)
# Syntax Highlighting Bool
def getSyntaxHighlightEnabled(self):
return self._styleSyntaxHighlightEnabled
def setSyntaxHighlightEnabled(self, value):
if value is not self._styleSyntaxHighlightEnabled:
self._styleSyntaxHighlightEnabled = value
if not value:
self.set_lexer(None)
else:
self.set_lexer("g-code")
styleSyntaxHighlightEnabled = Property(bool, getSyntaxHighlightEnabled, setSyntaxHighlightEnabled)
##########################################################
# Gcode display widget (intended read-only)
##########################################################
class GcodeDisplay(EditorBase, _HalWidgetBase):
CURRENT_MARKER_NUM = 0
USER_MARKER_NUM = 1
def __init__(self, parent=None):
super(GcodeDisplay, self).__init__(parent)
# linuxcnc defaults
self.idle_line_reset = False
self._last_filename = None
self.auto_show_mdi = True
self.auto_show_manual = False
self.auto_show_preference = True
self.last_line = 0
# keep track of vertical scroll setting in auto mode
self._last_auto_scroll = 0
def _hal_init(self):
self.cursorPositionChanged.connect(self.line_changed)
if self.auto_show_mdi:
STATUS.connect('mode-mdi', self.load_mdi)
STATUS.connect('mdi-history-changed', self.load_mdi)
STATUS.connect('mode-auto', self.reload_last)
STATUS.connect('move-text-lineup', self.select_lineup)
STATUS.connect('move-text-linedown', self.select_linedown)
STATUS.connect('mode-manual', self.load_manual)
if self.auto_show_manual:
STATUS.connect('mode-manual', self.load_manual)
STATUS.connect('machine-log-changed', self.load_manual)
if self.auto_show_preference:
STATUS.connect('show-preference', self.load_preference)
STATUS.connect('file-loaded', self.load_program)
STATUS.connect('reload-display', self.load_program)
STATUS.connect('line-changed', self.external_highlight_request)
STATUS.connect('graphics-line-selected', self.external_highlight_request)
STATUS.connect('command-stopped', lambda w: self.run_stopped())
if self.idle_line_reset:
STATUS.connect('interp_idle', lambda w: self.set_line_number(0))
self.markerDeleteHandle(self.currentHandle)
def load_program(self, w, filename=None):
if filename is None:
filename = self._last_filename
elif 'file_clear.ngc' in filename:
self._last_filename = None
else:
self._last_filename = filename
self.load_text(filename)
#self.zoomTo(6)
self.setCursorPosition(0, 0)
self.markerDeleteHandle(self.currentHandle)
self.setModified(False)
self._lastUserLine = 0
# when switching from MDI to AUTO we need to reload the
# last (linuxcnc loaded) program.
def reload_last(self, w):
self.load_text(STATUS.old['file'])
self.setCursorPosition(0, 0)
# keep track of vertical scroll bar setting
self.verticalScrollBar().setValue(self._last_auto_scroll)
# and margin marker if it is showing
if self._lastUserLine >0:
self.markerAdd(self._lastUserLine, self.USER_MARKER_NUM)
# With the auto_show__mdi option, MDI history is shown
def load_mdi(self, w):
# record scroll position in auto mode's gcode
if STATUS.get_previous_mode() == STATUS.AUTO:
self._last_auto_scroll = self.verticalScrollBar().value()
self.load_text(INFO.MDI_HISTORY_PATH)
self._last_filename = INFO.MDI_HISTORY_PATH
self.setCursorPosition(self.lines()-1, 0)
self.moveMarker(self.lines()-1)
# With the auto_show__mdi option, MDI history is shown
def load_manual(self, w):
# record scroll position in auto mode's gcode
if STATUS.get_previous_mode() == STATUS.AUTO:
self._last_auto_scroll = self.verticalScrollBar().value()
if self.auto_show_manual and STATUS.is_man_mode():
self.load_text(INFO.MACHINE_LOG_HISTORY_PATH)
self.setCursorPosition(self.lines()-1, 0)
def load_preference(self, w):
self.load_text(self.PATHS_.PREFS_FILENAME)
self.setCursorPosition(self.lines()-1, 0)
# external line numbers start at 1 - convert that to start at 0
def external_highlight_request(self, w, line):
if line in (-1, None):
return
if STATUS.is_auto_running():
self.highlight_line(None, line-1)
return
LOG.verbose('editor: got external highlight {}'.format(line))
#self.highlight_line(None, line-1)
self.ensureLineVisible(line-1)
#self.setSelection(line-1,0,line-1,self.lineLength(line-1)-1)
self.moveMarker(line-1)
self.selectAll(False)
def moveMarker(self, line):
if STATUS.is_auto_running() and STATUS.stat.file == '':
self.last_line = 0
return
self.markerDeleteHandle(self.currentHandle)
self.currentHandle = self.markerAdd(line, self.CURRENT_MARKER_NUM)
self.last_line = line
def highlight_line(self, w, line):
LOG.verbose('editor: highlight line {}'.format(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.emit_percent(round(line*100/self.lines()))
self.moveMarker(line)
self.setCursorPosition(line, 0)
self.ensureCursorVisible()
self.SendScintilla(QsciScintilla.SCI_VERTICALCENTRECARET)
def emit_percent(self, percent):
pass
def run_stopped(self):
self.emit_percent(-1)
def set_line_number(self, line):
STATUS.emit('gcode-line-selected', line+1)
def line_changed(self, line, index):
LOG.verbose('Line changed: {}'.format(line))
if STATUS.is_auto_running() is False:
if STATUS.is_mdi_mode():
line_text = str(self.text(line)).strip()
STATUS.emit('mdi-line-selected', line_text, self._last_filename)
else:
self.moveMarker(line)
self.set_line_number(line)
def select_lineup(self, w):
line, col = self.getCursorPosition()
LOG.verbose(line)
self.setCursorPosition(line-1, 0)
self.highlight_line(None, line-1)
def select_linedown(self, w):
line, col = self.getCursorPosition()
LOG.verbose(line)
self.setCursorPosition(line+1, 0)
self.highlight_line(None, line+1)
def jump_line(self, jump):
line, col = self.getCursorPosition()
line = line + jump
LOG.verbose(line)
if line <0:
line = 0
elif line > self.lines()-1:
line = self.lines()-1
self.setCursorPosition(line, 0)
self.highlight_line(None, line)
# overridden functions #
#####################################
def zoomIn(self):
super().zoomIn()
self.set_margin_width()
def zoomOut(self):
super().zoomOut()
self.set_margin_width()
#####################################
# designer recognized getter/setters
# auto_show_mdi status
def set_auto_show_mdi(self, data):
self.auto_show_mdi = data
def get_auto_show_mdi(self):
return self.auto_show_mdi
def reset_auto_show_mdi(self):
self.auto_show_mdi = True
auto_show_mdi_status = Property(bool, get_auto_show_mdi, set_auto_show_mdi, reset_auto_show_mdi)
# designer recognized getter/setters
# auto_show_manual status
def set_auto_show_manual(self, data):
self.auto_show_manual = data
def get_auto_show_manual(self):
return self.auto_show_manual
def reset_auto_show_manual(self):
self.auto_show_manual = True
auto_show_manual_status = Property(bool, get_auto_show_manual, set_auto_show_manual, reset_auto_show_manual)
#############################################
# For Editing Gcode
#############################################
class GcodeEditor(QWidget, _HalWidgetBase):
percentDone = Signal(int)
def __init__(self, parent=None):
super(GcodeEditor, self).__init__(parent)
self.load_dialog_code = 'LOAD'
self.save_dialog_code = 'SAVE'
STATUS.connect('general',self.returnFromDialog)
self.isCaseSensitive = 0
self.setMinimumSize(QSize(300, 200))
self.setWindowTitle("PyQt5 editor test example")
lay = QVBoxLayout()
lay.setContentsMargins(0,0,0,0)
self.setLayout(lay)
# make editor
self.editor = GcodeDisplay(self)
# class patch editor's function to ours
# so we get the lines percent update
self.editor.emit_percent = self.emit_percent
self.editor.setReadOnly(True)
self.editor.setModified(False)
################################
# add menubar actions
################################
# Create new action
icon = self.style().standardIcon( QStyle.SP_FileIcon)
self.newAction = QAction(icon, 'New', self)
self.newAction.setShortcut('Ctrl+N')
self.newAction.setStatusTip('New document')
self.newAction.triggered.connect(self.newCall)
# Create open action
icon = self.style().standardIcon( QStyle.SP_DirOpenIcon)
self.openAction = QAction(icon, '&Open', self)
self.openAction.setShortcut('Ctrl+O')
self.openAction.setStatusTip('Open document')
self.openAction.triggered.connect(self.openCall)
# Create save action
icon = self.style().standardIcon( QStyle.SP_DialogSaveButton)
self.saveAction = QAction(icon, '&Save', self)
self.saveAction.setShortcut('Ctrl+S')
self.saveAction.setStatusTip('Save document')
self.saveAction.triggered.connect(self.saveCall)
# Create exit action
self.exitAction = QAction(QIcon.fromTheme('application-exit'), '&Exit', self)
self.exitAction.setShortcut('Ctrl+Q')
self.exitAction.setStatusTip('Exit application')
self.exitAction.triggered.connect(self.exitCall)
# Create gcode lexer action
self.gCodeLexerAction = QAction(QIcon.fromTheme('lexer.png'), '&Gcode\nLexer', self)
self.gCodeLexerAction.setCheckable(1)
self.gCodeLexerAction.setShortcut('Ctrl+G')
self.gCodeLexerAction.setStatusTip('Set Gcode highlighting')
self.gCodeLexerAction.triggered.connect(self.gcodeLexerCall)
# Create gcode lexer action
self.pythonLexerAction = QAction(QIcon.fromTheme('lexer.png'), '&Python\nLexer', self)
self.pythonLexerAction.setShortcut('Ctrl+P')
self.pythonLexerAction.setStatusTip('Set Python highlighting')
self.pythonLexerAction.triggered.connect(self.pythonLexerCall)
# Create toolbar and add action
self.toolBar = QToolBar('File')
self.toolBar.addAction(self.newAction)
self.toolBar.addAction(self.openAction)
self.toolBar.addAction(self.saveAction)
self.toolBar.addAction(self.exitAction)
self.toolBar.addSeparator()
# add lexer actions
self.toolBar.addAction(self.gCodeLexerAction)
self.toolBar.addAction(self.pythonLexerAction)
self.toolBar.addSeparator()
self.label = QLabel('''<html><head/><body><p><span style=" font-size:20pt;
font-weight:600;">Edit Mode</span></p></body></html>''')
self.toolBar.addWidget(self.label)
# create a frame for buttons
self.topBox = QHBoxLayout()
self.topBox.addWidget(self.toolBar)
self.topMenu = QFrame()
self.topMenu.setLayout(self.topBox)
# add widgets
lay.addWidget(self.topMenu)
lay.addWidget(self.editor)
lay.addWidget(self.createGroup())
self.readOnlyMode()
def createGroup(self):
self.bottomMenu = QFrame()
self.searchText = QLineEdit(self)
self.searchText.setStatusTip('Text to search for')
self.replaceText = QLineEdit(self)
self.replaceText.setStatusTip('Replace search text with this text')
toolBar = QToolBar()
# Create new action
undoAction = QAction(QIcon.fromTheme('edit-undo'), 'Undo', self)
undoAction.setStatusTip('Undo')
undoAction.triggered.connect(self.undoCall)
toolBar.addAction(undoAction)
# create redo action
redoAction = QAction(QIcon.fromTheme('edit-redo'), 'Redo', self)
redoAction.setStatusTip('Redo')
redoAction.triggered.connect(self.redoCall)
toolBar.addAction(redoAction)
toolBar.addSeparator()
# create replace action
replaceAction = QAction(QIcon.fromTheme('edit-find-replace'), 'Replace', self)
replaceAction.setStatusTip('Replace text')
replaceAction.triggered.connect(self.replaceCall)
toolBar.addAction(replaceAction)
# create find action
findAction = QAction(QIcon.fromTheme('edit-find'), 'Find', self)
findAction.setStatusTip('Find next occurrence of text')
findAction.triggered.connect(self.findCall)
toolBar.addAction(findAction)
# create next action
previousAction = QAction(QIcon.fromTheme('go-previous'), 'Find Previous', self)
previousAction.setStatusTip('Find previous occurrence of text')
previousAction.triggered.connect(self.previousCall)
toolBar.addAction(previousAction)
toolBar.addSeparator()
# create case action
caseAction = QAction(QIcon.fromTheme('edit-case'), 'Aa', self)
caseAction.setStatusTip('Toggle between any case and match case')
caseAction.setCheckable(1)
caseAction.triggered.connect(self.caseCall)
toolBar.addAction(caseAction)
self.bottomBox = QHBoxLayout()
self.bottomBox.addWidget(toolBar)
self.bottomBox.addWidget(self.searchText)
self.bottomBox.addWidget(self.replaceText)
self.bottomBox.addStretch(1)
self.bottomMenu.setLayout(self.bottomBox)
return self.bottomMenu
# callback functions built for easy class patching ##########
# want to refrain from renaming these functions as it will break
# any class patch user's use
# we split them like this so a user can intercept the callback
# but still call the original functionality
def caseCall(self):
self.case()
def case(self):
self.isCaseSensitive -=1
self.isCaseSensitive *=-1
def exitCall(self):
self.exit()
def exit(self):
if self.editor.isModified():
result = self.killCheck()
if result:
self.readOnlyMode()
def findCall(self):
self.find()
def find(self):
self.editor.search(str(self.searchText.text()),
re=False, case=self.isCaseSensitive,
word=False, wrap= True, fwd=True)
def previousCall(self):
self.previous()
def previous(self):
self.editor.setCursorPosition(self.editor.getSelection()[0],
self.editor.getSelection()[1])
self.editor.search(str(self.searchText.text()),
re=False, case=self.isCaseSensitive,
word=False, wrap=True, fwd=False)
def gcodeLexerCall(self):
self.gcodeLexer()
def gcodeLexer(self):
self.editor.set_lexer("g-code")
def nextCall(self):
self.next()
def next(self):
self.editor.search(str(self.searchText.text()),
re=False, case=self.isCaseSensitive,
word=False, wrap=True, fwd=False)
self.editor.search_Next()
def newCall(self):
self.new()
def new(self):
if self.editor.isModified():
result = self.killCheck()
if result:
self.editor.new_text()
else:
self.editor.new_text()
def openCall(self):
self.open()
def open(self):
self.getFileName()
def openReturn(self,f):
ACTION.OPEN_PROGRAM(f)
self.editor.setModified(False)
def pythonLexerCall(self):
self.pythonLexer()
def pythonLexer(self):
self.editor.set_lexer("python")
def redoCall(self):
self.redo()
def redo(self):
self.editor.redo()
def replaceCall(self):
self.replace()
def replace(self):
self.editor.replace_text(str(self.replaceText.text()))
self.editor.search(str(self.searchText.text()),
re=False, case=self.isCaseSensitive,
word=False, wrap=True, fwd=True)
def saveCall(self):
self.save()
def save(self):
self.getSaveFileName()
def saveReturn(self, fname):
saved = ACTION.SAVE_PROGRAM(self.editor.text(), fname)
if saved is not None:
self.editor.setModified(False)
ACTION.OPEN_PROGRAM(saved)
def undoCall(self):
self.undo()
def undo(self):
self.editor.undo()
# helper functions ############################################
def _hal_init(self):
# name the top and bottom frames so it's easier to style
self.bottomMenu.setObjectName('%sBottomButtonFrame'% self.objectName())
self.topMenu.setObjectName('%sTopButtonFrame'% self.objectName())
self.editor.setObjectName('{}_display'.format( self.objectName()))
def editMode(self):
self.topMenu.show()
self.bottomMenu.show()
self.editor.setReadOnly(False)
def readOnlyMode(self):
self.topMenu.hide()
self.bottomMenu.hide()
self.editor.setReadOnly(True)
def getFileName(self):
mess = {'NAME':self.load_dialog_code,'ID':'%s__' % self.objectName(),
'TITLE':'Load Editor'}
STATUS.emit('dialog-request', mess)
def getSaveFileName(self):
mess = {'NAME':self.save_dialog_code,'ID':'%s__' % self.objectName(),
'TITLE':'Save Editor', 'FILENAME':self.editor._last_filename}
STATUS.emit('dialog-request', mess)
# process the STATUS return message
def returnFromDialog(self, w, message):
if message.get('NAME') == self.load_dialog_code:
path = message.get('RETURN')
code = bool(message.get('ID') == '%s__'% self.objectName())
if path and code:
self.openReturn(path)
elif message.get('NAME') == self.save_dialog_code:
path = message.get('RETURN')
code = bool(message.get('ID') == '%s__'% self.objectName())
if path and code:
self.saveReturn(path)
def killCheck(self):
choice = QMessageBox.question(self, 'Warning!!',
"This file has changed since loading...Still want to proceed?",
QMessageBox.Yes | QMessageBox.No)
if choice == QMessageBox.Yes:
return True
else:
return False
def emit_percent(self, percent):
self.percentDone.emit(int(percent))
def select_lineup(self):
self.editor.select_lineup(None)
def select_linedown(self):
self.editor.select_linedown(None)
def select_line(self, line):
self.editor.highlight_line(None, line)
def jump_line(self, jump):
self.editor.jump_line(jump)
def get_line(self):
return self.editor.getCursorPosition()[0] +1
def set_margin_metric(self,width):
self.editor.set_margin_metric(width)
def set_font(self, font):
self.editor.setDefaultFont(font)
def isReadOnly(self):
return self.editor.isReadOnly()
# designer recognized getter/setters
# auto_show_mdi status
# These adjust the self.editor instance
def set_auto_show_mdi(self, data):
self.editor.auto_show_mdi = data
def get_auto_show_mdi(self):
return self.editor.auto_show_mdi
def reset_auto_show_mdi(self):
self.editor.auto_show_mdi = True
auto_show_mdi_status = Property(bool, get_auto_show_mdi, set_auto_show_mdi, reset_auto_show_mdi)
# designer recognized getter/setters
# auto_show_manual status
def set_auto_show_manual(self, data):
self.editor.auto_show_manual = data
def get_auto_show_manual(self):
return self.editor.auto_show_manual
def reset_auto_show_manual(self):
self.editor.auto_show_manual = True
auto_show_manual_status = Property(bool, get_auto_show_manual, set_auto_show_manual, reset_auto_show_manual)
# for direct testing
if __name__ == "__main__":
from qtpy.QtWidgets import *
from qtpy.QtCore import *
from qtpy.QtGui import *
sample_text = r"""( ----- Simple G-Code -----)
G17 G20 G40 G49 G54 G80 G90 G94
M7
G0 Z10
F400
G1 Z1
G3 X50 Y50 Z1 I-7.5 J0
G0 Z30
G5.2 X3.53 Y-1.50 P2
X5.33 Y-11.01 P1
X3.52 Y-24.00 P1
X0.0 Y-29.56 P1
G5.3
G0 Z2
G1 Z-5 F100
F600
( ----- Line Numbers ----- )
N1
N20
N300
N030 (insert numbers in place of .)
N000 G1 X20
N98765 G0 Z30"""
app = QApplication(sys.argv)
w = GcodeEditor()
w.editMode()
if len(sys.argv) > 1:
w.editor.load_text(sys.argv[1])
else:
w.editor.setText(sample_text)
if 0:
w.toolBar.hide()
if 1:
w.pythonLexerAction.setVisible(True)
w.gCodeLexerAction.setVisible(True)
if 1:
w.openAction.setVisible(False)
w.newAction.setVisible(False)
if 0:
w.saveAction.setVisible(False)
w.exitAction.setVisible(False)
if 1:
w.label.setText('<b>Edit mode title label</b>')
w.show()
sys.exit( app.exec() )