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.
@@ -0,0 +1,1116 @@
1
+ """PlotDeck main window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+
11
+ from PySide6.QtCore import QObject, QSettings, Qt, QTimer, Signal
12
+ from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut
13
+ from PySide6.QtWidgets import (
14
+ QApplication,
15
+ QDialog,
16
+ QDockWidget,
17
+ QFileDialog,
18
+ QInputDialog,
19
+ QLabel,
20
+ QMainWindow,
21
+ QMessageBox,
22
+ QSizePolicy,
23
+ QSplitter,
24
+ QToolBar,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from .. import APP_NAME
30
+ from ..core.controller import MachineController, SettingsEntry
31
+ from ..core.gcode_parser import ProgramLine, Toolpath, build_toolpath
32
+ from ..core.grbl_protocol import ResponseKind
33
+ from ..core.status import MachineStatus
34
+ from ..core.streamer import StreamerState
35
+ from ..utils.settings import AppSettings
36
+ from .connection_panel import ConnectionPanel
37
+ from .console import Console
38
+ from .dialogs import PreflightDialog, ProbeDialog, SettingsDialog, ToolChangeDialog
39
+ from .editor import FindBar, GcodeEditor
40
+ from .job_panel import JobPanel
41
+ from .jog_panel import JogPanel
42
+ from .manual import ManualDialog
43
+ from .status_panel import StatusPanel
44
+ from .theme import build_stylesheet, is_dark_system, tokens
45
+ from .visualizer import Visualizer
46
+
47
+ GCODE_FILTER = "G-code (*.nc *.gcode *.ngc *.tap *.txt *.cnc);;All files (*)"
48
+
49
+
50
+ class ControllerBridge(QObject):
51
+ line = Signal(object, str)
52
+ sent = Signal(str)
53
+ status = Signal(object)
54
+ connected = Signal(str)
55
+ disconnected = Signal(str)
56
+ alarm = Signal(int, str)
57
+ message = Signal(str)
58
+ commandError = Signal(int, str)
59
+ streamError = Signal(object, int, str)
60
+ progress = Signal(int, int, int)
61
+ jobFinished = Signal(str)
62
+ toolChange = Signal(object)
63
+ streamState = Signal(object)
64
+ settings = Signal(object)
65
+ portError = Signal(str)
66
+
67
+
68
+ class MainWindow(QMainWindow):
69
+ def __init__(
70
+ self,
71
+ controller: MachineController | None = None,
72
+ app_settings: AppSettings | None = None,
73
+ ) -> None:
74
+ super().__init__()
75
+ self.app_settings = app_settings or AppSettings()
76
+ self.controller = controller or MachineController()
77
+ self._settings: QSettings = self.app_settings._settings
78
+
79
+ self._current_path: Path | None = None
80
+ self._program: list[ProgramLine] = []
81
+ self._toolpath: Toolpath | None = None
82
+ self._limits: tuple[float, float, float] | None = None
83
+ self._status = MachineStatus()
84
+ self._connected = False
85
+ self._stream_state = StreamerState.IDLE
86
+ self._last_progress_line = 0
87
+ self._last_editor_update = 0.0
88
+ self._wcs = 1
89
+ self._homed = False
90
+ self._z_zeroed = False
91
+ self._dry_run = False
92
+ self._alarm_open = False
93
+ self._caffeinate: subprocess.Popen | None = None
94
+ self._settings_dialog: SettingsDialog | None = None
95
+ self._manual_dialog: ManualDialog | None = None
96
+ self._dark = self.app_settings.dark_theme(is_dark_system())
97
+
98
+ self.setWindowTitle(APP_NAME)
99
+ self.resize(1440, 900)
100
+ self.setMinimumSize(1100, 700)
101
+ self.setAcceptDrops(True)
102
+
103
+ self.bridge = ControllerBridge()
104
+ self._build_ui()
105
+ self._build_toolbar()
106
+ self._build_menus()
107
+ self._build_statusbar()
108
+ self._wire_controller()
109
+ self._wire_ui()
110
+ self._refresh_ports()
111
+ self._rebuild_recent_menu()
112
+ self._update_actions()
113
+ self._restore_window()
114
+
115
+ self.apply_theme(self._dark, persist=False)
116
+ self.editor.document().modificationChanged.connect(lambda _: self._update_title())
117
+ self._update_title()
118
+
119
+ # ------------------------------------------------------------------ #
120
+ # Construction
121
+ # ------------------------------------------------------------------ #
122
+ def _build_ui(self) -> None:
123
+ self.editor = GcodeEditor()
124
+ self.editor.setPlaceholderText("Open a G-code file (⌘O), drop one here, or start typing…")
125
+ self.console = Console()
126
+ self.find_bar = FindBar(self.editor)
127
+ self.status_panel = StatusPanel()
128
+ self.jog_panel = JogPanel()
129
+ self.visualizer = Visualizer()
130
+ self.job_panel = JobPanel()
131
+
132
+ central = QWidget()
133
+ layout = QVBoxLayout(central)
134
+ layout.setContentsMargins(0, 0, 0, 0)
135
+ layout.setSpacing(0)
136
+ layout.addWidget(self.find_bar)
137
+ splitter = QSplitter(Qt.Orientation.Vertical)
138
+ splitter.addWidget(self.editor)
139
+ splitter.addWidget(self.console)
140
+ splitter.setStretchFactor(0, 3)
141
+ splitter.setStretchFactor(1, 2)
142
+ splitter.setSizes([560, 340])
143
+ layout.addWidget(splitter, 1)
144
+ self.setCentralWidget(central)
145
+
146
+ left = QWidget()
147
+ left_layout = QVBoxLayout(left)
148
+ left_layout.setContentsMargins(0, 0, 0, 0)
149
+ left_layout.setSpacing(1)
150
+ left_layout.addWidget(self.status_panel)
151
+ left_layout.addWidget(self.jog_panel)
152
+ left_layout.addStretch(1)
153
+
154
+ self.dock_machine = QDockWidget("Machine", self)
155
+ self.dock_machine.setObjectName("MachineDock")
156
+ self.dock_machine.setWidget(left)
157
+ self.dock_machine.setMinimumWidth(300)
158
+
159
+ self.dock_toolpath = QDockWidget("Toolpath", self)
160
+ self.dock_toolpath.setObjectName("ToolpathDock")
161
+ self.dock_toolpath.setWidget(self.visualizer)
162
+ self.dock_toolpath.setMinimumWidth(340)
163
+
164
+ self.dock_job = QDockWidget("Job", self)
165
+ self.dock_job.setObjectName("JobDock")
166
+ self.dock_job.setWidget(self.job_panel)
167
+ self.dock_job.setMinimumHeight(140)
168
+
169
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.dock_machine)
170
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_toolpath)
171
+ self.splitDockWidget(self.dock_toolpath, self.dock_job, Qt.Orientation.Vertical)
172
+ self.resizeDocks([self.dock_machine], [310], Qt.Orientation.Horizontal)
173
+ self.resizeDocks([self.dock_toolpath, self.dock_job], [620, 200], Qt.Orientation.Vertical)
174
+
175
+ def _build_toolbar(self) -> None:
176
+ toolbar = QToolBar("Main")
177
+ toolbar.setObjectName("MainToolbar")
178
+ toolbar.setMovable(False)
179
+ toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
180
+ self.addToolBar(toolbar)
181
+
182
+ self.connection_panel = ConnectionPanel()
183
+ toolbar.addWidget(self.connection_panel)
184
+ toolbar.addSeparator()
185
+
186
+ self.action_run = self._action("Run", self._run, "Ctrl+R", "Run the loaded file (⌘R)")
187
+ self.action_pause = self._action(
188
+ "Pause", self._toggle_pause, None, "Feed hold / cycle start"
189
+ )
190
+ self.action_stop = self._action(
191
+ "Stop", self._stop, QKeySequence("Ctrl+."), "Stop the job (⌘.)"
192
+ )
193
+ toolbar.addAction(self.action_run)
194
+ toolbar.addAction(self.action_pause)
195
+ toolbar.addAction(self.action_stop)
196
+ toolbar.addSeparator()
197
+
198
+ self.action_home = self._action("Home", self._home, None, "Run the homing cycle ($H)")
199
+ self.action_unlock = self._action("Unlock", self._unlock, None, "Clear an alarm ($X)")
200
+ self.action_reset = self._action(
201
+ "Reset", self._soft_reset, QKeySequence("Ctrl+Shift+."), "Soft reset (Ctrl-X)"
202
+ )
203
+ toolbar.addAction(self.action_home)
204
+ toolbar.addAction(self.action_unlock)
205
+ toolbar.addAction(self.action_reset)
206
+
207
+ spacer = QWidget()
208
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
209
+ toolbar.addWidget(spacer)
210
+ self.action_manual = self._action(
211
+ "Manual", self._show_manual, "F1", "Open the PlotDeck user manual"
212
+ )
213
+ toolbar.addAction(self.action_manual)
214
+ toolbar.addSeparator()
215
+
216
+ self.firmware_label = QLabel("GRBL")
217
+ self.firmware_label.setObjectName("Muted")
218
+ toolbar.addWidget(self.firmware_label)
219
+
220
+ stop_button = toolbar.widgetForAction(self.action_stop)
221
+ if stop_button is not None:
222
+ stop_button.setObjectName("Danger")
223
+
224
+ def _build_menus(self) -> None:
225
+ menu = self.menuBar()
226
+
227
+ file_menu = menu.addMenu("&File")
228
+ file_menu.addAction(self._action("New", self._new_file, QKeySequence.StandardKey.New))
229
+ file_menu.addAction(self._action("Open…", self._open_file, QKeySequence.StandardKey.Open))
230
+ self.recent_menu = file_menu.addMenu("Open Recent")
231
+ file_menu.addSeparator()
232
+ file_menu.addAction(self._action("Save", self._save, QKeySequence.StandardKey.Save))
233
+ file_menu.addAction(
234
+ self._action("Save As…", self._save_as, QKeySequence.StandardKey.SaveAs)
235
+ )
236
+ file_menu.addSeparator()
237
+ file_menu.addAction(self._action("Close", self.close, QKeySequence.StandardKey.Close))
238
+ file_menu.addAction("Quit", self.close, QKeySequence.StandardKey.Quit)
239
+
240
+ edit_menu = menu.addMenu("&Edit")
241
+ edit_menu.addAction(self._action("Undo", self.editor.undo, QKeySequence.StandardKey.Undo))
242
+ edit_menu.addAction(self._action("Redo", self.editor.redo, QKeySequence.StandardKey.Redo))
243
+ edit_menu.addSeparator()
244
+ edit_menu.addAction(self._action("Find…", self._show_find, QKeySequence.StandardKey.Find))
245
+ edit_menu.addAction(
246
+ self._action("Replace…", self._show_replace, QKeySequence("Ctrl+Alt+F"))
247
+ )
248
+ edit_menu.addAction(self._action("Go to Line…", self._go_to_line, QKeySequence("Ctrl+L")))
249
+ edit_menu.addSeparator()
250
+ edit_menu.addAction(
251
+ self._action("Toggle Comment", self._toggle_comment, QKeySequence("Ctrl+/"))
252
+ )
253
+ edit_menu.addAction("Uppercase Selection", self.editor.uppercase_selection)
254
+ edit_menu.addAction("Strip Comments", self._strip_comments)
255
+ edit_menu.addAction("Renumber Lines", self.editor.renumber)
256
+ transform = edit_menu.addMenu("Transform")
257
+ transform.addAction("Offset…", self._transform_offset)
258
+ transform.addAction("Scale…", self._transform_scale)
259
+ transform.addAction("Rotate…", self._transform_rotate)
260
+
261
+ view_menu = menu.addMenu("&View")
262
+ view_menu.addAction(self.dock_machine.toggleViewAction())
263
+ view_menu.addAction(self.dock_toolpath.toggleViewAction())
264
+ view_menu.addAction(self.dock_job.toggleViewAction())
265
+ view_menu.addSeparator()
266
+ view_menu.addAction(
267
+ self._action(
268
+ "Zoom In",
269
+ self.visualizer.zoom_in,
270
+ QKeySequence.StandardKey.ZoomIn,
271
+ "Zoom into the toolpath",
272
+ )
273
+ )
274
+ view_menu.addAction(
275
+ self._action(
276
+ "Zoom Out",
277
+ self.visualizer.zoom_out,
278
+ QKeySequence.StandardKey.ZoomOut,
279
+ "Zoom out of the toolpath",
280
+ )
281
+ )
282
+ view_menu.addAction(self._action("Fit Toolpath", self.visualizer.fit, "Ctrl+0"))
283
+ theme_menu = view_menu.addMenu("Theme")
284
+ self._theme_group = QActionGroup(self)
285
+ dark_action = QAction("Dark", self, checkable=True)
286
+ light_action = QAction("Light", self, checkable=True)
287
+ self._theme_group.addAction(dark_action)
288
+ self._theme_group.addAction(light_action)
289
+ dark_action.setChecked(self._dark)
290
+ light_action.setChecked(not self._dark)
291
+ dark_action.triggered.connect(lambda: self.apply_theme(True))
292
+ light_action.triggered.connect(lambda: self.apply_theme(False))
293
+ theme_menu.addActions([dark_action, light_action])
294
+ view_menu.addSeparator()
295
+ view_menu.addAction(self._action("Focus Console", self.console.focus_input, "Ctrl+`"))
296
+
297
+ machine_menu = menu.addMenu("&Machine")
298
+ machine_menu.addAction(
299
+ self._action("Connect / Disconnect", self._toggle_connection, "Ctrl+K")
300
+ )
301
+ machine_menu.addSeparator()
302
+ machine_menu.addAction(self.action_run)
303
+ machine_menu.addAction(self.action_pause)
304
+ machine_menu.addAction(self.action_stop)
305
+ machine_menu.addAction(
306
+ self._action("Run From Cursor", self._run_from_cursor, "Ctrl+Shift+R")
307
+ )
308
+ machine_menu.addSeparator()
309
+ machine_menu.addAction(self.action_home)
310
+ machine_menu.addAction(self.action_unlock)
311
+ machine_menu.addAction(self.action_reset)
312
+ machine_menu.addSeparator()
313
+ machine_menu.addAction("Cancel Jog", self.controller.jog_cancel)
314
+ machine_menu.addAction("Probe Z…", self._probe_dialog)
315
+ machine_menu.addAction("Spindle On…", self._spindle_on)
316
+ machine_menu.addAction("Spindle Off", self.controller.spindle_off)
317
+ machine_menu.addSeparator()
318
+ machine_menu.addAction("GRBL Settings…", self._show_settings)
319
+
320
+ help_menu = menu.addMenu("&Help")
321
+ help_menu.addAction(self.action_manual)
322
+ help_menu.addAction("Keyboard Shortcuts", self._show_shortcuts)
323
+ help_menu.addAction("About PlotDeck", self._about)
324
+
325
+ fit_shortcut = QShortcut(QKeySequence("F"), self.visualizer)
326
+ fit_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
327
+ fit_shortcut.activated.connect(self.visualizer.fit)
328
+
329
+ def _build_statusbar(self) -> None:
330
+ bar = self.statusBar()
331
+ self._status_connection = QLabel("Offline")
332
+ self._status_port = QLabel("")
333
+ self._status_modal = QLabel("G21 · G90 · G54")
334
+ self._status_cursor = QLabel("Ln 1, Col 1")
335
+ self._status_notice = QLabel("")
336
+ for label in (
337
+ self._status_connection,
338
+ self._status_port,
339
+ self._status_cursor,
340
+ self._status_modal,
341
+ self._status_notice,
342
+ ):
343
+ label.setObjectName("Muted")
344
+ bar.addWidget(self._status_connection)
345
+ bar.addWidget(self._status_port)
346
+ bar.addPermanentWidget(self._status_notice, 1)
347
+ bar.addPermanentWidget(self._status_cursor)
348
+ bar.addPermanentWidget(self._status_modal)
349
+
350
+ def _wire_controller(self) -> None:
351
+ controller = self.controller
352
+ bridge = self.bridge
353
+ controller.on_line = bridge.line.emit
354
+ controller.on_sent = bridge.sent.emit
355
+ controller.on_status = bridge.status.emit
356
+ controller.on_connected = bridge.connected.emit
357
+ controller.on_disconnected = bridge.disconnected.emit
358
+ controller.on_alarm = bridge.alarm.emit
359
+ controller.on_message = bridge.message.emit
360
+ controller.on_command_error = bridge.commandError.emit
361
+ controller.on_stream_error = bridge.streamError.emit
362
+ controller.on_progress = bridge.progress.emit
363
+ controller.on_job_finished = bridge.jobFinished.emit
364
+ controller.on_tool_change = bridge.toolChange.emit
365
+ controller.on_stream_state = bridge.streamState.emit
366
+ controller.on_settings = bridge.settings.emit
367
+ controller.on_port_error = bridge.portError.emit
368
+
369
+ bridge.line.connect(self._on_line)
370
+ bridge.sent.connect(lambda text: self.console.append("sent", text))
371
+ bridge.status.connect(self._on_status)
372
+ bridge.connected.connect(self._on_connected)
373
+ bridge.disconnected.connect(self._on_disconnected)
374
+ bridge.alarm.connect(self._on_alarm)
375
+ bridge.message.connect(self._on_message)
376
+ bridge.commandError.connect(self._on_command_error)
377
+ bridge.streamError.connect(self._on_stream_error)
378
+ bridge.progress.connect(self._on_progress)
379
+ bridge.jobFinished.connect(self._on_job_finished)
380
+ bridge.toolChange.connect(self._on_tool_change)
381
+ bridge.streamState.connect(self._on_stream_state)
382
+ bridge.settings.connect(self._on_settings)
383
+ bridge.portError.connect(self._on_port_error)
384
+
385
+ def _wire_ui(self) -> None:
386
+ self.connection_panel.connectRequested.connect(self._connect)
387
+ self.connection_panel.disconnectRequested.connect(self.controller.disconnect)
388
+ self.console.commandSubmitted.connect(self._send_command)
389
+ self.jog_panel.jogRequested.connect(self.controller.jog)
390
+ self.jog_panel.cancelRequested.connect(self.controller.jog_cancel)
391
+ self.jog_panel.homeRequested.connect(self._home)
392
+ self.jog_panel.unlockRequested.connect(self._unlock)
393
+ self.jog_panel.zeroRequested.connect(self._zero_axis)
394
+ self.jog_panel.gotoZeroRequested.connect(lambda: self.controller.goto_zero("XY"))
395
+ self.jog_panel.wcsChanged.connect(self._change_wcs)
396
+ self.status_panel.zeroRequested.connect(self._zero_axis)
397
+ self.status_panel.feedOverrideChanged.connect(
398
+ lambda value: self.controller.set_override("feed", value)
399
+ )
400
+ self.status_panel.spindleOverrideChanged.connect(
401
+ lambda value: self.controller.set_override("spindle", value)
402
+ )
403
+ self.status_panel.rapidOverrideChanged.connect(
404
+ lambda value: self.controller.set_override("rapid", value)
405
+ )
406
+ self.editor.cursorLineChanged.connect(self._update_cursor_label)
407
+
408
+ # ------------------------------------------------------------------ #
409
+ # Helpers
410
+ # ------------------------------------------------------------------ #
411
+ def _action(
412
+ self,
413
+ text: str,
414
+ slot, # noqa: ANN001
415
+ shortcut=None, # noqa: ANN001
416
+ tip: str = "",
417
+ ) -> QAction:
418
+ action = QAction(text, self)
419
+ if shortcut is not None:
420
+ action.setShortcut(
421
+ QKeySequence(shortcut) if not isinstance(shortcut, QKeySequence) else shortcut
422
+ )
423
+ if tip:
424
+ action.setStatusTip(tip)
425
+ action.setToolTip(tip)
426
+ action.triggered.connect(slot)
427
+ return action
428
+
429
+ def _update_title(self) -> None:
430
+ name = self._current_path.name if self._current_path else "Untitled"
431
+ modified = self.editor.document().isModified()
432
+ self.setWindowTitle(f"{APP_NAME} — {name}{' *' if modified else ''}")
433
+
434
+ def _update_cursor_label(self, line: int) -> None:
435
+ column = self.editor.textCursor().positionInBlock() + 1
436
+ self._status_cursor.setText(f"Ln {line}, Col {column}")
437
+
438
+ def _refresh_ports(self) -> None:
439
+ self.connection_panel.refresh_ports()
440
+ last = self.app_settings.last_port()
441
+ if last:
442
+ self.connection_panel.select_port(last)
443
+ self.connection_panel.set_baud(self.app_settings.last_baud())
444
+
445
+ # ------------------------------------------------------------------ #
446
+ # Files
447
+ # ------------------------------------------------------------------ #
448
+ def _maybe_save(self) -> bool:
449
+ if not self.editor.document().isModified():
450
+ return True
451
+ box = QMessageBox(self)
452
+ box.setWindowTitle("Unsaved changes")
453
+ box.setText("Save changes before continuing?")
454
+ box.setStandardButtons(
455
+ QMessageBox.StandardButton.Save
456
+ | QMessageBox.StandardButton.Discard
457
+ | QMessageBox.StandardButton.Cancel
458
+ )
459
+ box.setDefaultButton(QMessageBox.StandardButton.Save)
460
+ result = box.exec()
461
+ if result == QMessageBox.StandardButton.Save:
462
+ return self._save()
463
+ return result == QMessageBox.StandardButton.Discard
464
+
465
+ def _new_file(self) -> None:
466
+ if not self._maybe_save():
467
+ return
468
+ self.editor.set_source_text("")
469
+ self._current_path = None
470
+ self._program = []
471
+ self._toolpath = None
472
+ self.visualizer.clear()
473
+ self.job_panel.reset()
474
+ self.editor.document().setModified(False)
475
+ self._update_title()
476
+ self._update_actions()
477
+
478
+ def _open_file(self) -> None:
479
+ if not self._maybe_save():
480
+ return
481
+ path, _ = QFileDialog.getOpenFileName(self, "Open G-code", str(Path.home()), GCODE_FILTER)
482
+ if path:
483
+ self.open_path(path)
484
+
485
+ def open_path(self, path: str) -> None:
486
+ try:
487
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
488
+ except OSError as exc:
489
+ QMessageBox.critical(self, "Could not open file", str(exc))
490
+ return
491
+ self._current_path = Path(path)
492
+ self.editor.set_source_text(text)
493
+ self.editor.document().setModified(False)
494
+ self.app_settings.add_recent_file(str(self._current_path))
495
+ self._rebuild_recent_menu()
496
+ self._update_title()
497
+ self._refresh_program()
498
+ self.job_panel.reset()
499
+ self.console.log(f"Loaded {self._current_path.name} ({len(self._program)} lines)")
500
+ self._update_actions()
501
+
502
+ def _save(self) -> bool:
503
+ if self._current_path is None:
504
+ return self._save_as()
505
+ try:
506
+ self._current_path.write_text(self.editor.source_text(), encoding="utf-8")
507
+ except OSError as exc:
508
+ QMessageBox.critical(self, "Could not save file", str(exc))
509
+ return False
510
+ self.editor.document().setModified(False)
511
+ self.app_settings.add_recent_file(str(self._current_path))
512
+ self._rebuild_recent_menu()
513
+ self._update_title()
514
+ return True
515
+
516
+ def _save_as(self) -> bool:
517
+ path, _ = QFileDialog.getSaveFileName(
518
+ self,
519
+ "Save G-code",
520
+ str(self._current_path or Path.home() / "job.nc"),
521
+ GCODE_FILTER,
522
+ )
523
+ if not path:
524
+ return False
525
+ self._current_path = Path(path)
526
+ return self._save()
527
+
528
+ def _rebuild_recent_menu(self) -> None:
529
+ self.recent_menu.clear()
530
+ files = self.app_settings.recent_files()
531
+ if not files:
532
+ action = self.recent_menu.addAction("No recent files")
533
+ action.setEnabled(False)
534
+ return
535
+ for path in files:
536
+ action = self.recent_menu.addAction(Path(path).name)
537
+ action.setToolTip(path)
538
+ action.triggered.connect(lambda _=False, p=path: self.open_path(p))
539
+ self.recent_menu.addSeparator()
540
+ self.recent_menu.addAction("Clear Menu", self.app_settings.clear_recent_files)
541
+
542
+ def _refresh_program(self) -> None:
543
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
544
+ try:
545
+ self._program = self.editor.program()
546
+ self.visualizer.set_program(self._program, limits=self._limits)
547
+ self._toolpath = self.visualizer.toolpath
548
+ if self._program:
549
+ self._status_notice.setText(
550
+ f"{len(self._program):,} lines · "
551
+ f"{self._toolpath.total_length:.0f} mm of motion"
552
+ )
553
+ finally:
554
+ QApplication.restoreOverrideCursor()
555
+ self._update_actions()
556
+
557
+ def _toggle_comment(self) -> None:
558
+ self.editor.toggle_comment()
559
+ self.editor.document().setModified(True)
560
+ self._refresh_program()
561
+
562
+ def _strip_comments(self) -> None:
563
+ self.editor.strip_comments()
564
+ self._refresh_program()
565
+
566
+ def _transform_offset(self) -> None:
567
+ text, ok = QInputDialog.getText(
568
+ self, "Offset", "dx, dy, dz in mm (for example: 10, 0, -1)", text="0, 0, 0"
569
+ )
570
+ if not ok:
571
+ return
572
+ try:
573
+ dx, dy, dz = (float(part.strip()) for part in text.split(","))
574
+ except ValueError:
575
+ QMessageBox.warning(self, "Offset", "Enter three numbers separated by commas.")
576
+ return
577
+ self.editor.transform(dx=dx, dy=dy, dz=dz)
578
+ self._after_transform()
579
+
580
+ def _transform_scale(self) -> None:
581
+ factor, ok = QInputDialog.getDouble(self, "Scale", "Scale factor", 1.0, 0.01, 100.0, 3)
582
+ if ok:
583
+ self.editor.transform(scale=factor)
584
+ self._after_transform()
585
+
586
+ def _transform_rotate(self) -> None:
587
+ angle, ok = QInputDialog.getDouble(
588
+ self, "Rotate", "Rotation around Z (degrees)", 0.0, -360.0, 360.0, 2
589
+ )
590
+ if ok:
591
+ self.editor.transform(rotate=angle)
592
+ self._after_transform()
593
+
594
+ def _after_transform(self) -> None:
595
+ self.editor.document().setModified(True)
596
+ self._refresh_program()
597
+ self._update_title()
598
+
599
+ def _show_find(self) -> None:
600
+ self.find_bar.activate(replace=False)
601
+
602
+ def _show_replace(self) -> None:
603
+ self.find_bar.activate(replace=True)
604
+
605
+ def _go_to_line(self) -> None:
606
+ line, ok = QInputDialog.getInt(
607
+ self,
608
+ "Go to line",
609
+ "Line number",
610
+ self.editor.current_line(),
611
+ 1,
612
+ max(1, self.editor.blockCount()),
613
+ )
614
+ if ok:
615
+ self.editor.go_to_line(line)
616
+
617
+ # ------------------------------------------------------------------ #
618
+ # Connection
619
+ # ------------------------------------------------------------------ #
620
+ def _connect(self, port: str, baud: int, demo: bool) -> None:
621
+ self.app_settings.set_last_port(port)
622
+ self.app_settings.set_last_baud(baud)
623
+ self._status_connection.setText("Connecting…")
624
+ self._status_notice.setText(
625
+ "Waiting for the GRBL banner (the Uno resets when the port opens)…"
626
+ )
627
+ self.controller.connect(port, baud, demo)
628
+
629
+ def _toggle_connection(self) -> None:
630
+ if self._connected:
631
+ self.controller.disconnect()
632
+ else:
633
+ self.connection_panel.toggle()
634
+
635
+ def _on_connected(self, version: str) -> None:
636
+ self._connected = True
637
+ self._homed = False
638
+ self._z_zeroed = False
639
+ self._alarm_open = False
640
+ self._status_connection.setText("Connected")
641
+ self._status_port.setText(
642
+ f"{self.app_settings.last_port()} · {self.app_settings.last_baud()} baud"
643
+ )
644
+ self.connection_panel.set_connected(True, self.app_settings.last_port())
645
+ self.status_panel.set_connected(True, version)
646
+ self.firmware_label.setText(f"GRBL {version}" if version else "GRBL")
647
+ self.console.log(f"Connected: GRBL {version}")
648
+ self._status_notice.setText(f"Connected to GRBL {version}")
649
+ self._update_actions()
650
+ QTimer.singleShot(350, self._request_settings_silently)
651
+
652
+ def _on_disconnected(self, reason: str) -> None:
653
+ self._connected = False
654
+ self.status_panel.set_connected(False)
655
+ self.connection_panel.set_connected(False)
656
+ self._status_connection.setText("Offline")
657
+ self._status_port.setText("")
658
+ self._status_notice.setText(reason)
659
+ self.console.log(f"Disconnected: {reason}")
660
+ if self._last_progress_line and self._stream_state in (
661
+ StreamerState.RUNNING,
662
+ StreamerState.PAUSED,
663
+ ):
664
+ self.editor.mark_error(self._last_progress_line)
665
+ self.console.log(
666
+ f"Job interrupted near line {self._last_progress_line}. "
667
+ "Re-home before resuming from that line."
668
+ )
669
+ self._stream_state = StreamerState.IDLE
670
+ self._update_actions()
671
+
672
+ def _on_port_error(self, message: str) -> None:
673
+ self.connection_panel.set_error(message)
674
+ self.console.append("error", message)
675
+ self._status_connection.setText("Offline")
676
+ self._status_notice.setText(message)
677
+
678
+ def _request_settings_silently(self) -> None:
679
+ if self._connected and not (
680
+ self.controller.streamer and self.controller.streamer.is_active
681
+ ):
682
+ self.controller.request_settings()
683
+
684
+ # ------------------------------------------------------------------ #
685
+ # Running jobs
686
+ # ------------------------------------------------------------------ #
687
+ def _run(self) -> None:
688
+ self._start_job()
689
+
690
+ def _run_from_cursor(self) -> None:
691
+ self._start_job(from_line=self.editor.current_line())
692
+
693
+ def _start_job(self, from_line: int | None = None) -> None:
694
+ if not self._connected:
695
+ QMessageBox.information(self, "Not connected", "Connect to the machine first.")
696
+ return
697
+ streamer = self.controller.streamer
698
+ if streamer is not None and streamer.is_active:
699
+ return
700
+ if self._status.is_alarm:
701
+ QMessageBox.warning(
702
+ self,
703
+ "Alarm active",
704
+ "Clear the alarm (Unlock) and home the machine before running a job.",
705
+ )
706
+ return
707
+ program = self.editor.program()
708
+ if not program:
709
+ QMessageBox.information(self, "Nothing to run", "The file has no executable lines.")
710
+ return
711
+
712
+ self._refresh_program()
713
+ toolpath = build_toolpath(program, limits=self._limits)
714
+ warnings = self._collect_warnings(program, toolpath, from_line)
715
+ dialog = PreflightDialog(
716
+ self._current_path.name if self._current_path else "Untitled",
717
+ len(program),
718
+ toolpath,
719
+ warnings,
720
+ self,
721
+ )
722
+ if dialog.exec() != QDialog.DialogCode.Accepted:
723
+ return
724
+
725
+ dry_run = dialog.choice == "dry_run"
726
+ if dry_run and not self.controller.send_line("$C"):
727
+ QMessageBox.warning(self, "Dry run", "Could not enable GRBL check mode.")
728
+ return
729
+ started = self.controller.run_program(program, from_line)
730
+ if not started:
731
+ if dry_run:
732
+ self.controller.send_line("$C")
733
+ QMessageBox.warning(
734
+ self,
735
+ "Could not start",
736
+ "The job could not start. Check the connection and machine state.",
737
+ )
738
+ return
739
+
740
+ self._dry_run = dry_run
741
+ self._keep_awake(True)
742
+ self._last_progress_line = from_line or 0
743
+ self._last_editor_update = 0.0
744
+ self.job_panel.begin(len(program), toolpath.estimated_seconds)
745
+ self.editor.clear_markers()
746
+ self.visualizer.set_progress_line(0, force=True)
747
+ self._status_notice.setText("Dry run started (check mode)" if dry_run else "Job running")
748
+ self._update_actions()
749
+
750
+ def _collect_warnings(
751
+ self, program: list[ProgramLine], toolpath: Toolpath, from_line: int | None
752
+ ) -> list[str]:
753
+ warnings: list[str] = []
754
+ if not self._homed:
755
+ warnings.append(
756
+ "The machine has not been homed in this session. Soft limits and "
757
+ "work coordinates may not be where you expect."
758
+ )
759
+ if not self._z_zeroed:
760
+ warnings.append("Z zero has not been set in this session.")
761
+ if self.editor.document().isModified():
762
+ warnings.append("The file has unsaved changes; the editor contents will run.")
763
+ long_lines = sum(1 for line in program if line.too_long)
764
+ if long_lines:
765
+ warnings.append(
766
+ f"{long_lines} line(s) exceed GRBL's 80-character limit and may be rejected."
767
+ )
768
+ warnings.extend(toolpath.warnings)
769
+ if toolpath.tools:
770
+ warnings.append(
771
+ "This file has tool changes (M6). They are intercepted and will pause the job."
772
+ )
773
+ if from_line:
774
+ warnings.append(f"Starting from line {from_line}.")
775
+ warnings.append("Verify the spindle is running and the workpiece is secure.")
776
+ return warnings
777
+
778
+ def _toggle_pause(self) -> None:
779
+ streamer = self.controller.streamer
780
+ if streamer is None:
781
+ return
782
+ if streamer.state is StreamerState.PAUSED:
783
+ if streamer.error is not None or streamer.awaiting_tool_change is not None:
784
+ return
785
+ self.controller.resume()
786
+ elif streamer.state is StreamerState.RUNNING:
787
+ self.controller.pause()
788
+
789
+ def _stop(self) -> None:
790
+ self.controller.stop()
791
+ self._status_notice.setText("Stopping…")
792
+ if self._dry_run:
793
+ QTimer.singleShot(400, self._disable_check_mode)
794
+
795
+ def _soft_reset(self) -> None:
796
+ streamer = self.controller.streamer
797
+ active = streamer is not None and streamer.is_active
798
+ if active:
799
+ box = QMessageBox.question(
800
+ self,
801
+ "Soft reset",
802
+ "Reset the controller now? The job will be aborted and position " "may be lost.",
803
+ )
804
+ if box != QMessageBox.StandardButton.Yes:
805
+ return
806
+ self.controller.reset()
807
+ self.editor.mark_all_done()
808
+
809
+ def _home(self) -> None:
810
+ if self.controller.home():
811
+ self._homed = True
812
+ self.console.log("Homing cycle requested")
813
+ self._status_notice.setText("Homing…")
814
+
815
+ def _unlock(self) -> None:
816
+ if self.controller.unlock():
817
+ self._status_notice.setText("Alarm unlock requested")
818
+
819
+ def _zero_axis(self, axis: str) -> None:
820
+ if self.controller.zero_axis(axis, self._wcs):
821
+ if axis.upper() == "Z":
822
+ self._z_zeroed = True
823
+ self._status_notice.setText(f"Work zero set for {axis}")
824
+ self.console.log(f"Zeroed {axis} in G5{self._wcs + 3}")
825
+
826
+ def _change_wcs(self, index: int) -> None:
827
+ self._wcs = index
828
+ self.controller.goto_wcs(index)
829
+ self._status_modal.setText(f"G21 · G90 · G5{index + 3}")
830
+
831
+ def _probe_dialog(self) -> None:
832
+ if not self._connected:
833
+ QMessageBox.information(self, "Not connected", "Connect to the machine first.")
834
+ return
835
+ dialog = ProbeDialog(self)
836
+ if dialog.exec() != QDialog.DialogCode.Accepted:
837
+ return
838
+ thickness, feed, travel, retract = dialog.values()
839
+ if self.controller.probe_z(thickness, feed, travel, retract):
840
+ self._z_zeroed = True
841
+ self._status_notice.setText("Probing Z…")
842
+ self.console.log(
843
+ f"Probing Z (plate {thickness} mm, feed {feed} mm/min, travel {travel} mm)"
844
+ )
845
+ else:
846
+ QMessageBox.warning(self, "Probe", "Could not start the probe cycle.")
847
+
848
+ def _spindle_on(self) -> None:
849
+ if not self._connected:
850
+ return
851
+ rpm, ok = QInputDialog.getDouble(self, "Spindle", "RPM", 1000, 1, 60000, 0)
852
+ if ok:
853
+ self.controller.set_spindle(rpm)
854
+
855
+ # ------------------------------------------------------------------ #
856
+ # Controller events
857
+ # ------------------------------------------------------------------ #
858
+ def _on_line(self, kind: ResponseKind, text: str) -> None:
859
+ self.console.append(kind, text)
860
+
861
+ def _on_status(self, status: MachineStatus) -> None:
862
+ self._status = status
863
+ self.status_panel.update_status(status)
864
+ active = self._stream_state in (StreamerState.RUNNING, StreamerState.PAUSED)
865
+ self.jog_panel.set_machine(status, self._connected, active)
866
+ if status.wpos:
867
+ self.visualizer.set_position(*status.wpos)
868
+ if status.is_idle and self._alarm_open:
869
+ self._alarm_open = False
870
+ pins = f" · pins {status.pins}" if status.pins else ""
871
+ self._status_modal.setText(f"G5{self._wcs + 3} · {status.state}{pins}")
872
+ self._update_actions()
873
+
874
+ def _on_progress(self, sent: int, total: int, line: int) -> None:
875
+ self.job_panel.update_progress(sent, total, line)
876
+ if line:
877
+ self._last_progress_line = line
878
+ self.visualizer.set_progress_line(line)
879
+ now = time.monotonic()
880
+ if now - self._last_editor_update > 0.12:
881
+ self._last_editor_update = now
882
+ self.editor.set_execution_line(line)
883
+
884
+ def _on_stream_state(self, state: StreamerState) -> None:
885
+ self._stream_state = state
886
+ if state is StreamerState.PAUSED:
887
+ self.action_pause.setText("Resume")
888
+ else:
889
+ self.action_pause.setText("Pause")
890
+ active = state in (StreamerState.RUNNING, StreamerState.PAUSED)
891
+ self.jog_panel.set_machine(self._status, self._connected, active)
892
+ self._update_actions()
893
+
894
+ def _on_job_finished(self, reason: str) -> None:
895
+ self._keep_awake(False)
896
+ self.job_panel.finish(reason)
897
+ if reason == "completed":
898
+ self.editor.mark_all_done()
899
+ self.visualizer.set_progress_line(self._last_progress_line, force=True)
900
+ self._status_notice.setText("Job completed")
901
+ elif reason == "stopped":
902
+ self._status_notice.setText("Job stopped")
903
+ elif reason == "alarm":
904
+ self._status_notice.setText("Job halted by alarm")
905
+ elif reason == "disconnected":
906
+ self._status_notice.setText("Job interrupted: connection lost")
907
+ self._stream_state = StreamerState.IDLE
908
+ if self._dry_run:
909
+ QTimer.singleShot(350, self._disable_check_mode)
910
+ self._update_actions()
911
+
912
+ def _disable_check_mode(self) -> None:
913
+ if self._dry_run:
914
+ self.controller.send_line("$C")
915
+ self._dry_run = False
916
+
917
+ def _on_stream_error(self, line: ProgramLine, code: int, detail: str) -> None:
918
+ self.editor.mark_error(line.source_line)
919
+ self.editor.go_to_line(line.source_line)
920
+ self.console.append("error", f"error:{code} on line {line.source_line}: {detail}")
921
+ box = QMessageBox(self)
922
+ box.setIcon(QMessageBox.Icon.Warning)
923
+ box.setWindowTitle("G-code error")
924
+ box.setText(f"error:{code} on line {line.source_line}")
925
+ box.setInformativeText(f"{detail}\n\n{line.text}")
926
+ skip = box.addButton("Skip line", QMessageBox.ButtonRole.AcceptRole)
927
+ abort = box.addButton("Abort job", QMessageBox.ButtonRole.DestructiveRole)
928
+ box.setDefaultButton(abort)
929
+ box.exec()
930
+ if box.clickedButton() is skip:
931
+ self.controller.skip_error()
932
+ else:
933
+ self.controller.stop()
934
+
935
+ def _on_tool_change(self, line: ProgramLine) -> None:
936
+ self._status_notice.setText(f"Tool change at line {line.source_line}")
937
+ dialog = ToolChangeDialog(line.tool_number, self)
938
+ if dialog.exec() == QDialog.DialogCode.Accepted:
939
+ self.controller.complete_tool_change()
940
+ else:
941
+ self.controller.cancel_tool_change()
942
+
943
+ def _on_alarm(self, code: int, detail: str) -> None:
944
+ self._alarm_open = True
945
+ self._z_zeroed = False
946
+ self._status_notice.setText(f"ALARM:{code} {detail}")
947
+ box = QMessageBox(self)
948
+ box.setIcon(QMessageBox.Icon.Critical)
949
+ box.setWindowTitle(f"ALARM:{code}")
950
+ box.setText(detail)
951
+ box.setInformativeText(
952
+ "Motion is locked until the alarm is cleared. Unlock if the position is "
953
+ "still valid, otherwise home the machine."
954
+ )
955
+ unlock = box.addButton("Unlock ($X)", QMessageBox.ButtonRole.ActionRole)
956
+ home = box.addButton("Home ($H)", QMessageBox.ButtonRole.ActionRole)
957
+ box.addButton(QMessageBox.StandardButton.Close)
958
+ box.exec()
959
+ if box.clickedButton() is unlock:
960
+ self.controller.unlock()
961
+ elif box.clickedButton() is home:
962
+ self._home()
963
+
964
+ def _on_command_error(self, code: int, detail: str) -> None:
965
+ self.console.append("error", f"error:{code} {detail}")
966
+ self._status_notice.setText(f"error:{code}: {detail}")
967
+
968
+ def _on_message(self, text: str) -> None:
969
+ if text:
970
+ self.console.log(text)
971
+ self._status_notice.setText(text)
972
+
973
+ def _on_settings(self, entries: list[SettingsEntry]) -> None:
974
+ values = {entry.number: entry for entry in entries}
975
+ limits = []
976
+ for number in (130, 131, 132):
977
+ entry = values.get(number)
978
+ if entry is not None:
979
+ with suppress(ValueError):
980
+ limits.append(float(entry.value))
981
+ if len(limits) == 3:
982
+ new_limits = (limits[0], limits[1], limits[2])
983
+ streamer = self.controller.streamer
984
+ if new_limits != self._limits:
985
+ self._limits = new_limits
986
+ if self._program and not (streamer and streamer.is_active):
987
+ self._refresh_program()
988
+ if self._settings_dialog is not None and self._settings_dialog.isVisible():
989
+ self._settings_dialog.set_settings(entries)
990
+
991
+ def _show_settings(self) -> None:
992
+ if not self._connected:
993
+ QMessageBox.information(self, "Not connected", "Connect to the machine first.")
994
+ return
995
+ if self._settings_dialog is None:
996
+ self._settings_dialog = SettingsDialog(parent=self)
997
+ self._settings_dialog.refreshRequested.connect(self.controller.request_settings)
998
+ self._settings_dialog.writeRequested.connect(self.controller.set_setting)
999
+ self._settings_dialog.show()
1000
+ self._settings_dialog.raise_()
1001
+ self.controller.request_settings()
1002
+
1003
+ def _send_command(self, text: str) -> None:
1004
+ if not self.controller.send_line(text):
1005
+ self.console.log("Command was not sent (not connected or job active).")
1006
+
1007
+ # ------------------------------------------------------------------ #
1008
+ # Theme / persistence
1009
+ # ------------------------------------------------------------------ #
1010
+ def apply_theme(self, dark: bool, persist: bool = True) -> None:
1011
+ self._dark = dark
1012
+ token = tokens(dark)
1013
+ app = QApplication.instance()
1014
+ if isinstance(app, QApplication):
1015
+ app.setStyleSheet(build_stylesheet(token))
1016
+ self.editor.set_token(token)
1017
+ self.console.set_token(token)
1018
+ self.visualizer.set_token(token)
1019
+ self.status_panel.set_token(token)
1020
+ if persist:
1021
+ self.app_settings.set_dark_theme(dark)
1022
+
1023
+ def _restore_window(self) -> None:
1024
+ geometry = self.app_settings.geometry()
1025
+ if geometry is not None:
1026
+ self.restoreGeometry(geometry)
1027
+ state = self.app_settings.window_state()
1028
+ if state is not None:
1029
+ self.restoreState(state)
1030
+
1031
+ def _keep_awake(self, enabled: bool) -> None:
1032
+ if sys.platform != "darwin":
1033
+ return
1034
+ if enabled and self._caffeinate is None:
1035
+ with suppress(OSError):
1036
+ self._caffeinate = subprocess.Popen(
1037
+ ["caffeinate", "-i"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
1038
+ )
1039
+ self._status_notice.setText("Mac kept awake while the job runs")
1040
+ elif not enabled and self._caffeinate is not None:
1041
+ self._caffeinate.terminate()
1042
+ self._caffeinate = None
1043
+
1044
+ def closeEvent(self, event) -> None: # noqa: N802, ANN001
1045
+ if not self._maybe_save():
1046
+ event.ignore()
1047
+ return
1048
+ self.app_settings.save_geometry(self.saveGeometry())
1049
+ self.app_settings.save_window_state(self.saveState())
1050
+ self.app_settings.sync()
1051
+ self._keep_awake(False)
1052
+ self.controller.disconnect()
1053
+ super().closeEvent(event)
1054
+
1055
+ # ------------------------------------------------------------------ #
1056
+ # Enablement
1057
+ # ------------------------------------------------------------------ #
1058
+ def _update_actions(self) -> None:
1059
+ streamer = self.controller.streamer
1060
+ active = streamer is not None and streamer.is_active
1061
+ family = self._status.family if self._connected else "offline"
1062
+ paused = streamer is not None and streamer.state is StreamerState.PAUSED
1063
+ error = streamer is not None and streamer.error is not None
1064
+ tool_change = streamer is not None and streamer.awaiting_tool_change is not None
1065
+
1066
+ self.action_run.setEnabled(
1067
+ self._connected and family == "idle" and not active and bool(self._program)
1068
+ )
1069
+ self.action_pause.setText("Resume" if paused else "Pause")
1070
+ self.action_pause.setEnabled(bool(active and not error and not tool_change))
1071
+ self.action_stop.setEnabled(self._connected and (active or family in ("hold", "alarm")))
1072
+ self.action_home.setEnabled(self._connected and not active and family in ("idle", "alarm"))
1073
+ self.action_unlock.setEnabled(self._connected and family == "alarm")
1074
+ self.action_reset.setEnabled(self._connected)
1075
+ self.console.set_enabled(self._connected)
1076
+ self.status_panel.set_interactive(self._connected, family, bool(active))
1077
+
1078
+ # ------------------------------------------------------------------ #
1079
+ # Misc
1080
+ # ------------------------------------------------------------------ #
1081
+ def _show_manual(self) -> None:
1082
+ if self._manual_dialog is None:
1083
+ self._manual_dialog = ManualDialog(parent=self)
1084
+ self._manual_dialog.show()
1085
+ self._manual_dialog.raise_()
1086
+ self._manual_dialog.activateWindow()
1087
+ self._manual_dialog.browser.setFocus()
1088
+
1089
+ def _show_shortcuts(self) -> None:
1090
+ QMessageBox.information(
1091
+ self,
1092
+ "Keyboard shortcuts",
1093
+ "⌘O Open · ⌘S Save · ⌘F Find · ⌘L Go to line\n"
1094
+ "⌘R Run · ⌘. Stop · ⌘⇧. Soft reset\n"
1095
+ "⌘/ Toggle comment · ⌘K Connect · ⌘0 Fit toolpath\n"
1096
+ "Jog panel: arrows X/Y · PgUp/PgDn Z · [ ] step · Space cancel jog",
1097
+ )
1098
+
1099
+ def _about(self) -> None:
1100
+ QMessageBox.about(
1101
+ self,
1102
+ f"About {APP_NAME}",
1103
+ f"{APP_NAME}\n\nA G-code sender and editor for GRBL 1.1 on "
1104
+ "Arduino Uno.\n\nKeep a hand near the power switch and wear eye "
1105
+ "protection. Software stop is not an emergency stop.",
1106
+ )
1107
+
1108
+ def dragEnterEvent(self, event) -> None: # noqa: N802, ANN001
1109
+ if event.mimeData().hasUrls():
1110
+ event.acceptProposedAction()
1111
+
1112
+ def dropEvent(self, event) -> None: # noqa: N802, ANN001
1113
+ urls = event.mimeData().urls()
1114
+ if urls and urls[0].isLocalFile() and self._maybe_save():
1115
+ self.open_path(urls[0].toLocalFile())
1116
+ event.acceptProposedAction()