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/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("")
@@ -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)}")