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/manual.py ADDED
@@ -0,0 +1,156 @@
1
+ """In-app user manual rendered from USER_MANUAL.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from PySide6.QtCore import QUrl
9
+ from PySide6.QtGui import (
10
+ QColor,
11
+ QDesktopServices,
12
+ QPalette,
13
+ QTextCursor,
14
+ QTextDocument,
15
+ )
16
+ from PySide6.QtWidgets import (
17
+ QDialog,
18
+ QHBoxLayout,
19
+ QLineEdit,
20
+ QPushButton,
21
+ QTextBrowser,
22
+ QVBoxLayout,
23
+ )
24
+
25
+ from .theme import tokens
26
+
27
+ MISSING_MANUAL = """# PlotDeck User Manual
28
+
29
+ The manual file (`USER_MANUAL.md`) could not be found in this installation.
30
+
31
+ From a source checkout, it lives in the project root next to `README.md`.
32
+ """
33
+
34
+
35
+ def manual_path() -> Path | None:
36
+ """Find USER_MANUAL.md in a source checkout or a frozen application."""
37
+ candidates: list[Path] = []
38
+ bundle = getattr(sys, "_MEIPASS", None)
39
+ if bundle:
40
+ candidates.append(Path(bundle) / "USER_MANUAL.md")
41
+ candidates.append(Path(__file__).resolve().parents[3] / "USER_MANUAL.md")
42
+ candidates.append(Path(__file__).resolve().parent / "resources" / "USER_MANUAL.md")
43
+ candidates.append(Path.cwd() / "USER_MANUAL.md")
44
+ for candidate in candidates:
45
+ if candidate.is_file():
46
+ return candidate
47
+ return None
48
+
49
+
50
+ def manual_text() -> str:
51
+ path = manual_path()
52
+ if path is None:
53
+ return MISSING_MANUAL
54
+ try:
55
+ return path.read_text(encoding="utf-8", errors="replace")
56
+ except OSError:
57
+ return MISSING_MANUAL
58
+
59
+
60
+ class ManualDialog(QDialog):
61
+ """Non-modal, searchable viewer for the user manual."""
62
+
63
+ def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
64
+ super().__init__(parent)
65
+ self._token = token or tokens()
66
+ self.setWindowTitle("PlotDeck User Manual")
67
+ self.resize(940, 760)
68
+ self.setMinimumSize(620, 480)
69
+
70
+ self.browser = QTextBrowser()
71
+ self.browser.setOpenExternalLinks(False)
72
+ self.browser.setOpenLinks(False)
73
+ self.browser.anchorClicked.connect(self._open_link)
74
+ self.browser.setMarkdown(manual_text())
75
+ self.browser.setStyleSheet(
76
+ f"QTextBrowser {{ background: {self._token['editor']};"
77
+ f" color: {self._token['text']};"
78
+ f" border: 1px solid {self._token['border']}; border-radius: 6px;"
79
+ " padding: 10px; }"
80
+ )
81
+ palette = self.browser.palette()
82
+ palette.setColor(QPalette.ColorRole.Link, QColor(self._token["accent"]))
83
+ self.browser.setPalette(palette)
84
+ self._search = QLineEdit()
85
+ self._search.setPlaceholderText("Find in manual…")
86
+ self._search.setClearButtonEnabled(True)
87
+ self._search.returnPressed.connect(self._find_next)
88
+ previous = QPushButton("Previous")
89
+ next_button = QPushButton("Next")
90
+ previous.clicked.connect(self._find_previous)
91
+ next_button.clicked.connect(self._find_next)
92
+
93
+ top = QHBoxLayout()
94
+ top.addWidget(self._search, 1)
95
+ top.addWidget(previous)
96
+ top.addWidget(next_button)
97
+
98
+ close = QPushButton("Close")
99
+ close.clicked.connect(self.accept)
100
+ open_external = QPushButton("Open in browser")
101
+ open_external.setToolTip("Open the markdown file in your default Markdown viewer")
102
+ open_external.clicked.connect(self._open_external)
103
+
104
+ bottom = QHBoxLayout()
105
+ bottom.addStretch(1)
106
+ bottom.addWidget(open_external)
107
+ bottom.addWidget(close)
108
+
109
+ layout = QVBoxLayout(self)
110
+ layout.setContentsMargins(12, 12, 12, 10)
111
+ layout.setSpacing(8)
112
+ layout.addLayout(top)
113
+ layout.addWidget(self.browser, 1)
114
+ layout.addLayout(bottom)
115
+
116
+ # ------------------------------------------------------------------ #
117
+ def _find_next(self) -> None:
118
+ text = self._search.text()
119
+ if not text:
120
+ return
121
+ if not self.browser.find(text):
122
+ self.browser.moveCursor(QTextCursor.MoveOperation.Start)
123
+ if not self.browser.find(text):
124
+ self._search.setStyleSheet(f"color: {self._token['alarm']};")
125
+ return
126
+ self._search.setStyleSheet("")
127
+
128
+ def _find_previous(self) -> None:
129
+ text = self._search.text()
130
+ if not text:
131
+ return
132
+ if not self.browser.find(text, QTextDocument.FindFlag.FindBackward):
133
+ cursor = self.browser.textCursor()
134
+ cursor.movePosition(QTextCursor.MoveOperation.End)
135
+ self.browser.setTextCursor(cursor)
136
+ self.browser.find(text, QTextDocument.FindFlag.FindBackward)
137
+
138
+ def _open_link(self, url: QUrl) -> None:
139
+ anchor = url.toString()
140
+ if anchor.startswith("#"):
141
+ self.browser.scrollToAnchor(anchor[1:])
142
+ return
143
+ if url.scheme() in ("http", "https", "mailto"):
144
+ QDesktopServices.openUrl(url)
145
+ return
146
+ # Relative links such as README.md live next to the manual.
147
+ path = manual_path()
148
+ if path is not None:
149
+ target = path.parent / url.toLocalFile() if url.isLocalFile() else path.parent / anchor
150
+ if target.is_file():
151
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
152
+
153
+ def _open_external(self) -> None:
154
+ path = manual_path()
155
+ if path is not None:
156
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
@@ -0,0 +1,244 @@
1
+ """Position readout, state badge and override controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Qt, Signal
6
+ from PySide6.QtWidgets import (
7
+ QComboBox,
8
+ QHBoxLayout,
9
+ QLabel,
10
+ QPushButton,
11
+ QSlider,
12
+ QVBoxLayout,
13
+ QWidget,
14
+ )
15
+
16
+ from ..core.grbl_protocol import state_family
17
+ from ..core.status import MachineStatus
18
+ from .theme import state_color, tokens
19
+
20
+
21
+ class StateBadge(QLabel):
22
+ def __init__(self, token: dict[str, str]) -> None:
23
+ super().__init__("OFFLINE")
24
+ self._token = token
25
+ self._state = "Unknown"
26
+ self._connected = False
27
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
28
+ self.setMinimumWidth(86)
29
+ self.set_state("Unknown", False)
30
+
31
+ def refresh(self) -> None:
32
+ self.set_state(self._state, self._connected)
33
+
34
+ def set_state(self, state: str, connected: bool = True) -> None:
35
+ self._state = state
36
+ self._connected = connected
37
+ family = state_family(state) if connected else "offline"
38
+ color = state_color(self._token, family)
39
+ label = state.split(":")[0].upper() if connected else "OFFLINE"
40
+ if label == "RUN":
41
+ label = "RUN"
42
+ self.setText(label)
43
+ self.setStyleSheet(
44
+ f"background: {color}; color: white; border-radius: 6px;"
45
+ "padding: 4px 10px; font-weight: 700; font-size: 11px;"
46
+ )
47
+ self.setToolTip(state if connected else "Not connected")
48
+
49
+
50
+ class DroRow(QWidget):
51
+ zeroRequested = Signal(str)
52
+
53
+ def __init__(self, axis: str, token: dict[str, str], parent=None) -> None: # noqa: ANN001
54
+ super().__init__(parent)
55
+ self._axis = axis
56
+ self._token = token
57
+ axis_label = QLabel(axis)
58
+ axis_label.setObjectName("DroAxis")
59
+ axis_label.setFixedWidth(18)
60
+ self._value = QLabel("0.000")
61
+ self._value.setObjectName("DroValue")
62
+ self._secondary = QLabel("MPos 0.000")
63
+ self._secondary.setObjectName("DroSecondary")
64
+ zero = QPushButton("0")
65
+ self._zero = zero
66
+ zero.setFixedSize(24, 24)
67
+ zero.setToolTip(f"Zero {axis}")
68
+ zero.clicked.connect(lambda: self.zeroRequested.emit(self._axis))
69
+
70
+ layout = QHBoxLayout(self)
71
+ layout.setContentsMargins(0, 2, 0, 2)
72
+ layout.setSpacing(6)
73
+ layout.addWidget(axis_label)
74
+ layout.addWidget(self._value, 1)
75
+ text_column = QVBoxLayout()
76
+ text_column.setSpacing(0)
77
+ text_column.addWidget(self._secondary)
78
+ layout.addLayout(text_column)
79
+ layout.addWidget(zero)
80
+
81
+ def set_zero_enabled(self, enabled: bool) -> bool:
82
+ self._zero.setEnabled(enabled)
83
+ return enabled
84
+
85
+ def set_values(self, work: float | None, machine: float | None, units: str = "mm") -> None:
86
+ work_text = "---" if work is None else f"{work:.3f}"
87
+ machine_text = "---" if machine is None else f"{machine:.3f}"
88
+ self._value.setText(work_text)
89
+ self._secondary.setText(f"MPos {machine_text} {units}")
90
+
91
+
92
+ class OverrideSlider(QWidget):
93
+ valueChanged = Signal(int)
94
+
95
+ def __init__(self, label: str, minimum: int, maximum: int, token: dict[str, str]) -> None:
96
+ super().__init__()
97
+ self._label = QLabel(label)
98
+ self._label.setObjectName("Muted")
99
+ self._label.setFixedWidth(56)
100
+ self._slider = QSlider(Qt.Orientation.Horizontal)
101
+ self._slider.setRange(minimum, maximum)
102
+ self._slider.setValue(100)
103
+ self._value = QLabel("100%")
104
+ self._value.setFixedWidth(42)
105
+ self._value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
106
+ self._token = token
107
+ layout = QHBoxLayout(self)
108
+ layout.setContentsMargins(0, 1, 0, 1)
109
+ layout.setSpacing(6)
110
+ layout.addWidget(self._label)
111
+ layout.addWidget(self._slider, 1)
112
+ layout.addWidget(self._value)
113
+ self._slider.valueChanged.connect(self._on_change)
114
+ self._slider.sliderReleased.connect(self._emit_value)
115
+
116
+ def _on_change(self, value: int) -> None:
117
+ self._value.setText(f"{value}%")
118
+
119
+ def _emit_value(self) -> None:
120
+ self.valueChanged.emit(self._slider.value())
121
+
122
+ def reset(self) -> None:
123
+ self._slider.setValue(100)
124
+
125
+ def value(self) -> int:
126
+ return self._slider.value()
127
+
128
+
129
+ class StatusPanel(QWidget):
130
+ zeroRequested = Signal(str)
131
+ feedOverrideChanged = Signal(int)
132
+ spindleOverrideChanged = Signal(int)
133
+ rapidOverrideChanged = Signal(int)
134
+
135
+ def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
136
+ super().__init__(parent)
137
+ self._token = token or tokens()
138
+ self.setObjectName("Panel")
139
+
140
+ self.badge = StateBadge(self._token)
141
+ header = QHBoxLayout()
142
+ header.addWidget(self.badge)
143
+ self._firmware = QLabel("not connected")
144
+ self._firmware.setObjectName("Muted")
145
+ header.addWidget(self._firmware, 1, Qt.AlignmentFlag.AlignRight)
146
+
147
+ self.rows = {axis: DroRow(axis, self._token) for axis in ("X", "Y", "Z")}
148
+ for row in self.rows.values():
149
+ row.zeroRequested.connect(self.zeroRequested)
150
+
151
+ self._feed = QLabel("F 0")
152
+ self._feed.setObjectName("Muted")
153
+ self._spindle = QLabel("S 0")
154
+ self._spindle.setObjectName("Muted")
155
+ self._buffer = QLabel("Bf 0/127")
156
+ self._buffer.setObjectName("Muted")
157
+ footer = QHBoxLayout()
158
+ footer.addWidget(self._feed)
159
+ footer.addWidget(self._spindle)
160
+ footer.addStretch(1)
161
+ footer.addWidget(self._buffer)
162
+
163
+ self.feed_override = OverrideSlider("Feed", 10, 200, self._token)
164
+ self.spindle_override = OverrideSlider("Spindle", 10, 200, self._token)
165
+ self._rapid = QComboBox()
166
+ self._rapid.addItems(["100%", "50%", "25%"])
167
+ rapid_row = QHBoxLayout()
168
+ rapid_label = QLabel("Rapid")
169
+ rapid_label.setObjectName("Muted")
170
+ rapid_label.setFixedWidth(56)
171
+ rapid_row.addWidget(rapid_label)
172
+ rapid_row.addWidget(self._rapid, 1)
173
+
174
+ overrides_header = QLabel("OVERRIDES")
175
+ overrides_header.setObjectName("SectionHeader")
176
+
177
+ self.feed_override.valueChanged.connect(self.feedOverrideChanged)
178
+ self.spindle_override.valueChanged.connect(self.spindleOverrideChanged)
179
+ self._rapid.currentIndexChanged.connect(self._rapid_changed)
180
+
181
+ layout = QVBoxLayout(self)
182
+ layout.setContentsMargins(10, 8, 10, 8)
183
+ layout.setSpacing(4)
184
+ layout.addLayout(header)
185
+ position_header = QLabel("POSITION")
186
+ position_header.setObjectName("SectionHeader")
187
+ layout.addSpacing(4)
188
+ layout.addWidget(position_header)
189
+ for row in self.rows.values():
190
+ layout.addWidget(row)
191
+ layout.addLayout(footer)
192
+ layout.addSpacing(8)
193
+ layout.addWidget(overrides_header)
194
+ layout.addWidget(self.feed_override)
195
+ layout.addWidget(self.spindle_override)
196
+ layout.addLayout(rapid_row)
197
+ layout.addStretch(1)
198
+
199
+ def set_token(self, token: dict[str, str]) -> None:
200
+ self._token = token
201
+ self.badge._token = token
202
+ self.badge.refresh()
203
+
204
+ def set_interactive(self, connected: bool, family: str, job_active: bool) -> None:
205
+ overrides = connected and family in ("idle", "run", "hold")
206
+ self.feed_override.setEnabled(overrides)
207
+ self.spindle_override.setEnabled(overrides)
208
+ self._rapid.setEnabled(overrides)
209
+ zero = connected and family == "idle" and not job_active
210
+ for row in self.rows.values():
211
+ row.set_zero_enabled(zero)
212
+
213
+ def _rapid_changed(self, index: int) -> None:
214
+ self.rapidOverrideChanged.emit((100, 50, 25)[index])
215
+
216
+ def update_status(self, status: MachineStatus, units: str = "mm") -> None:
217
+ self.badge.set_state(status.state, True)
218
+ for index, axis in enumerate(("X", "Y", "Z")):
219
+ work = status.wpos[index] if status.wpos else None
220
+ machine = status.mpos[index] if status.mpos else None
221
+ self.rows[axis].set_values(work, machine, units)
222
+ self._feed.setText(f"F {status.feed:.0f}" if status.feed is not None else "F --")
223
+ self._spindle.setText(f"S {status.spindle:.0f}" if status.spindle is not None else "S --")
224
+ if status.buffer:
225
+ self._buffer.setText(f"Bf {status.buffer[0]}/{status.buffer[1]}")
226
+ self._buffer.setToolTip(
227
+ f"RX buffer {status.buffer[0]} of {status.buffer[1]} bytes used"
228
+ )
229
+ else:
230
+ self._buffer.setText("Bf --")
231
+ if status.pins:
232
+ self._buffer.setToolTip(f"RX buffer | triggered pins: {status.pins}")
233
+
234
+ def set_connected(self, connected: bool, firmware: str = "") -> None:
235
+ if not connected:
236
+ self.badge.set_state("Unknown", False)
237
+ self._firmware.setText("not connected")
238
+ for row in self.rows.values():
239
+ row.set_values(None, None)
240
+ self._feed.setText("F --")
241
+ self._spindle.setText("S --")
242
+ self._buffer.setText("Bf --")
243
+ else:
244
+ self._firmware.setText(f"GRBL {firmware}" if firmware else "GRBL")