plotdeck 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plotdeck/__init__.py +4 -0
- plotdeck/__main__.py +6 -0
- plotdeck/app.py +55 -0
- plotdeck/core/__init__.py +4 -0
- plotdeck/core/controller.py +561 -0
- plotdeck/core/gcode_parser.py +404 -0
- plotdeck/core/grbl_protocol.py +278 -0
- plotdeck/core/status.py +148 -0
- plotdeck/core/streamer.py +328 -0
- plotdeck/core/transport.py +375 -0
- plotdeck/ui/__init__.py +1 -0
- plotdeck/ui/connection_panel.py +139 -0
- plotdeck/ui/console.py +171 -0
- plotdeck/ui/dialogs.py +317 -0
- plotdeck/ui/editor.py +544 -0
- plotdeck/ui/job_panel.py +130 -0
- plotdeck/ui/jog_panel.py +222 -0
- plotdeck/ui/main_window.py +1116 -0
- plotdeck/ui/manual.py +156 -0
- plotdeck/ui/status_panel.py +244 -0
- plotdeck/ui/theme.py +329 -0
- plotdeck/ui/visualizer.py +374 -0
- plotdeck/utils/__init__.py +1 -0
- plotdeck/utils/logging_setup.py +42 -0
- plotdeck/utils/settings.py +86 -0
- plotdeck-1.0.0.dist-info/METADATA +227 -0
- plotdeck-1.0.0.dist-info/RECORD +30 -0
- plotdeck-1.0.0.dist-info/WHEEL +5 -0
- plotdeck-1.0.0.dist-info/entry_points.txt +5 -0
- plotdeck-1.0.0.dist-info/top_level.txt +1 -0
plotdeck/ui/editor.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""G-code editor: line-number gutter, syntax highlighting and job markers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QRect, QSize, Qt, QTimer, Signal
|
|
8
|
+
from PySide6.QtGui import (
|
|
9
|
+
QColor,
|
|
10
|
+
QFont,
|
|
11
|
+
QFontDatabase,
|
|
12
|
+
QKeyEvent,
|
|
13
|
+
QPainter,
|
|
14
|
+
QSyntaxHighlighter,
|
|
15
|
+
QTextCharFormat,
|
|
16
|
+
QTextCursor,
|
|
17
|
+
QTextDocument,
|
|
18
|
+
QTextFormat,
|
|
19
|
+
)
|
|
20
|
+
from PySide6.QtWidgets import (
|
|
21
|
+
QCheckBox,
|
|
22
|
+
QHBoxLayout,
|
|
23
|
+
QLabel,
|
|
24
|
+
QLineEdit,
|
|
25
|
+
QPlainTextEdit,
|
|
26
|
+
QPushButton,
|
|
27
|
+
QTextEdit,
|
|
28
|
+
QVBoxLayout,
|
|
29
|
+
QWidget,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ..core.gcode_parser import (
|
|
33
|
+
ProgramLine,
|
|
34
|
+
clean_line,
|
|
35
|
+
parse_program,
|
|
36
|
+
strip_comments,
|
|
37
|
+
tokenize,
|
|
38
|
+
transform_lines,
|
|
39
|
+
)
|
|
40
|
+
from ..core.grbl_protocol import MAX_LINE_LENGTH
|
|
41
|
+
from .theme import tokens
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GcodeHighlighter(QSyntaxHighlighter):
|
|
45
|
+
def __init__(self, document: QTextDocument, token: dict[str, str]) -> None:
|
|
46
|
+
super().__init__(document)
|
|
47
|
+
self._token = token
|
|
48
|
+
self._comment = QTextCharFormat()
|
|
49
|
+
self._comment.setForeground(QColor(token["comment"]))
|
|
50
|
+
self._comment.setFontItalic(True)
|
|
51
|
+
|
|
52
|
+
def fmt(color: str, bold: bool = False) -> QTextCharFormat:
|
|
53
|
+
style = QTextCharFormat()
|
|
54
|
+
style.setForeground(QColor(color))
|
|
55
|
+
if bold:
|
|
56
|
+
style.setFontWeight(QFont.Weight.DemiBold)
|
|
57
|
+
return style
|
|
58
|
+
|
|
59
|
+
self._keyword_formats: list[tuple[re.Pattern[str], QTextCharFormat]] = [
|
|
60
|
+
(re.compile(r"\bN\d+\b"), fmt(token["muted"])),
|
|
61
|
+
(re.compile(r"\bG\d+(?:\.\d+)?\b"), fmt(token["g_word"], True)),
|
|
62
|
+
(re.compile(r"\bM\d+(?:\.\d+)?\b"), fmt(token["m_word"], True)),
|
|
63
|
+
(re.compile(r"\b[XYZIJKR][-+]?(?:\d+(?:\.\d*)?|\.\d+)"), fmt(token["axis_word"])),
|
|
64
|
+
(re.compile(r"\b[FST][-+]?(?:\d+(?:\.\d*)?|\.\d+)"), fmt(token["feed_word"])),
|
|
65
|
+
(re.compile(r"\$[A-Z#]+\d*"), fmt(token["accent"], True)),
|
|
66
|
+
(re.compile(r"\[[^\]]*\]"), fmt(token["comment"])),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
|
|
70
|
+
for pattern, style in self._keyword_formats:
|
|
71
|
+
for match in pattern.finditer(text):
|
|
72
|
+
self.setFormat(match.start(), match.end() - match.start(), style)
|
|
73
|
+
# Comments win over everything else.
|
|
74
|
+
for match in re.finditer(r";.*$", text):
|
|
75
|
+
self.setFormat(match.start(), len(text) - match.start(), self._comment)
|
|
76
|
+
for match in re.finditer(r"\([^)]*\)", text):
|
|
77
|
+
self.setFormat(match.start(), match.end() - match.start(), self._comment)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LineNumberArea(QWidget):
|
|
81
|
+
def __init__(self, editor: GcodeEditor) -> None:
|
|
82
|
+
super().__init__(editor)
|
|
83
|
+
self._editor = editor
|
|
84
|
+
|
|
85
|
+
def sizeHint(self) -> QSize: # noqa: N802
|
|
86
|
+
return QSize(self._editor.line_number_width(), 0)
|
|
87
|
+
|
|
88
|
+
def paintEvent(self, event) -> None: # noqa: N802, ANN001
|
|
89
|
+
self._editor.paint_gutter(event)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GcodeEditor(QPlainTextEdit):
|
|
93
|
+
cursorLineChanged = Signal(int)
|
|
94
|
+
|
|
95
|
+
def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
|
|
96
|
+
super().__init__(parent)
|
|
97
|
+
self._token = token or tokens()
|
|
98
|
+
self._gutter = LineNumberArea(self)
|
|
99
|
+
self._highlighter = GcodeHighlighter(self.document(), self._token)
|
|
100
|
+
self._current_line = 0
|
|
101
|
+
self._markers: dict[int, str] = {}
|
|
102
|
+
self._validation_lines: set[int] = set()
|
|
103
|
+
self._gutter_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
|
104
|
+
self._gutter_font.setPointSize(11)
|
|
105
|
+
|
|
106
|
+
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
|
107
|
+
font.setPointSize(12)
|
|
108
|
+
font.setFamily("Menlo")
|
|
109
|
+
self.setFont(font)
|
|
110
|
+
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|
111
|
+
self.setTabStopDistance(self.fontMetrics().horizontalAdvance(" ") * 4)
|
|
112
|
+
self.setCursorWidth(2)
|
|
113
|
+
|
|
114
|
+
self.blockCountChanged.connect(self._update_gutter_width)
|
|
115
|
+
self.updateRequest.connect(self._update_gutter)
|
|
116
|
+
self.cursorPositionChanged.connect(self._on_cursor_moved)
|
|
117
|
+
self.textChanged.connect(self._queue_validation)
|
|
118
|
+
self._validate_timer = QTimer(self)
|
|
119
|
+
self._validate_timer.setSingleShot(True)
|
|
120
|
+
self._validate_timer.setInterval(450)
|
|
121
|
+
self._validate_timer.timeout.connect(self._validate)
|
|
122
|
+
|
|
123
|
+
self._update_gutter_width()
|
|
124
|
+
self._refresh_extra_selections()
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
# Public API
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
def set_token(self, token: dict[str, str]) -> None:
|
|
130
|
+
self._token = token
|
|
131
|
+
self._highlighter.setDocument(None)
|
|
132
|
+
self._highlighter = GcodeHighlighter(self.document(), token)
|
|
133
|
+
self._refresh_extra_selections()
|
|
134
|
+
self._gutter.update()
|
|
135
|
+
|
|
136
|
+
def source_text(self) -> str:
|
|
137
|
+
return self.toPlainText()
|
|
138
|
+
|
|
139
|
+
def set_source_text(self, text: str) -> None:
|
|
140
|
+
lines = text.count("\n") + 1
|
|
141
|
+
self.setUndoRedoEnabled(not (lines > 50_000))
|
|
142
|
+
self.setPlainText(text)
|
|
143
|
+
self.setUndoRedoEnabled(True)
|
|
144
|
+
self.clear_markers()
|
|
145
|
+
self._current_line = 0
|
|
146
|
+
self.moveCursor(QTextCursor.MoveOperation.Start)
|
|
147
|
+
self._update_gutter_width()
|
|
148
|
+
self._refresh_extra_selections()
|
|
149
|
+
|
|
150
|
+
def program(self) -> list[ProgramLine]:
|
|
151
|
+
return parse_program(self.toPlainText())
|
|
152
|
+
|
|
153
|
+
def go_to_line(self, line: int, focus: bool = True) -> None:
|
|
154
|
+
block = self.document().findBlockByLineNumber(max(0, line - 1))
|
|
155
|
+
if not block.isValid():
|
|
156
|
+
return
|
|
157
|
+
cursor = QTextCursor(block)
|
|
158
|
+
self.setTextCursor(cursor)
|
|
159
|
+
self.centerCursor()
|
|
160
|
+
if focus:
|
|
161
|
+
self.setFocus()
|
|
162
|
+
|
|
163
|
+
def current_line(self) -> int:
|
|
164
|
+
return self.textCursor().blockNumber() + 1
|
|
165
|
+
|
|
166
|
+
def selected_or_current_line(self) -> int:
|
|
167
|
+
return self.current_line()
|
|
168
|
+
|
|
169
|
+
def set_execution_line(self, line: int) -> None:
|
|
170
|
+
if line <= 0:
|
|
171
|
+
return
|
|
172
|
+
if self._current_line and self._current_line != line:
|
|
173
|
+
self._markers[self._current_line] = "done"
|
|
174
|
+
self._current_line = line
|
|
175
|
+
self._markers[line] = "current"
|
|
176
|
+
self._refresh_extra_selections()
|
|
177
|
+
# Never yank the insertion point away from someone typing while a job
|
|
178
|
+
# runs; only auto-scroll when the editor is not focused.
|
|
179
|
+
if not self.hasFocus():
|
|
180
|
+
self.go_to_line(line, focus=False)
|
|
181
|
+
|
|
182
|
+
def mark_error(self, line: int) -> None:
|
|
183
|
+
self._markers[line] = "error"
|
|
184
|
+
self._refresh_extra_selections()
|
|
185
|
+
|
|
186
|
+
def clear_markers(self) -> None:
|
|
187
|
+
self._markers.clear()
|
|
188
|
+
self._current_line = 0
|
|
189
|
+
self._refresh_extra_selections()
|
|
190
|
+
|
|
191
|
+
def mark_all_done(self) -> None:
|
|
192
|
+
for line in list(self._markers):
|
|
193
|
+
if self._markers[line] == "current":
|
|
194
|
+
self._markers[line] = "done"
|
|
195
|
+
self._current_line = 0
|
|
196
|
+
self._refresh_extra_selections()
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------ #
|
|
199
|
+
# Editing actions
|
|
200
|
+
# ------------------------------------------------------------------ #
|
|
201
|
+
def toggle_comment(self) -> None:
|
|
202
|
+
cursor = self.textCursor()
|
|
203
|
+
start = cursor.selectionStart()
|
|
204
|
+
end = cursor.selectionEnd()
|
|
205
|
+
cursor.setPosition(start)
|
|
206
|
+
first = cursor.blockNumber()
|
|
207
|
+
cursor.setPosition(end)
|
|
208
|
+
last = cursor.blockNumber()
|
|
209
|
+
blocks = [self.document().findBlockByNumber(index) for index in range(first, last + 1)]
|
|
210
|
+
commented = all(
|
|
211
|
+
block.text().lstrip().startswith(";") for block in blocks if block.text().strip()
|
|
212
|
+
)
|
|
213
|
+
cursor.beginEditBlock()
|
|
214
|
+
for block in blocks:
|
|
215
|
+
if not block.text().strip():
|
|
216
|
+
continue
|
|
217
|
+
edit = QTextCursor(block)
|
|
218
|
+
text = block.text()
|
|
219
|
+
if commented:
|
|
220
|
+
stripped = text.lstrip()
|
|
221
|
+
offset = len(text) - len(stripped)
|
|
222
|
+
edit.setPosition(block.position() + offset)
|
|
223
|
+
edit.movePosition(
|
|
224
|
+
QTextCursor.MoveOperation.Right,
|
|
225
|
+
QTextCursor.MoveMode.KeepAnchor,
|
|
226
|
+
2 if stripped.startswith("; ") else 1,
|
|
227
|
+
)
|
|
228
|
+
edit.removeSelectedText()
|
|
229
|
+
else:
|
|
230
|
+
edit.movePosition(QTextCursor.MoveOperation.StartOfBlock)
|
|
231
|
+
edit.insertText("; ")
|
|
232
|
+
cursor.endEditBlock()
|
|
233
|
+
|
|
234
|
+
def strip_comments(self) -> None:
|
|
235
|
+
cursor = self.textCursor()
|
|
236
|
+
start_block = self.document().findBlock(cursor.selectionStart()).blockNumber()
|
|
237
|
+
end_block = self.document().findBlock(cursor.selectionEnd()).blockNumber()
|
|
238
|
+
cursor.beginEditBlock()
|
|
239
|
+
for index in range(start_block, end_block + 1):
|
|
240
|
+
block = self.document().findBlockByNumber(index)
|
|
241
|
+
cleaned = strip_comments(block.text()).strip()
|
|
242
|
+
edit = QTextCursor(block)
|
|
243
|
+
edit.select(QTextCursor.SelectionType.BlockUnderCursor)
|
|
244
|
+
edit.insertText(cleaned)
|
|
245
|
+
cursor.endEditBlock()
|
|
246
|
+
|
|
247
|
+
def uppercase_selection(self) -> None:
|
|
248
|
+
cursor = self.textCursor()
|
|
249
|
+
text = cursor.selectedText()
|
|
250
|
+
if text:
|
|
251
|
+
cursor.insertText(text.upper())
|
|
252
|
+
|
|
253
|
+
def renumber(self) -> None:
|
|
254
|
+
program = parse_program(self.toPlainText())
|
|
255
|
+
cursor = QTextCursor(self.document())
|
|
256
|
+
cursor.beginEditBlock()
|
|
257
|
+
for line in program:
|
|
258
|
+
block = self.document().findBlockByNumber(line.source_line - 1)
|
|
259
|
+
if not block.isValid():
|
|
260
|
+
continue
|
|
261
|
+
raw = block.text().lstrip()
|
|
262
|
+
if raw.upper().startswith("N"):
|
|
263
|
+
continue
|
|
264
|
+
edit = QTextCursor(block)
|
|
265
|
+
edit.select(QTextCursor.SelectionType.BlockUnderCursor)
|
|
266
|
+
edit.insertText(f"N{line.source_line * 10} {raw}")
|
|
267
|
+
cursor.endEditBlock()
|
|
268
|
+
|
|
269
|
+
def transform(
|
|
270
|
+
self,
|
|
271
|
+
dx: float = 0.0,
|
|
272
|
+
dy: float = 0.0,
|
|
273
|
+
dz: float = 0.0,
|
|
274
|
+
scale: float = 1.0,
|
|
275
|
+
rotate: float = 0.0,
|
|
276
|
+
) -> None:
|
|
277
|
+
source = self.toPlainText()
|
|
278
|
+
output: list[str] = []
|
|
279
|
+
for number, raw in enumerate(source.splitlines(), start=1):
|
|
280
|
+
clean = clean_line(raw)
|
|
281
|
+
if not clean:
|
|
282
|
+
output.append(raw)
|
|
283
|
+
continue
|
|
284
|
+
words = tokenize(clean)
|
|
285
|
+
if not any(letter in "XYZ" for letter, _ in words):
|
|
286
|
+
output.append(raw)
|
|
287
|
+
continue
|
|
288
|
+
transformed = transform_lines(
|
|
289
|
+
[ProgramLine(clean, number)], dx=dx, dy=dy, dz=dz, scale=scale, rotate=rotate
|
|
290
|
+
)[0]
|
|
291
|
+
comment = ""
|
|
292
|
+
if ";" in raw:
|
|
293
|
+
comment = " ;" + raw.split(";", 1)[1]
|
|
294
|
+
output.append(transformed + comment)
|
|
295
|
+
self.set_source_text("\n".join(output))
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------ #
|
|
298
|
+
# Gutter
|
|
299
|
+
# ------------------------------------------------------------------ #
|
|
300
|
+
def line_number_width(self) -> int:
|
|
301
|
+
digits = max(2, len(str(max(1, self.blockCount()))))
|
|
302
|
+
return 14 + digits * 8
|
|
303
|
+
|
|
304
|
+
def _update_gutter_width(self) -> None:
|
|
305
|
+
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
|
|
306
|
+
|
|
307
|
+
def _update_gutter(self, rect: QRect, dy: int) -> None:
|
|
308
|
+
if dy:
|
|
309
|
+
self._gutter.scroll(0, dy)
|
|
310
|
+
else:
|
|
311
|
+
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
|
312
|
+
if rect.contains(self.viewport().rect()):
|
|
313
|
+
self._update_gutter_width()
|
|
314
|
+
|
|
315
|
+
def resizeEvent(self, event) -> None: # noqa: N802, ANN001
|
|
316
|
+
super().resizeEvent(event)
|
|
317
|
+
area = self.contentsRect()
|
|
318
|
+
self._gutter.setGeometry(
|
|
319
|
+
QRect(area.left(), area.top(), self.line_number_width(), area.height())
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def paint_gutter(self, event) -> None: # noqa: ANN001
|
|
323
|
+
painter = QPainter(self._gutter)
|
|
324
|
+
painter.fillRect(event.rect(), QColor(self._token["gutter"]))
|
|
325
|
+
painter.setFont(self._gutter_font)
|
|
326
|
+
block = self.firstVisibleBlock()
|
|
327
|
+
number = block.blockNumber() + 1
|
|
328
|
+
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
|
329
|
+
bottom = top + self.blockBoundingRect(block).height()
|
|
330
|
+
current = self.current_line()
|
|
331
|
+
|
|
332
|
+
while block.isValid() and top <= event.rect().bottom():
|
|
333
|
+
if block.isVisible() and bottom >= event.rect().top():
|
|
334
|
+
height = int(self.blockBoundingRect(block).height())
|
|
335
|
+
if number == current:
|
|
336
|
+
painter.setPen(QColor(self._token["accent"]))
|
|
337
|
+
else:
|
|
338
|
+
painter.setPen(QColor(self._token["muted"]))
|
|
339
|
+
painter.drawText(
|
|
340
|
+
0,
|
|
341
|
+
int(top),
|
|
342
|
+
self.line_number_width() - 10,
|
|
343
|
+
height,
|
|
344
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
|
345
|
+
str(number),
|
|
346
|
+
)
|
|
347
|
+
marker = self._markers.get(number)
|
|
348
|
+
if marker == "current":
|
|
349
|
+
color = self._token["run"]
|
|
350
|
+
elif marker == "done":
|
|
351
|
+
color = self._token["idle"]
|
|
352
|
+
elif marker == "error":
|
|
353
|
+
color = self._token["alarm"]
|
|
354
|
+
else:
|
|
355
|
+
color = ""
|
|
356
|
+
if color:
|
|
357
|
+
painter.fillRect(
|
|
358
|
+
self.line_number_width() - 5, int(top), 3, height, QColor(color)
|
|
359
|
+
)
|
|
360
|
+
elif number in self._validation_lines:
|
|
361
|
+
painter.fillRect(
|
|
362
|
+
self.line_number_width() - 5,
|
|
363
|
+
int(top),
|
|
364
|
+
3,
|
|
365
|
+
height,
|
|
366
|
+
QColor(self._token["hold"]),
|
|
367
|
+
)
|
|
368
|
+
block = block.next()
|
|
369
|
+
top = bottom
|
|
370
|
+
bottom = top + self.blockBoundingRect(block).height()
|
|
371
|
+
number += 1
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------ #
|
|
374
|
+
# Selections / validation
|
|
375
|
+
# ------------------------------------------------------------------ #
|
|
376
|
+
def _on_cursor_moved(self) -> None:
|
|
377
|
+
self.cursorLineChanged.emit(self.current_line())
|
|
378
|
+
|
|
379
|
+
def _refresh_extra_selections(self) -> None:
|
|
380
|
+
selections: list[QTextEdit.ExtraSelection] = []
|
|
381
|
+
if self._current_line:
|
|
382
|
+
block = self.document().findBlockByNumber(self._current_line - 1)
|
|
383
|
+
if block.isValid():
|
|
384
|
+
selection = QTextEdit.ExtraSelection()
|
|
385
|
+
selection.format.setBackground(QColor(self._token["selection"]))
|
|
386
|
+
selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
|
|
387
|
+
cursor = QTextCursor(block)
|
|
388
|
+
cursor.clearSelection()
|
|
389
|
+
selection.cursor = cursor
|
|
390
|
+
selections.append(selection)
|
|
391
|
+
|
|
392
|
+
error_format = QTextCharFormat()
|
|
393
|
+
error_format.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline)
|
|
394
|
+
error_format.setUnderlineColor(QColor(self._token["error"]))
|
|
395
|
+
for line in sorted(self._validation_lines)[:200]:
|
|
396
|
+
block = self.document().findBlockByNumber(line - 1)
|
|
397
|
+
if not block.isValid():
|
|
398
|
+
continue
|
|
399
|
+
selection = QTextEdit.ExtraSelection()
|
|
400
|
+
selection.format = error_format
|
|
401
|
+
cursor = QTextCursor(block)
|
|
402
|
+
selection.cursor = cursor
|
|
403
|
+
selections.append(selection)
|
|
404
|
+
|
|
405
|
+
self.setExtraSelections(selections)
|
|
406
|
+
|
|
407
|
+
def _queue_validation(self) -> None:
|
|
408
|
+
self._validate_timer.start()
|
|
409
|
+
|
|
410
|
+
def _validate(self) -> None:
|
|
411
|
+
if self.blockCount() > 20_000:
|
|
412
|
+
self._validation_lines = set()
|
|
413
|
+
self._gutter.update()
|
|
414
|
+
return
|
|
415
|
+
invalid: set[int] = set()
|
|
416
|
+
block = self.document().firstBlock()
|
|
417
|
+
while block.isValid():
|
|
418
|
+
if len(block.text()) > MAX_LINE_LENGTH:
|
|
419
|
+
invalid.add(block.blockNumber() + 1)
|
|
420
|
+
block = block.next()
|
|
421
|
+
if invalid != self._validation_lines:
|
|
422
|
+
self._validation_lines = invalid
|
|
423
|
+
self._gutter.update()
|
|
424
|
+
self._refresh_extra_selections()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class FindBar(QWidget):
|
|
428
|
+
"""Inline find/replace bar used by the main window."""
|
|
429
|
+
|
|
430
|
+
def __init__(self, editor: GcodeEditor, parent=None) -> None: # noqa: ANN001
|
|
431
|
+
super().__init__(parent)
|
|
432
|
+
self._editor = editor
|
|
433
|
+
self.setObjectName("Panel")
|
|
434
|
+
self._find = QLineEdit()
|
|
435
|
+
self._find.setPlaceholderText("Find")
|
|
436
|
+
self._find.setClearButtonEnabled(True)
|
|
437
|
+
self._replace = QLineEdit()
|
|
438
|
+
self._replace.setPlaceholderText("Replace with")
|
|
439
|
+
self._case = QCheckBox("Match case")
|
|
440
|
+
self._status = QLabel("")
|
|
441
|
+
self._status.setObjectName("Muted")
|
|
442
|
+
self._prev = QPushButton("Prev")
|
|
443
|
+
self._next = QPushButton("Next")
|
|
444
|
+
self._replace_button = QPushButton("Replace")
|
|
445
|
+
self._replace_all_button = QPushButton("All")
|
|
446
|
+
close = QPushButton("✕")
|
|
447
|
+
close.setFixedWidth(30)
|
|
448
|
+
|
|
449
|
+
first = QHBoxLayout()
|
|
450
|
+
first.addWidget(QLabel("Find"))
|
|
451
|
+
first.addWidget(self._find, 1)
|
|
452
|
+
first.addWidget(self._case)
|
|
453
|
+
first.addWidget(self._prev)
|
|
454
|
+
first.addWidget(self._next)
|
|
455
|
+
first.addWidget(close)
|
|
456
|
+
|
|
457
|
+
second = QHBoxLayout()
|
|
458
|
+
second.addWidget(QLabel("Replace"))
|
|
459
|
+
second.addWidget(self._replace, 1)
|
|
460
|
+
second.addWidget(self._replace_button)
|
|
461
|
+
second.addWidget(self._replace_all_button)
|
|
462
|
+
second.addWidget(self._status, 1)
|
|
463
|
+
|
|
464
|
+
layout = QVBoxLayout(self)
|
|
465
|
+
layout.setContentsMargins(8, 6, 8, 6)
|
|
466
|
+
layout.setSpacing(4)
|
|
467
|
+
layout.addLayout(first)
|
|
468
|
+
layout.addLayout(second)
|
|
469
|
+
|
|
470
|
+
self._next.clicked.connect(self.find_next)
|
|
471
|
+
self._prev.clicked.connect(self.find_previous)
|
|
472
|
+
self._replace_button.clicked.connect(self.replace_one)
|
|
473
|
+
self._replace_all_button.clicked.connect(self.replace_all)
|
|
474
|
+
close.clicked.connect(self.hide)
|
|
475
|
+
self._find.returnPressed.connect(self.find_next)
|
|
476
|
+
self._find.textChanged.connect(self._clear_status)
|
|
477
|
+
self.hide()
|
|
478
|
+
|
|
479
|
+
def activate(self, replace: bool = False) -> None:
|
|
480
|
+
self.show()
|
|
481
|
+
if replace:
|
|
482
|
+
self._replace.setFocus()
|
|
483
|
+
self._replace.selectAll()
|
|
484
|
+
else:
|
|
485
|
+
self._find.setFocus()
|
|
486
|
+
self._find.selectAll()
|
|
487
|
+
|
|
488
|
+
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
|
489
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
490
|
+
self.hide()
|
|
491
|
+
self._editor.setFocus()
|
|
492
|
+
return
|
|
493
|
+
super().keyPressEvent(event)
|
|
494
|
+
|
|
495
|
+
def _flags(self) -> QTextDocument.FindFlag:
|
|
496
|
+
flags = QTextDocument.FindFlag(0)
|
|
497
|
+
if self._case.isChecked():
|
|
498
|
+
flags |= QTextDocument.FindFlag.FindCaseSensitively
|
|
499
|
+
return flags
|
|
500
|
+
|
|
501
|
+
def find_next(self) -> None:
|
|
502
|
+
if not self._find.text():
|
|
503
|
+
return
|
|
504
|
+
if not self._editor.find(self._find.text(), self._flags()):
|
|
505
|
+
cursor = self._editor.textCursor()
|
|
506
|
+
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
|
507
|
+
self._editor.setTextCursor(cursor)
|
|
508
|
+
if not self._editor.find(self._find.text(), self._flags()):
|
|
509
|
+
self._status.setText("Not found")
|
|
510
|
+
|
|
511
|
+
def find_previous(self) -> None:
|
|
512
|
+
if not self._find.text():
|
|
513
|
+
return
|
|
514
|
+
flags = self._flags() | QTextDocument.FindFlag.FindBackward
|
|
515
|
+
if not self._editor.find(self._find.text(), flags):
|
|
516
|
+
cursor = self._editor.textCursor()
|
|
517
|
+
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
518
|
+
self._editor.setTextCursor(cursor)
|
|
519
|
+
if not self._editor.find(self._find.text(), flags):
|
|
520
|
+
self._status.setText("Not found")
|
|
521
|
+
|
|
522
|
+
def replace_one(self) -> None:
|
|
523
|
+
cursor = self._editor.textCursor()
|
|
524
|
+
if cursor.hasSelection():
|
|
525
|
+
cursor.insertText(self._replace.text())
|
|
526
|
+
self.find_next()
|
|
527
|
+
|
|
528
|
+
def replace_all(self) -> None:
|
|
529
|
+
needle = self._find.text()
|
|
530
|
+
if not needle:
|
|
531
|
+
return
|
|
532
|
+
text = self._editor.toPlainText()
|
|
533
|
+
replacement = self._replace.text()
|
|
534
|
+
if self._case.isChecked():
|
|
535
|
+
count = text.count(needle)
|
|
536
|
+
updated = text.replace(needle, replacement)
|
|
537
|
+
else:
|
|
538
|
+
count = len(re.findall(re.escape(needle), text, flags=re.IGNORECASE))
|
|
539
|
+
updated = re.sub(re.escape(needle), lambda _: replacement, text, flags=re.IGNORECASE)
|
|
540
|
+
self._editor.set_source_text(updated)
|
|
541
|
+
self._status.setText(f"Replaced {count}")
|
|
542
|
+
|
|
543
|
+
def _clear_status(self) -> None:
|
|
544
|
+
self._status.setText("")
|
plotdeck/ui/job_panel.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Job progress, elapsed time and line counter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QTimer
|
|
8
|
+
from PySide6.QtWidgets import (
|
|
9
|
+
QGridLayout,
|
|
10
|
+
QLabel,
|
|
11
|
+
QProgressBar,
|
|
12
|
+
QVBoxLayout,
|
|
13
|
+
QWidget,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_duration(seconds: float) -> str:
|
|
18
|
+
seconds = max(0, int(seconds))
|
|
19
|
+
hours, remainder = divmod(seconds, 3600)
|
|
20
|
+
minutes, secs = divmod(remainder, 60)
|
|
21
|
+
if hours:
|
|
22
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
23
|
+
return f"{minutes:02d}:{secs:02d}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JobPanel(QWidget):
|
|
27
|
+
def __init__(self, parent=None) -> None: # noqa: ANN001
|
|
28
|
+
super().__init__(parent)
|
|
29
|
+
self.setObjectName("Panel")
|
|
30
|
+
self._started: float | None = None
|
|
31
|
+
self._total = 0
|
|
32
|
+
self._sent = 0
|
|
33
|
+
self._estimate = 0.0
|
|
34
|
+
self._running = False
|
|
35
|
+
|
|
36
|
+
header = QLabel("JOB")
|
|
37
|
+
header.setObjectName("SectionHeader")
|
|
38
|
+
self._progress = QProgressBar()
|
|
39
|
+
self._progress.setRange(0, 1000)
|
|
40
|
+
self._progress.setValue(0)
|
|
41
|
+
self._progress.setFormat("0%")
|
|
42
|
+
|
|
43
|
+
self._line = QLabel("line -- / --")
|
|
44
|
+
self._line.setObjectName("Muted")
|
|
45
|
+
self._elapsed = QLabel("elapsed 00:00")
|
|
46
|
+
self._elapsed.setObjectName("Muted")
|
|
47
|
+
self._remaining = QLabel("remaining --:--")
|
|
48
|
+
self._remaining.setObjectName("Muted")
|
|
49
|
+
|
|
50
|
+
grid = QGridLayout()
|
|
51
|
+
grid.addWidget(self._line, 0, 0)
|
|
52
|
+
grid.addWidget(self._elapsed, 1, 0)
|
|
53
|
+
grid.addWidget(self._remaining, 1, 1)
|
|
54
|
+
|
|
55
|
+
layout = QVBoxLayout(self)
|
|
56
|
+
layout.setContentsMargins(10, 8, 10, 8)
|
|
57
|
+
layout.setSpacing(6)
|
|
58
|
+
layout.addWidget(header)
|
|
59
|
+
layout.addWidget(self._progress)
|
|
60
|
+
layout.addLayout(grid)
|
|
61
|
+
layout.addStretch(1)
|
|
62
|
+
|
|
63
|
+
self._timer = QTimer(self)
|
|
64
|
+
self._timer.setInterval(1000)
|
|
65
|
+
self._timer.timeout.connect(self._tick)
|
|
66
|
+
|
|
67
|
+
def begin(self, total: int, estimate_seconds: float = 0.0) -> None:
|
|
68
|
+
self._total = total
|
|
69
|
+
self._sent = 0
|
|
70
|
+
self._estimate = estimate_seconds
|
|
71
|
+
self._started = time.monotonic()
|
|
72
|
+
self._running = True
|
|
73
|
+
self._progress.setValue(0)
|
|
74
|
+
self._progress.setFormat("0%")
|
|
75
|
+
self._line.setText(f"line -- / {total}")
|
|
76
|
+
self._elapsed.setText("elapsed 00:00")
|
|
77
|
+
self._remaining.setText(
|
|
78
|
+
f"remaining {format_duration(estimate_seconds)}"
|
|
79
|
+
if estimate_seconds
|
|
80
|
+
else "remaining --:--"
|
|
81
|
+
)
|
|
82
|
+
self._timer.start()
|
|
83
|
+
|
|
84
|
+
def update_progress(self, sent: int, total: int, line: int) -> None:
|
|
85
|
+
if not self._running:
|
|
86
|
+
return
|
|
87
|
+
self._sent = sent
|
|
88
|
+
self._total = max(1, total)
|
|
89
|
+
fraction = sent / self._total
|
|
90
|
+
self._progress.setValue(int(fraction * 1000))
|
|
91
|
+
self._progress.setFormat(f"{fraction * 100:.0f}%")
|
|
92
|
+
self._line.setText(f"line {line} / {self._total}")
|
|
93
|
+
|
|
94
|
+
def finish(self, reason: str) -> None:
|
|
95
|
+
self._running = False
|
|
96
|
+
self._timer.stop()
|
|
97
|
+
label = {
|
|
98
|
+
"completed": "completed",
|
|
99
|
+
"stopped": "stopped",
|
|
100
|
+
"alarm": "stopped by alarm",
|
|
101
|
+
"disconnected": "interrupted - disconnected",
|
|
102
|
+
"error": "stopped by error",
|
|
103
|
+
}.get(reason, reason)
|
|
104
|
+
self._line.setText(f"Job {label}")
|
|
105
|
+
if reason == "completed":
|
|
106
|
+
self._progress.setValue(1000)
|
|
107
|
+
self._progress.setFormat("100%")
|
|
108
|
+
self._remaining.setText("remaining 00:00")
|
|
109
|
+
else:
|
|
110
|
+
self._remaining.setText("remaining --:--")
|
|
111
|
+
|
|
112
|
+
def reset(self) -> None:
|
|
113
|
+
self._running = False
|
|
114
|
+
self._timer.stop()
|
|
115
|
+
self._total = 0
|
|
116
|
+
self._sent = 0
|
|
117
|
+
self._progress.setValue(0)
|
|
118
|
+
self._progress.setFormat("0%")
|
|
119
|
+
self._line.setText("line -- / --")
|
|
120
|
+
self._elapsed.setText("elapsed 00:00")
|
|
121
|
+
self._remaining.setText("remaining --:--")
|
|
122
|
+
|
|
123
|
+
def _tick(self) -> None:
|
|
124
|
+
if self._started is None:
|
|
125
|
+
return
|
|
126
|
+
elapsed = time.monotonic() - self._started
|
|
127
|
+
self._elapsed.setText(f"elapsed {format_duration(elapsed)}")
|
|
128
|
+
if self._estimate and self._total:
|
|
129
|
+
remaining = self._estimate * (1 - self._sent / self._total)
|
|
130
|
+
self._remaining.setText(f"remaining {format_duration(remaining)}")
|