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,375 @@
1
+ """Serial transport layer.
2
+
3
+ The transport is swappable: :class:`SerialTransport` talks to a real port,
4
+ while :class:`MockTransport` provides a self-contained demo machine. Both
5
+ deliver complete lines through plain callbacks, which keeps this module free
6
+ of Qt imports.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from abc import ABC, abstractmethod
14
+ from collections.abc import Callable
15
+ from contextlib import suppress
16
+ from typing import Any
17
+
18
+ from .grbl_protocol import BAUD_RATE, UNO_RESET_DELAY_S
19
+
20
+
21
+ class TransportError(RuntimeError):
22
+ pass
23
+
24
+
25
+ class Transport(ABC):
26
+ """Common interface for anything that can exchange lines with GRBL."""
27
+
28
+ on_line: Callable[[str], None] | None = None
29
+ on_error: Callable[[str], None] | None = None
30
+ on_closed: Callable[[str], None] | None = None
31
+ on_open: Callable[[], None] | None = None
32
+
33
+ @property
34
+ @abstractmethod
35
+ def is_open(self) -> bool: ...
36
+
37
+ @abstractmethod
38
+ def open(self) -> None: ...
39
+
40
+ @abstractmethod
41
+ def close(self) -> None: ...
42
+
43
+ @abstractmethod
44
+ def write(self, data: bytes) -> None: ...
45
+
46
+ # Convenience helpers ---------------------------------------------------
47
+ def send_line(self, line: str) -> None:
48
+ self.write(line.encode("utf-8") + b"\n")
49
+
50
+ def send_realtime(self, byte: bytes) -> None:
51
+ self.write(byte)
52
+
53
+ def _emit_line(self, line: str) -> None:
54
+ callback = self.on_line
55
+ if callback is not None:
56
+ # Never let a consumer exception kill the reader thread.
57
+ with suppress(Exception):
58
+ callback(line)
59
+
60
+ def _emit_error(self, message: str) -> None:
61
+ callback = self.on_error
62
+ if callback is not None:
63
+ with suppress(Exception):
64
+ callback(message)
65
+
66
+ def _emit_closed(self, reason: str) -> None:
67
+ callback = self.on_closed
68
+ if callback is not None:
69
+ with suppress(Exception):
70
+ callback(reason)
71
+
72
+ def _emit_open(self) -> None:
73
+ callback = self.on_open
74
+ if callback is not None:
75
+ with suppress(Exception):
76
+ callback()
77
+
78
+
79
+ class SerialTransport(Transport):
80
+ """pyserial transport on a background thread.
81
+
82
+ The Uno resets when the port opens, so the reader waits
83
+ :data:`UNO_RESET_DELAY_S` before flushing and asking for a status report.
84
+ The GRBL welcome banner then tells the controller the link is really up.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ port: str,
90
+ baud: int = BAUD_RATE,
91
+ reset_delay: float = UNO_RESET_DELAY_S,
92
+ ) -> None:
93
+ self.port = port
94
+ self.baud = baud
95
+ self.reset_delay = reset_delay
96
+ self._serial: Any = None
97
+ self._thread: threading.Thread | None = None
98
+ self._stop = threading.Event()
99
+ self._write_lock = threading.Lock()
100
+ self._open = False
101
+
102
+ @property
103
+ def is_open(self) -> bool:
104
+ return self._open
105
+
106
+ def open(self) -> None:
107
+ if self._thread is not None and self._thread.is_alive():
108
+ return
109
+ self._stop.clear()
110
+ self._thread = threading.Thread(target=self._run, name=f"serial-{self.port}", daemon=True)
111
+ self._thread.start()
112
+
113
+ def _run(self) -> None:
114
+ import serial
115
+
116
+ try:
117
+ self._serial = serial.Serial(self.port, self.baud, timeout=0.05)
118
+ except Exception as exc: # serial.SerialException and OSError
119
+ self._open = False
120
+ self._emit_error(f"Could not open {self.port}: {exc}")
121
+ self._emit_closed(str(exc))
122
+ return
123
+
124
+ self._open = True
125
+ try:
126
+ time.sleep(self.reset_delay)
127
+ if self._stop.is_set():
128
+ return
129
+ with self._write_lock:
130
+ self._serial.write(b"\r\n\r\n")
131
+ self._serial.flush()
132
+ # Start status polling only after the Uno has rebooted, so the
133
+ # first '?' does not land in the bootloader.
134
+ self._emit_open()
135
+ except Exception as exc: # noqa: BLE001
136
+ self._emit_error(str(exc))
137
+
138
+ while not self._stop.is_set():
139
+ try:
140
+ data = self._serial.readline()
141
+ except Exception as exc: # unplugged or closed
142
+ if not self._stop.is_set():
143
+ self._emit_error(f"Serial connection lost: {exc}")
144
+ self._emit_closed(str(exc))
145
+ break
146
+ if not data:
147
+ continue
148
+ decoded = data.decode("utf-8", errors="replace").strip("\r\n ")
149
+ if decoded:
150
+ self._emit_line(decoded)
151
+
152
+ def close(self) -> None:
153
+ self._stop.set()
154
+ thread = self._thread
155
+ if thread is not None and thread is not threading.current_thread():
156
+ thread.join(timeout=1.5)
157
+ self._thread = None
158
+ self._open = False
159
+ serial_port = self._serial
160
+ self._serial = None
161
+ if serial_port is not None:
162
+ with suppress(Exception):
163
+ serial_port.close()
164
+
165
+ def write(self, data: bytes) -> None:
166
+ serial_port = self._serial
167
+ if not self._open or serial_port is None:
168
+ raise TransportError("Serial port is not open.")
169
+ with self._write_lock:
170
+ try:
171
+ serial_port.write(data)
172
+ serial_port.flush()
173
+ except Exception as exc: # noqa: BLE001
174
+ self._emit_error(f"Write failed: {exc}")
175
+ raise TransportError(str(exc)) from exc
176
+
177
+
178
+ class MockTransport(Transport):
179
+ """A tiny GRBL stand-in so the UI can be explored without hardware.
180
+
181
+ It implements the same character-counting contract as the real firmware:
182
+ lines are queued and acknowledged one at a time, status reports work, and
183
+ ``!``/``~`` change the state.
184
+ """
185
+
186
+ def __init__(self, line_delay: float = 0.004) -> None:
187
+ self._delay = line_delay
188
+ self._open = False
189
+ self._state = "Idle"
190
+ self._pos = [0.0, 0.0, 0.0]
191
+ self._feed = 0.0
192
+ self._spindle = 0.0
193
+ self._settings = {
194
+ 0: "10",
195
+ 1: "25",
196
+ 2: "0",
197
+ 3: "0",
198
+ 4: "0",
199
+ 5: "0",
200
+ 6: "0",
201
+ 10: "3",
202
+ 11: "0.010",
203
+ 12: "0.002",
204
+ 13: "0",
205
+ 20: "0",
206
+ 21: "0",
207
+ 22: "1",
208
+ 23: "0",
209
+ 24: "25.000",
210
+ 25: "500.000",
211
+ 26: "250",
212
+ 27: "1.000",
213
+ 30: "1000",
214
+ 31: "0",
215
+ 32: "0",
216
+ 100: "250.000",
217
+ 101: "250.000",
218
+ 102: "250.000",
219
+ 110: "500.000",
220
+ 111: "500.000",
221
+ 112: "500.000",
222
+ 120: "10.000",
223
+ 121: "10.000",
224
+ 122: "10.000",
225
+ 130: "200.000",
226
+ 131: "200.000",
227
+ 132: "200.000",
228
+ }
229
+ self._thread: threading.Thread | None = None
230
+ self._worker: threading.Thread | None = None
231
+ self._stop = threading.Event()
232
+ self._lock = threading.Lock()
233
+ self._pending: list[int] = []
234
+ self._lines: list[str] = []
235
+
236
+ @property
237
+ def is_open(self) -> bool:
238
+ return self._open
239
+
240
+ def open(self) -> None:
241
+ self._open = True
242
+ self._stop.clear()
243
+ self._thread = threading.Thread(target=self._run, name="mock-grbl", daemon=True)
244
+ self._thread.start()
245
+ self._worker = threading.Thread(
246
+ target=self._process_lines, name="mock-grbl-lines", daemon=True
247
+ )
248
+ self._worker.start()
249
+
250
+ def _run(self) -> None:
251
+ time.sleep(0.2)
252
+ self._emit_open()
253
+ self._emit_line("Grbl 1.1h ['$' for help]")
254
+ while not self._stop.is_set():
255
+ time.sleep(0.05)
256
+
257
+ def _process_lines(self) -> None:
258
+ # Line commands are acknowledged asynchronously, like real firmware.
259
+ while not self._stop.is_set():
260
+ with self._lock:
261
+ line = self._lines.pop(0) if self._lines else None
262
+ if line is None:
263
+ time.sleep(0.002)
264
+ continue
265
+ self._dispatch(line)
266
+ time.sleep(self._delay)
267
+
268
+ def close(self) -> None:
269
+ self._stop.set()
270
+ thread = self._thread
271
+ if thread is not None and thread is not threading.current_thread():
272
+ thread.join(timeout=1.0)
273
+ worker = self._worker
274
+ if worker is not None and worker is not threading.current_thread():
275
+ worker.join(timeout=1.0)
276
+ self._thread = None
277
+ self._worker = None
278
+ if self._open:
279
+ self._open = False
280
+ self._emit_closed("Disconnected")
281
+
282
+ def write(self, data: bytes) -> None:
283
+ if not self._open:
284
+ raise TransportError("Mock transport is not open.")
285
+ with self._lock:
286
+ for byte in data:
287
+ self._handle_byte(byte)
288
+
289
+ def _handle_byte(self, byte: int) -> None:
290
+ if byte == 0x3F: # '?'
291
+ self._emit_status()
292
+ elif byte == 0x21: # '!'
293
+ self._state = "Hold:0"
294
+ elif byte == 0x7E: # '~'
295
+ self._state = "Idle"
296
+ elif byte == 0x18: # soft reset
297
+ self._state = "Idle"
298
+ self._feed = 0.0
299
+ self._pending.clear()
300
+ self._lines.clear()
301
+ threading.Timer(0.05, lambda: self._emit_line("Grbl 1.1h ['$' for help]")).start()
302
+ elif byte == 0x85:
303
+ self._state = "Idle"
304
+ elif byte in (0x0A, 0x0D):
305
+ line = bytes(self._pending).decode("utf-8", errors="replace").strip()
306
+ self._pending.clear()
307
+ if line:
308
+ self._lines.append(line)
309
+ else:
310
+ self._pending.append(byte)
311
+
312
+ def _emit_status(self) -> None:
313
+ pos = "MPos:{:.3f},{:.3f},{:.3f}".format(*self._pos)
314
+ self._emit_line(
315
+ f"<{self._state}|{pos}|FS:{self._feed:.0f},{self._spindle:.0f}"
316
+ f"|Bf:0,127|Ov:100,100,100>"
317
+ )
318
+
319
+ # A line is dispatched when its newline arrives; handled in write().
320
+ def _dispatch(self, line: str) -> None:
321
+ text = line.strip()
322
+ if not text:
323
+ return
324
+ if text == "?":
325
+ self._emit_status()
326
+ return
327
+ if text == "$$":
328
+ for key in sorted(self._settings):
329
+ self._emit_line(f"${key}={self._settings[key]}")
330
+ self._emit_line("ok")
331
+ return
332
+ if text == "$I":
333
+ self._emit_line("[VER:1.1h.20190825:]")
334
+ self._emit_line("[OPT:V,15,128]")
335
+ self._emit_line("ok")
336
+ return
337
+ if text.startswith("$J="):
338
+ self._emit_line("ok")
339
+ return
340
+ if text == "$H":
341
+ self._state = "Home"
342
+ self._pos = [0.0, 0.0, 0.0]
343
+ self._state = "Idle"
344
+ self._emit_line("ok")
345
+ return
346
+ if text in ("$X", "$C", "$G", "$#"):
347
+ self._emit_line("ok")
348
+ return
349
+ if text.startswith("G0") or text.startswith("G1"):
350
+ import re
351
+
352
+ for axis, value in re.findall(r"([XYZ])([-+]?\d*\.?\d+)", text):
353
+ index = "XYZ".index(axis)
354
+ self._pos[index] = float(value)
355
+ self._emit_line("ok")
356
+ return
357
+ if text.startswith("M3") or text.startswith("M5"):
358
+ self._emit_line("ok")
359
+ return
360
+ self._emit_line("ok")
361
+
362
+
363
+ def list_serial_ports() -> list[tuple[str, str]]:
364
+ """Return USB-looking serial ports as ``(device, description)``."""
365
+ try:
366
+ from serial.tools import list_ports
367
+ except ImportError:
368
+ return []
369
+ ports: list[tuple[str, str]] = []
370
+ for info in list_ports.comports():
371
+ device = info.device
372
+ if any(token in device for token in ("usbmodem", "usbserial", "wchusbserial")):
373
+ ports.append((device, info.description or device))
374
+ ports.sort()
375
+ return ports
@@ -0,0 +1 @@
1
+ """PySide6 user interface for PlotDeck."""
@@ -0,0 +1,139 @@
1
+ """Connection controls shown in the main toolbar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Signal
6
+ from PySide6.QtWidgets import (
7
+ QComboBox,
8
+ QHBoxLayout,
9
+ QLabel,
10
+ QPushButton,
11
+ QToolButton,
12
+ QWidget,
13
+ )
14
+
15
+ from ..core.controller import MachineController
16
+
17
+
18
+ class ConnectionPanel(QWidget):
19
+ connectRequested = Signal(str, int, bool)
20
+ disconnectRequested = Signal()
21
+
22
+ def __init__(self, parent=None) -> None: # noqa: ANN001
23
+ super().__init__(parent)
24
+ self.setObjectName("Panel")
25
+ self._connected = False
26
+ self._connecting = False
27
+
28
+ self._port = QComboBox()
29
+ self._port.setMinimumWidth(200)
30
+ self._port.setToolTip("Serial port")
31
+ refresh = QToolButton()
32
+ refresh.setText("⟳")
33
+ refresh.setToolTip("Refresh ports")
34
+ refresh.clicked.connect(self.refresh_ports)
35
+
36
+ self._baud = QComboBox()
37
+ self._baud.setEditable(True)
38
+ for baud in ("9600", "57600", "115200", "230400", "250000"):
39
+ self._baud.addItem(baud)
40
+ self._baud.setCurrentText("115200")
41
+ self._baud.setFixedWidth(90)
42
+ self._baud.setToolTip("Baud rate")
43
+
44
+ self._connect = QPushButton("Connect")
45
+ self._connect.setObjectName("Primary")
46
+ self._connect.setMinimumWidth(96)
47
+ self._connect.clicked.connect(self.toggle)
48
+
49
+ demo = QToolButton()
50
+ demo.setText("Demo")
51
+ demo.setToolTip("Connect to a simulated GRBL machine (no hardware)")
52
+ demo.clicked.connect(self._demo)
53
+
54
+ self._status = QLabel("")
55
+ self._status.setObjectName("Muted")
56
+
57
+ layout = QHBoxLayout(self)
58
+ layout.setContentsMargins(0, 0, 0, 0)
59
+ layout.setSpacing(5)
60
+ layout.addWidget(QLabel("Port"))
61
+ layout.addWidget(self._port)
62
+ layout.addWidget(refresh)
63
+ layout.addWidget(QLabel("Baud"))
64
+ layout.addWidget(self._baud)
65
+ layout.addWidget(self._connect)
66
+ layout.addWidget(demo)
67
+ layout.addWidget(self._status)
68
+
69
+ self.refresh_ports()
70
+
71
+ def refresh_ports(self) -> None:
72
+ current = self._port.currentData()
73
+ self._port.clear()
74
+ ports = MachineController.available_ports()
75
+ for device, description in ports:
76
+ label = device if description == device else f"{device} · {description}"
77
+ self._port.addItem(label, device)
78
+ if not ports:
79
+ self._port.addItem("No USB serial ports found", "")
80
+ if current:
81
+ index = self._port.findData(current)
82
+ if index >= 0:
83
+ self._port.setCurrentIndex(index)
84
+
85
+ def select_port(self, port: str) -> None:
86
+ if not port:
87
+ return
88
+ index = self._port.findData(port)
89
+ if index >= 0:
90
+ self._port.setCurrentIndex(index)
91
+ elif port.upper() != "DEMO":
92
+ self._port.insertItem(0, port, port)
93
+ self._port.setCurrentIndex(0)
94
+
95
+ def baud(self) -> int:
96
+ try:
97
+ return int(self._baud.currentText())
98
+ except ValueError:
99
+ return 115200
100
+
101
+ def set_baud(self, baud: int) -> None:
102
+ self._baud.setCurrentText(str(baud))
103
+
104
+ def toggle(self) -> None:
105
+ if self._connected:
106
+ self._connecting = True
107
+ self._connect.setEnabled(False)
108
+ self._connect.setText("Disconnecting…")
109
+ self.disconnectRequested.emit()
110
+ return
111
+ port = self._port.currentData()
112
+ if not port:
113
+ self._status.setText("No port selected")
114
+ return
115
+ self._connecting = True
116
+ self._connect.setEnabled(False)
117
+ self._connect.setText("Connecting…")
118
+ self.connectRequested.emit(port, self.baud(), False)
119
+
120
+ def _demo(self) -> None:
121
+ self._connecting = True
122
+ self._connect.setEnabled(False)
123
+ self._connect.setText("Connecting…")
124
+ self.connectRequested.emit("DEMO", self.baud(), True)
125
+
126
+ def set_connected(self, connected: bool, port: str = "") -> None:
127
+ self._connected = connected
128
+ self._connecting = False
129
+ self._connect.setEnabled(True)
130
+ self._connect.setText("Disconnect" if connected else "Connect")
131
+ self._status.setText(port if connected else "")
132
+ self._port.setEnabled(not connected)
133
+ self._baud.setEnabled(not connected)
134
+
135
+ def set_error(self, message: str) -> None:
136
+ self._connecting = False
137
+ self._connect.setEnabled(True)
138
+ self._connect.setText("Connect")
139
+ self._status.setText(message)
plotdeck/ui/console.py ADDED
@@ -0,0 +1,171 @@
1
+ """Serial console with colour-coded traffic and command history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Qt, Signal
6
+ from PySide6.QtGui import QColor, QKeyEvent, QTextCharFormat, QTextCursor
7
+ from PySide6.QtWidgets import (
8
+ QCheckBox,
9
+ QHBoxLayout,
10
+ QLabel,
11
+ QLineEdit,
12
+ QPlainTextEdit,
13
+ QPushButton,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+ from ..core.grbl_protocol import ResponseKind
19
+ from .theme import MONO_FONT, tokens
20
+
21
+
22
+ class ConsoleInput(QLineEdit):
23
+ submitted = Signal(str)
24
+
25
+ def __init__(self, parent=None) -> None: # noqa: ANN001
26
+ super().__init__(parent)
27
+ self._history: list[str] = []
28
+ self._index = 0
29
+ self.setPlaceholderText("Send a command (↑/↓ for history)")
30
+ self.setClearButtonEnabled(True)
31
+ self.returnPressed.connect(self._submit)
32
+
33
+ def _submit(self) -> None:
34
+ text = self.text().strip()
35
+ if not text:
36
+ return
37
+ self._history.append(text)
38
+ self._history = self._history[-200:]
39
+ self._index = len(self._history)
40
+ self.submitted.emit(text)
41
+ self.clear()
42
+
43
+ def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
44
+ if event.key() == Qt.Key.Key_Up and self._history:
45
+ self._index = max(0, self._index - 1)
46
+ self.setText(self._history[self._index])
47
+ return
48
+ if event.key() == Qt.Key.Key_Down and self._history:
49
+ self._index = min(len(self._history), self._index + 1)
50
+ self.setText("" if self._index == len(self._history) else self._history[self._index])
51
+ return
52
+ super().keyPressEvent(event)
53
+
54
+
55
+ class Console(QWidget):
56
+ commandSubmitted = Signal(str)
57
+
58
+ def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
59
+ super().__init__(parent)
60
+ self._token = token or tokens()
61
+ self._hide_status = True
62
+ self.setObjectName("Panel")
63
+
64
+ self._view = QPlainTextEdit()
65
+ self._view.setReadOnly(True)
66
+ self._view.setMaximumBlockCount(5000)
67
+ self._view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
68
+ font = self._view.font()
69
+ font.setFamily(MONO_FONT.split(",")[0].replace('"', "").strip())
70
+ font.setPointSize(12)
71
+ self._view.setFont(font)
72
+
73
+ self._input = ConsoleInput()
74
+ self._input.submitted.connect(self._on_submit)
75
+ self._autoscroll = QCheckBox("Autoscroll")
76
+ self._autoscroll.setChecked(True)
77
+ self._hide_polls = QCheckBox("Hide status polls")
78
+ self._hide_polls.setChecked(True)
79
+ self._hide_polls.toggled.connect(self._set_hide_status)
80
+ clear = QPushButton("Clear")
81
+ clear.clicked.connect(self._view.clear)
82
+ self._count_label = QLabel("")
83
+ self._count_label.setObjectName("Muted")
84
+
85
+ controls = QHBoxLayout()
86
+ controls.setContentsMargins(0, 0, 0, 0)
87
+ controls.addWidget(self._autoscroll)
88
+ controls.addWidget(self._hide_polls)
89
+ controls.addWidget(self._count_label, 1)
90
+ controls.addWidget(clear)
91
+
92
+ layout = QVBoxLayout(self)
93
+ layout.setContentsMargins(0, 0, 0, 0)
94
+ layout.setSpacing(4)
95
+ layout.addLayout(controls)
96
+ layout.addWidget(self._view, 1)
97
+ layout.addWidget(self._input)
98
+
99
+ self._formats = {
100
+ "sent": self._format(self._token["accent"]),
101
+ "ok": self._format(self._token["idle"]),
102
+ "error": self._format(self._token["alarm"], bold=True),
103
+ "alarm": self._format(self._token["alarm"], bold=True),
104
+ "message": self._format(self._token["muted"]),
105
+ "status": self._format(self._token["muted"]),
106
+ "other": self._format(self._token["text"]),
107
+ "banner": self._format(self._token["hold"], bold=True),
108
+ }
109
+ self._line_count = 0
110
+
111
+ @staticmethod
112
+ def _format(color: str, bold: bool = False) -> QTextCharFormat:
113
+ style = QTextCharFormat()
114
+ style.setForeground(QColor(color))
115
+ if bold:
116
+ style.setFontWeight(700)
117
+ return style
118
+
119
+ def set_token(self, token: dict[str, str]) -> None:
120
+ self._token = token
121
+ self._formats.update(
122
+ {
123
+ "sent": self._format(token["accent"]),
124
+ "ok": self._format(token["idle"]),
125
+ "error": self._format(token["alarm"], bold=True),
126
+ "alarm": self._format(token["alarm"], bold=True),
127
+ "message": self._format(token["muted"]),
128
+ "status": self._format(token["muted"]),
129
+ "other": self._format(token["text"]),
130
+ "banner": self._format(token["hold"], bold=True),
131
+ }
132
+ )
133
+
134
+ def _set_hide_status(self, value: bool) -> None:
135
+ self._hide_status = value
136
+
137
+ def append(self, kind: ResponseKind | str, text: str) -> None:
138
+ key = kind.value if isinstance(kind, ResponseKind) else kind
139
+ if key == "status" and self._hide_status:
140
+ return
141
+ if key == "other" and not text.strip():
142
+ return
143
+ if key == "banner":
144
+ text = text or "(reset)"
145
+ prefix = ">" if key == "sent" else "<"
146
+ self._insert(f"{prefix} {text}", key)
147
+ self._line_count += 1
148
+ if self._line_count % 5 == 0:
149
+ self._count_label.setText(f"{self._line_count} lines")
150
+
151
+ def _insert(self, text: str, key: str) -> None:
152
+ bar = self._view.verticalScrollBar()
153
+ at_bottom = bar.value() >= bar.maximum() - 4
154
+ cursor = self._view.textCursor()
155
+ cursor.movePosition(QTextCursor.MoveOperation.End)
156
+ cursor.insertText(text + "\n", self._formats.get(key, self._formats["other"]))
157
+ if self._autoscroll.isChecked() or at_bottom:
158
+ bar.setValue(bar.maximum())
159
+
160
+ def _on_submit(self, text: str) -> None:
161
+ self.append("sent", text)
162
+ self.commandSubmitted.emit(text)
163
+
164
+ def set_enabled(self, enabled: bool) -> None:
165
+ self._input.setEnabled(enabled)
166
+
167
+ def focus_input(self) -> None:
168
+ self._input.setFocus()
169
+
170
+ def log(self, text: str) -> None:
171
+ self.append("message", text)