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/dialogs.py ADDED
@@ -0,0 +1,317 @@
1
+ """Modal dialogs: pre-flight summary, tool change, Z probe and settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from PySide6.QtCore import Qt, Signal
8
+ from PySide6.QtWidgets import (
9
+ QCheckBox,
10
+ QDialog,
11
+ QDialogButtonBox,
12
+ QDoubleSpinBox,
13
+ QFormLayout,
14
+ QGridLayout,
15
+ QHBoxLayout,
16
+ QHeaderView,
17
+ QLabel,
18
+ QPushButton,
19
+ QTableWidget,
20
+ QTableWidgetItem,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from ..core.controller import SettingsEntry
26
+ from ..core.gcode_parser import Toolpath
27
+ from .job_panel import format_duration
28
+
29
+
30
+ class PreflightDialog(QDialog):
31
+ def __init__(
32
+ self,
33
+ file_name: str,
34
+ line_count: int,
35
+ toolpath: Toolpath | None,
36
+ warnings: list[str],
37
+ parent=None, # noqa: ANN001
38
+ ) -> None:
39
+ super().__init__(parent)
40
+ self.setWindowTitle("Pre-flight check")
41
+ self.setMinimumWidth(480)
42
+ self.choice = "cancel"
43
+
44
+ title = QLabel(file_name or "Untitled")
45
+ font = title.font()
46
+ font.setPointSize(15)
47
+ font.setBold(True)
48
+ title.setFont(font)
49
+
50
+ grid = QGridLayout()
51
+ grid.setColumnStretch(1, 1)
52
+
53
+ rows = [("Lines", f"{line_count:,}")]
54
+ if toolpath is not None:
55
+ rows.append(("Estimated time", format_duration(toolpath.estimated_seconds)))
56
+ rows.append(("Moves", f"{len(toolpath.segments):,}"))
57
+ if toolpath.bounds:
58
+ low, high = toolpath.bounds
59
+ rows.append(
60
+ (
61
+ "Bounding box",
62
+ f"{high[0] - low[0]:.2f} × {high[1] - low[1]:.2f} × "
63
+ f"{high[2] - low[2]:.2f} mm",
64
+ )
65
+ )
66
+ rows.append(
67
+ (
68
+ "Max cut depth",
69
+ f"{toolpath.min_z:.2f} mm" if toolpath.min_z is not None else "--",
70
+ )
71
+ )
72
+ rows.append(
73
+ (
74
+ "Tools",
75
+ ", ".join(f"T{tool}" for tool in toolpath.tools) if toolpath.tools else "none",
76
+ )
77
+ )
78
+ for row, (label, value) in enumerate(rows):
79
+ key = QLabel(label)
80
+ key.setObjectName("Muted")
81
+ grid.addWidget(key, row, 0)
82
+ grid.addWidget(QLabel(value), row, 1)
83
+
84
+ warning_box = QWidget()
85
+ warning_layout = QVBoxLayout(warning_box)
86
+ warning_layout.setContentsMargins(0, 6, 0, 0)
87
+ if warnings:
88
+ heading = QLabel("Warnings")
89
+ heading.setObjectName("SectionHeader")
90
+ warning_layout.addWidget(heading)
91
+ for warning in warnings:
92
+ warning_label = QLabel(f"• {warning}")
93
+ warning_label.setWordWrap(True)
94
+ warning_label.setStyleSheet("color: #E0A030;")
95
+ warning_layout.addWidget(warning_label)
96
+ else:
97
+ ready = QLabel("No problems detected.")
98
+ ready.setObjectName("Muted")
99
+ warning_layout.addWidget(ready)
100
+
101
+ self._dry_run = QCheckBox("Dry run (GRBL check mode, no motion)")
102
+ self._dry_run.setToolTip("Runs the job with GRBL check mode enabled ($C).")
103
+
104
+ start = QPushButton("Start")
105
+ start.setObjectName("Primary")
106
+ cancel = QPushButton("Cancel")
107
+ start.setDefault(True)
108
+ start.clicked.connect(self._start)
109
+ cancel.clicked.connect(self.reject)
110
+
111
+ buttons = QHBoxLayout()
112
+ buttons.addWidget(self._dry_run)
113
+ buttons.addStretch(1)
114
+ buttons.addWidget(cancel)
115
+ buttons.addWidget(start)
116
+
117
+ layout = QVBoxLayout(self)
118
+ layout.setContentsMargins(18, 16, 18, 14)
119
+ layout.setSpacing(10)
120
+ layout.addWidget(title)
121
+ layout.addLayout(grid)
122
+ layout.addWidget(warning_box)
123
+ layout.addStretch(1)
124
+ layout.addLayout(buttons)
125
+
126
+ def _start(self) -> None:
127
+ self.choice = "dry_run" if self._dry_run.isChecked() else "start"
128
+ self.accept()
129
+
130
+
131
+ class ToolChangeDialog(QDialog):
132
+ def __init__(self, tool_number: int | None, parent=None) -> None: # noqa: ANN001
133
+ super().__init__(parent)
134
+ self.setWindowTitle("Tool change")
135
+ self.setMinimumWidth(420)
136
+
137
+ title = QLabel(
138
+ f"Insert tool {tool_number}" if tool_number is not None else "Insert the next tool"
139
+ )
140
+ font = title.font()
141
+ font.setPointSize(14)
142
+ font.setBold(True)
143
+ title.setFont(font)
144
+
145
+ body = QLabel(
146
+ "The job has stopped and the machine is on hold. Change the tool, "
147
+ "re-probe Z if needed, then resume.\n\n"
148
+ "M6 is handled by PlotDeck and is never sent to GRBL."
149
+ )
150
+ body.setWordWrap(True)
151
+ note = QLabel(
152
+ "Make sure the spindle is off and the machine has stopped before reaching in."
153
+ )
154
+ note.setWordWrap(True)
155
+ note.setStyleSheet("color: #E0A030;")
156
+
157
+ buttons = QDialogButtonBox()
158
+ resume = buttons.addButton("Resume job", QDialogButtonBox.ButtonRole.AcceptRole)
159
+ buttons.addButton("Abort job", QDialogButtonBox.ButtonRole.RejectRole)
160
+ resume.setObjectName("Primary")
161
+ buttons.accepted.connect(self.accept)
162
+ buttons.rejected.connect(self.reject)
163
+
164
+ layout = QVBoxLayout(self)
165
+ layout.setContentsMargins(18, 16, 18, 14)
166
+ layout.setSpacing(10)
167
+ layout.addWidget(title)
168
+ layout.addWidget(body)
169
+ layout.addWidget(note)
170
+ layout.addWidget(buttons)
171
+
172
+
173
+ class ProbeDialog(QDialog):
174
+ def __init__(self, parent=None) -> None: # noqa: ANN001
175
+ super().__init__(parent)
176
+ self.setWindowTitle("Probe Z")
177
+ self.setMinimumWidth(360)
178
+
179
+ intro = QLabel(
180
+ "Attach the touch-plate clip, place the plate under the bit and press Probe. "
181
+ "PlotDeck probes down slowly, sets work zero to the plate thickness, "
182
+ "then retracts."
183
+ )
184
+ intro.setWordWrap(True)
185
+
186
+ self.thickness = QDoubleSpinBox()
187
+ self.thickness.setRange(0.0, 50.0)
188
+ self.thickness.setDecimals(2)
189
+ self.thickness.setValue(10.0)
190
+ self.thickness.setSuffix(" mm")
191
+ self.feed = QDoubleSpinBox()
192
+ self.feed.setRange(1, 500)
193
+ self.feed.setValue(50)
194
+ self.feed.setSuffix(" mm/min")
195
+ self.travel = QDoubleSpinBox()
196
+ self.travel.setRange(1, 200)
197
+ self.travel.setValue(30)
198
+ self.travel.setSuffix(" mm")
199
+ self.retract = QDoubleSpinBox()
200
+ self.retract.setRange(0, 50)
201
+ self.retract.setValue(5)
202
+ self.retract.setSuffix(" mm")
203
+
204
+ form = QFormLayout()
205
+ form.addRow("Plate thickness", self.thickness)
206
+ form.addRow("Probe feed", self.feed)
207
+ form.addRow("Max travel", self.travel)
208
+ form.addRow("Retract height", self.retract)
209
+
210
+ buttons = QDialogButtonBox(
211
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
212
+ )
213
+ buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Probe")
214
+ buttons.accepted.connect(self.accept)
215
+ buttons.rejected.connect(self.reject)
216
+
217
+ layout = QVBoxLayout(self)
218
+ layout.setContentsMargins(18, 16, 18, 14)
219
+ layout.setSpacing(10)
220
+ layout.addWidget(intro)
221
+ layout.addLayout(form)
222
+ layout.addWidget(buttons)
223
+
224
+ def values(self) -> tuple[float, float, float, float]:
225
+ return (
226
+ self.thickness.value(),
227
+ self.feed.value(),
228
+ self.travel.value(),
229
+ self.retract.value(),
230
+ )
231
+
232
+
233
+ class SettingsDialog(QDialog):
234
+ """Editable ``$$`` settings table."""
235
+
236
+ refreshRequested = Signal()
237
+ writeRequested = Signal(int, str)
238
+
239
+ def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
240
+ super().__init__(parent)
241
+ self._token = token or {}
242
+ self.setWindowTitle("GRBL settings")
243
+ self.setMinimumSize(640, 520)
244
+
245
+ intro = QLabel(
246
+ "Values are read from the controller with $$. Select a row, edit the value "
247
+ "and press Write. Changes take effect immediately."
248
+ )
249
+ intro.setWordWrap(True)
250
+ intro.setObjectName("Muted")
251
+
252
+ self._table = QTableWidget(0, 3)
253
+ self._table.setHorizontalHeaderLabels(["#", "Value", "Description"])
254
+ self._table.verticalHeader().setVisible(False)
255
+ header = self._table.horizontalHeader()
256
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
257
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
258
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
259
+ self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
260
+ self._table.setEditTriggers(
261
+ QTableWidget.EditTrigger.DoubleClicked | QTableWidget.EditTrigger.EditKeyPressed
262
+ )
263
+
264
+ self._status = QLabel("Press Refresh to read settings from the machine.")
265
+ self._status.setObjectName("Muted")
266
+
267
+ refresh = QPushButton("Refresh")
268
+ write = QPushButton("Write selected")
269
+ close = QPushButton("Close")
270
+ refresh.clicked.connect(self.refreshRequested)
271
+ write.clicked.connect(self._write)
272
+ close.clicked.connect(self.accept)
273
+
274
+ buttons = QHBoxLayout()
275
+ buttons.addWidget(self._status, 1)
276
+ buttons.addWidget(refresh)
277
+ buttons.addWidget(write)
278
+ buttons.addWidget(close)
279
+
280
+ layout = QVBoxLayout(self)
281
+ layout.setContentsMargins(16, 14, 16, 12)
282
+ layout.setSpacing(8)
283
+ layout.addWidget(intro)
284
+ layout.addWidget(self._table, 1)
285
+ layout.addLayout(buttons)
286
+
287
+ def set_settings(self, entries: list[SettingsEntry]) -> None:
288
+ self._table.setRowCount(0)
289
+ for entry in entries:
290
+ row = self._table.rowCount()
291
+ self._table.insertRow(row)
292
+ number = QTableWidgetItem(f"${entry.number}")
293
+ number.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
294
+ value = QTableWidgetItem(entry.value)
295
+ number.setData(Qt.ItemDataRole.UserRole, entry.number)
296
+ self._table.setItem(row, 0, number)
297
+ self._table.setItem(row, 1, value)
298
+ description = QTableWidgetItem(entry.description)
299
+ description.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
300
+ self._table.setItem(row, 2, description)
301
+ self._status.setText(f"{len(entries)} settings loaded.")
302
+
303
+ def _write(self) -> None:
304
+ row = self._table.currentRow()
305
+ if row < 0:
306
+ self._status.setText("Select a setting first.")
307
+ return
308
+ number_item = self._table.item(row, 0)
309
+ value_item = self._table.item(row, 1)
310
+ if number_item is None or value_item is None:
311
+ return
312
+ value = value_item.text().strip()
313
+ if not re.fullmatch(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)", value):
314
+ self._status.setText("Enter a numeric value.")
315
+ return
316
+ self.writeRequested.emit(int(number_item.data(Qt.ItemDataRole.UserRole)), value)
317
+ self._status.setText(f"Wrote ${number_item.data(Qt.ItemDataRole.UserRole)}={value}")