|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the MicroPython REPL widget. |
|
8 """ |
|
9 |
|
10 from __future__ import unicode_literals |
|
11 |
|
12 import re |
|
13 |
|
14 from PyQt5.QtCore import pyqtSlot, Qt, QPoint, QEvent |
|
15 from PyQt5.QtGui import QColor, QKeySequence, QTextCursor |
|
16 from PyQt5.QtWidgets import ( |
|
17 QWidget, QMenu, QApplication, QHBoxLayout, QSpacerItem, QSizePolicy) |
|
18 try: |
|
19 from PyQt5.QtSerialPort import QSerialPortInfo # __IGNORE_WARNING__ |
|
20 HAS_QTSERIALPORT = True |
|
21 except ImportError: |
|
22 HAS_QTSERIALPORT = False |
|
23 |
|
24 from E5Gui.E5ZoomWidget import E5ZoomWidget |
|
25 |
|
26 from .Ui_MicroPythonReplWidget import Ui_MicroPythonReplWidget |
|
27 |
|
28 from . import MicroPythonDevices |
|
29 |
|
30 import Globals |
|
31 import UI.PixmapCache |
|
32 |
|
33 |
|
34 class MicroPythonReplWidget(QWidget, Ui_MicroPythonReplWidget): |
|
35 """ |
|
36 Class implementing the MicroPython REPL widget. |
|
37 """ |
|
38 ZoomMin = -10 |
|
39 ZoomMax = 20 |
|
40 |
|
41 def __init__(self, parent=None): |
|
42 """ |
|
43 Constructor |
|
44 |
|
45 @param parent reference to the parent widget |
|
46 @type QWidget |
|
47 """ |
|
48 super(MicroPythonReplWidget, self).__init__(parent) |
|
49 self.setupUi(self) |
|
50 |
|
51 self.deviceIconLabel.setPixmap(MicroPythonDevices.getDeviceIcon( |
|
52 "", False)) |
|
53 self.checkButton.setIcon(UI.PixmapCache.getIcon("question")) |
|
54 self.runButton.setIcon(UI.PixmapCache.getIcon("start")) |
|
55 self.replButton.setIcon(UI.PixmapCache.getIcon("terminal")) |
|
56 self.filesButton.setIcon(UI.PixmapCache.getIcon("filemanager")) |
|
57 self.chartButton.setIcon(UI.PixmapCache.getIcon("chart")) |
|
58 self.disconnectButton.setIcon(UI.PixmapCache.getIcon("disconnect")) |
|
59 |
|
60 self.__zoomLayout = QHBoxLayout() |
|
61 spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, |
|
62 QSizePolicy.Minimum) |
|
63 self.__zoomLayout.addSpacerItem(spacerItem) |
|
64 |
|
65 self.__zoom0 = self.replEdit.fontPointSize() |
|
66 self.__zoomWidget = E5ZoomWidget( |
|
67 UI.PixmapCache.getPixmap("zoomOut"), |
|
68 UI.PixmapCache.getPixmap("zoomIn"), |
|
69 UI.PixmapCache.getPixmap("zoomReset"), self) |
|
70 self.__zoomLayout.addWidget(self.__zoomWidget) |
|
71 self.layout().insertLayout( |
|
72 self.layout().count() - 1, |
|
73 self.__zoomLayout) |
|
74 self.__zoomWidget.setMinimum(self.ZoomMin) |
|
75 self.__zoomWidget.setMaximum(self.ZoomMax) |
|
76 self.__zoomWidget.valueChanged.connect(self.__doZoom) |
|
77 self.__currentZoom = 0 |
|
78 |
|
79 self.__serial = None |
|
80 self.__device = None |
|
81 self.setConnected(False) |
|
82 |
|
83 if not HAS_QTSERIALPORT: |
|
84 self.replEdit.setHtml(self.tr( |
|
85 "<h3>The QtSerialPort package is not available.<br/>" |
|
86 "MicroPython support is deactivated.</h3>")) |
|
87 self.setEnabled(False) |
|
88 return |
|
89 |
|
90 self.__vt100Re = re.compile( |
|
91 r'(?P<count>[\d]*)(;?[\d]*)*(?P<action>[ABCDKm])') |
|
92 |
|
93 self.__populateDeviceTypeComboBox() |
|
94 |
|
95 self.replEdit.setAcceptRichText(False) |
|
96 self.replEdit.setUndoRedoEnabled(False) |
|
97 self.replEdit.setContextMenuPolicy(Qt.CustomContextMenu) |
|
98 |
|
99 self.replEdit.installEventFilter(self) |
|
100 |
|
101 self.replEdit.customContextMenuRequested.connect( |
|
102 self.__showContextMenu) |
|
103 |
|
104 def __populateDeviceTypeComboBox(self): |
|
105 """ |
|
106 Private method to populate the device type selector. |
|
107 """ |
|
108 self.deviceTypeComboBox.clear() |
|
109 self.deviceInfoLabel.clear() |
|
110 |
|
111 self.deviceTypeComboBox.addItem("", "") |
|
112 devices = MicroPythonDevices.getFoundDevices() |
|
113 if devices: |
|
114 self.deviceInfoLabel.setText( |
|
115 self.tr("%n supported device(s) detected.", n=len(devices))) |
|
116 for device in sorted(devices): |
|
117 self.deviceTypeComboBox.addItem( |
|
118 self.tr("{0} at {1}".format(device[1], device[2])), |
|
119 device[0]) |
|
120 else: |
|
121 self.deviceInfoLabel.setText( |
|
122 self.tr("No supported devices detected.")) |
|
123 |
|
124 self.on_deviceTypeComboBox_activated(0) |
|
125 |
|
126 @pyqtSlot(int) |
|
127 def on_deviceTypeComboBox_activated(self, index): |
|
128 """ |
|
129 Private slot handling the selection of a device type. |
|
130 |
|
131 @param index index of the selected device |
|
132 @type int |
|
133 """ |
|
134 deviceType = self.deviceTypeComboBox.itemData(index) |
|
135 self.deviceIconLabel.setPixmap(MicroPythonDevices.getDeviceIcon( |
|
136 deviceType, False)) |
|
137 |
|
138 self.__device = MicroPythonDevices.getDevice(deviceType) |
|
139 if self.__device: |
|
140 actions = self.__device.supportedActions() |
|
141 else: |
|
142 actions = tuple() |
|
143 self.__setActionButtons(actions) |
|
144 |
|
145 @pyqtSlot() |
|
146 def on_checkButton_clicked(self): |
|
147 """ |
|
148 Private slot to check for connected devices. |
|
149 """ |
|
150 self.__populateDeviceTypeComboBox() |
|
151 |
|
152 def __setActionButtons(self, actions): |
|
153 """ |
|
154 Private method to set the enabled state of the various action buttons. |
|
155 |
|
156 @param actions tuple of supported actions out of "repl", "run", |
|
157 "files", "chart" |
|
158 @type tuple of str |
|
159 """ |
|
160 self.runButton.setEnabled("run" in actions) |
|
161 self.replButton.setEnabled("repl" in actions) |
|
162 self.filesButton.setEnabled("files" in actions) |
|
163 self.chartButton.setEnabled("chart" in actions) |
|
164 |
|
165 @pyqtSlot(QPoint) |
|
166 def __showContextMenu(self, pos): |
|
167 """ |
|
168 Privat slot to show the REPL context menu. |
|
169 |
|
170 @param pos position to show the menu at |
|
171 @type QPoint |
|
172 """ |
|
173 if Globals.isMacPlatform(): |
|
174 copyKeys = QKeySequence(Qt.CTRL + Qt.Key_C) |
|
175 pasteKeys = QKeySequence(Qt.CTRL + Qt.Key_V) |
|
176 else: |
|
177 copyKeys = QKeySequence(Qt.CTRL + Qt.SHIFT + Qt.Key_C) |
|
178 pasteKeys = QKeySequence(Qt.CTRL + Qt.SHIFT + Qt.Key_V) |
|
179 menu = QMenu(self) |
|
180 menu.addAction(self.tr("Copy"), self.replEdit.copy, copyKeys) |
|
181 menu.addAction(self.tr("Paste"), self.__paste, pasteKeys) |
|
182 menu.exec_(self.replEdit.mapToGlobal(pos)) |
|
183 |
|
184 def setConnected(self, connected): |
|
185 """ |
|
186 Public method to set the connection status LED. |
|
187 |
|
188 @param connected connection state |
|
189 @type bool |
|
190 """ |
|
191 if connected: |
|
192 self.deviceConnectedLed.setColor(QColor(Qt.green)) |
|
193 else: |
|
194 self.deviceConnectedLed.setColor(QColor(Qt.red)) |
|
195 |
|
196 self.deviceTypeComboBox.setEnabled(not connected) |
|
197 |
|
198 self.disconnectButton.setEnabled(connected) |
|
199 |
|
200 @pyqtSlot() |
|
201 def on_replButton_clicked(self): |
|
202 """ |
|
203 Private slot to connect to the selected device and start a REPL. |
|
204 """ |
|
205 # TODO: not implemented yet |
|
206 raise NotImplementedError |
|
207 |
|
208 @pyqtSlot() |
|
209 def on_disconnectButton_clicked(self): |
|
210 """ |
|
211 Private slot to disconnect from the currently connected device. |
|
212 """ |
|
213 # TODO: not implemented yet |
|
214 raise NotImplementedError |
|
215 |
|
216 def __activatePlotter(self): |
|
217 """ |
|
218 Private method to activate a data plotter widget. |
|
219 """ |
|
220 # TODO: not implemented yet |
|
221 raise NotImplementedError |
|
222 |
|
223 def __deactivatePlotter(self): |
|
224 """ |
|
225 Private method to deactivate the plotter widget. |
|
226 """ |
|
227 # TODO: not implemented yet |
|
228 |
|
229 @pyqtSlot() |
|
230 def __paste(self): |
|
231 """ |
|
232 Private slot to perform a paste operation. |
|
233 """ |
|
234 clipboard = QApplication.clipboard() |
|
235 if clipboard and clipboard.text(): |
|
236 pasteText = clipboard.text().replace('\n\r', '\r') |
|
237 pasteText = pasteText.replace('\n', '\r') |
|
238 self.__serial and self.__serial.write(pasteText.encode("utf-8")) |
|
239 |
|
240 def eventFilter(self, obj, evt): |
|
241 """ |
|
242 Public method to process events for the REPL pane. |
|
243 |
|
244 @param obj reference to the object the event was meant for |
|
245 @type QObject |
|
246 @param evt reference to the event object |
|
247 @type QEvent |
|
248 @return flag to indicate that the event was handled |
|
249 @rtype bool |
|
250 """ |
|
251 if obj is self.replEdit and evt.type() == QEvent.KeyPress: |
|
252 # handle the key press event on behalve of the REPL pane |
|
253 key = evt.key() |
|
254 msg = bytes(evt.text(), 'utf8') |
|
255 if key == Qt.Key_Backspace: |
|
256 msg = b'\b' |
|
257 elif key == Qt.Key_Delete: |
|
258 msg = b'\x1B[\x33\x7E' |
|
259 elif key == Qt.Key_Up: |
|
260 msg = b'\x1B[A' |
|
261 elif key == Qt.Key_Down: |
|
262 msg = b'\x1B[B' |
|
263 elif key == Qt.Key_Right: |
|
264 msg = b'\x1B[C' |
|
265 elif key == Qt.Key_Left: |
|
266 msg = b'\x1B[D' |
|
267 elif key == Qt.Key_Home: |
|
268 msg = b'\x1B[H' |
|
269 elif key == Qt.Key_End: |
|
270 msg = b'\x1B[F' |
|
271 elif ((Globals.isMacPlatform() and |
|
272 evt.modifiers() == Qt.MetaModifier) or |
|
273 (not Globals.isMacPlatform() and |
|
274 evt.modifiers() == Qt.ControlModifier)): |
|
275 if Qt.Key_A <= key <= Qt.Key_Z: |
|
276 # devices treat an input of \x01 as Ctrl+A, etc. |
|
277 msg = bytes([1 + key - Qt.Key_A]) |
|
278 elif (evt.modifiers() == Qt.ControlModifier | Qt.ShiftModifier or |
|
279 (Globals.isMacPlatform() and |
|
280 evt.modifiers() == Qt.ControlModifier)): |
|
281 if key == Qt.Key_C: |
|
282 self.replEdit.copy() |
|
283 msg = b'' |
|
284 elif key == Qt.Key_V: |
|
285 self.__paste() |
|
286 msg = b'' |
|
287 self.__serial and self.__serial.write(msg) |
|
288 return True |
|
289 |
|
290 else: |
|
291 # standard event processing |
|
292 return super(MicroPythonReplWidget, self).eventFilter(obj, evt) |
|
293 |
|
294 def __processData(self, data): |
|
295 """ |
|
296 Private slot to process bytes received from the device. |
|
297 |
|
298 @param data bytes received from the device |
|
299 @type bytes |
|
300 """ |
|
301 tc = self.replEdit.textCursor() |
|
302 # the text cursor must be on the last line |
|
303 while tc.movePosition(QTextCursor.Down): |
|
304 pass |
|
305 |
|
306 index = 1 |
|
307 while index < len(data): |
|
308 if data[index] == 8: # \b |
|
309 tc.movePosition(QTextCursor.Left) |
|
310 self.replEdit.setTextCursor(tc) |
|
311 elif data[index] == 13: # \r |
|
312 pass |
|
313 elif (len(data) > index + 1 and |
|
314 data[index] == 27 and |
|
315 data[index + 1] == 91): |
|
316 # VT100 cursor command detected: <Esc>[ |
|
317 index += 2 # move index to after the [ |
|
318 match = self.__vt100Re.search(data[index:].decaode("utf-8")) |
|
319 if match: |
|
320 # move to last position in control sequence |
|
321 # ++ will be done at end of loop |
|
322 index += match.end() - 1 |
|
323 |
|
324 if match.group("count") == "": |
|
325 count = 1 |
|
326 else: |
|
327 count = int(match.group("count")) |
|
328 |
|
329 action = match.group("action") |
|
330 if action == "A": # up |
|
331 tc.movePosition(QTextCursor.Up, n=count) |
|
332 self.replEdit.setTextCursor(tc) |
|
333 elif action == "B": # down |
|
334 tc.movePosition(QTextCursor.Down, n=count) |
|
335 self.replEdit.setTextCursor(tc) |
|
336 elif action == "C": # right |
|
337 tc.movePosition(QTextCursor.Right, n=count) |
|
338 self.replEdit.setTextCursor(tc) |
|
339 elif action == "D": # left |
|
340 tc.movePosition(QTextCursor.Left, n=count) |
|
341 self.replEdit.setTextCursor(tc) |
|
342 elif action == "K": # delete things |
|
343 if match.group("count") == "": # delete to eol |
|
344 tc.movePosition(QTextCursor.EndOfLine, |
|
345 mode=QTextCursor.KeepAnchor) |
|
346 tc.removeSelectedText() |
|
347 self.replEdit.setTextCursor(tc) |
|
348 # TODO: add handling of 'm' (colors) |
|
349 elif data[index] == 10: # \n |
|
350 tc.movePosition(QTextCursor.End) |
|
351 self.replEdit.setTextCursor(tc) |
|
352 self.replEdit.insertPlainText(chr(data[index])) |
|
353 else: |
|
354 tc.deleteChar() |
|
355 self.replEdit.setTextCursor(tc) |
|
356 self.replEdit.insertPlainText(chr(data[index])) |
|
357 |
|
358 index += 1 |
|
359 |
|
360 self.replEdit.ensureCursorVisible() |
|
361 |
|
362 def __doZoom(self, value): |
|
363 """ |
|
364 Private slot to zoom the REPL pane. |
|
365 |
|
366 @param value zoom value |
|
367 @param int |
|
368 """ |
|
369 if value < self.__currentZoom: |
|
370 self.replEdit.zoomOut(self.__currentZoom - value) |
|
371 elif value > self.__currentZoom: |
|
372 self.replEdit.zoomIn(value - self.__currentZoom) |
|
373 self.__currentZoom = value |