graphical-ssh 0.1.1__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.
- graphical_ssh-0.1.1.dist-info/METADATA +151 -0
- graphical_ssh-0.1.1.dist-info/RECORD +18 -0
- graphical_ssh-0.1.1.dist-info/WHEEL +4 -0
- graphical_ssh-0.1.1.dist-info/entry_points.txt +3 -0
- graphical_ssh-0.1.1.dist-info/licenses/LICENSE +21 -0
- gssh/__init__.py +3 -0
- gssh/__main__.py +43 -0
- gssh/config.py +245 -0
- gssh/connect.py +156 -0
- gssh/data/__init__.py +0 -0
- gssh/data/gssh.desktop +10 -0
- gssh/data/gssh.svg +26 -0
- gssh/host_editor.py +174 -0
- gssh/linter.py +402 -0
- gssh/main_window.py +384 -0
- gssh/models.py +91 -0
- gssh/py.typed +0 -0
- gssh/terminal.py +490 -0
gssh/terminal.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""In-app terminal widget using pyte + pty for a real PTY experience.
|
|
2
|
+
|
|
3
|
+
Spawns an SSH process in a pseudo-terminal, feeds output through pyte
|
|
4
|
+
for VT220 emulation, and renders the screen to a QWidget with scrollback
|
|
5
|
+
history, colour support, and mouse-wheel scrolling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
import os
|
|
12
|
+
import pty
|
|
13
|
+
import signal
|
|
14
|
+
import struct
|
|
15
|
+
import termios
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import pyte
|
|
19
|
+
from PyQt6.QtCore import QSocketNotifier, Qt, pyqtSignal
|
|
20
|
+
from PyQt6.QtGui import (
|
|
21
|
+
QColor,
|
|
22
|
+
QFont,
|
|
23
|
+
QFontMetrics,
|
|
24
|
+
QKeyEvent,
|
|
25
|
+
QPainter,
|
|
26
|
+
QPaintEvent,
|
|
27
|
+
QPen,
|
|
28
|
+
)
|
|
29
|
+
from PyQt6.QtWidgets import (
|
|
30
|
+
QAbstractScrollArea,
|
|
31
|
+
QWidget,
|
|
32
|
+
)
|
|
33
|
+
from pyte.screens import Char
|
|
34
|
+
|
|
35
|
+
# pyte colour name → QColor mapping (xterm-256colour subset)
|
|
36
|
+
_DEFAULT_FG = QColor(220, 220, 220)
|
|
37
|
+
_DEFAULT_BG = QColor(30, 30, 30)
|
|
38
|
+
|
|
39
|
+
_COLOUR_MAP: dict[str, QColor] = {
|
|
40
|
+
"black": QColor(0, 0, 0),
|
|
41
|
+
"red": QColor(205, 0, 0),
|
|
42
|
+
"green": QColor(0, 205, 0),
|
|
43
|
+
"yellow": QColor(205, 205, 0),
|
|
44
|
+
"blue": QColor(0, 0, 238),
|
|
45
|
+
"magenta": QColor(205, 0, 205),
|
|
46
|
+
"cyan": QColor(0, 205, 205),
|
|
47
|
+
"white": QColor(229, 229, 229),
|
|
48
|
+
"brightblack": QColor(127, 127, 127),
|
|
49
|
+
"brightred": QColor(255, 0, 0),
|
|
50
|
+
"brightgreen": QColor(0, 255, 0),
|
|
51
|
+
"brightyellow": QColor(255, 255, 0),
|
|
52
|
+
"brightblue": QColor(92, 92, 255),
|
|
53
|
+
"brightmagenta": QColor(255, 0, 255),
|
|
54
|
+
"brightcyan": QColor(0, 255, 255),
|
|
55
|
+
"brightwhite": QColor(255, 255, 255),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _char_colour(name: str, default: QColor) -> QColor:
|
|
60
|
+
if name == "default":
|
|
61
|
+
return default
|
|
62
|
+
if name in _COLOUR_MAP:
|
|
63
|
+
return _COLOUR_MAP[name]
|
|
64
|
+
# pyte may pass numeric indices as strings for 256-colour mode
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TerminalWidget(QAbstractScrollArea):
|
|
69
|
+
"""A scrollable terminal emulator widget backed by pyte with history."""
|
|
70
|
+
|
|
71
|
+
closed = pyqtSignal(int) # exit code
|
|
72
|
+
|
|
73
|
+
SCROLLBACK = 5000 # lines of history
|
|
74
|
+
|
|
75
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
76
|
+
super().__init__(parent)
|
|
77
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
78
|
+
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
79
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
80
|
+
self.setMouseTracking(True)
|
|
81
|
+
|
|
82
|
+
self._font = QFont("Monospace", 12)
|
|
83
|
+
self._font.setStyleHint(QFont.StyleHint.Monospace)
|
|
84
|
+
self._fm = QFontMetrics(self._font)
|
|
85
|
+
|
|
86
|
+
self._cols = 80
|
|
87
|
+
self._rows = 24
|
|
88
|
+
self._screen = pyte.HistoryScreen(self._cols, self._rows, self.SCROLLBACK)
|
|
89
|
+
self._stream = pyte.Stream(self._screen)
|
|
90
|
+
|
|
91
|
+
self._master_fd: int | None = None
|
|
92
|
+
self._pid: int | None = None
|
|
93
|
+
self._notifier: QSocketNotifier | None = None
|
|
94
|
+
|
|
95
|
+
self._scroll_offset = 0 # 0 = bottom (live); >0 = scrolled up N lines
|
|
96
|
+
|
|
97
|
+
# Viewport widget for painting
|
|
98
|
+
self._viewport = _TerminalViewport(self)
|
|
99
|
+
self.setViewport(self._viewport)
|
|
100
|
+
|
|
101
|
+
# Connect scrollbar
|
|
102
|
+
self.verticalScrollBar().valueChanged.connect(self._on_scroll)
|
|
103
|
+
|
|
104
|
+
cw = self._fm.horizontalAdvance("M")
|
|
105
|
+
ch = self._fm.height()
|
|
106
|
+
self.setMinimumSize(cw * 20, ch * 5)
|
|
107
|
+
|
|
108
|
+
# ── properties ──────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def char_width(self) -> int:
|
|
112
|
+
return self._fm.horizontalAdvance("M")
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def char_height(self) -> int:
|
|
116
|
+
return self._fm.height()
|
|
117
|
+
|
|
118
|
+
# ── PTY lifecycle ───────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _set_pty_size(self) -> None:
|
|
121
|
+
if self._master_fd is None:
|
|
122
|
+
return
|
|
123
|
+
winsize = struct.pack(
|
|
124
|
+
"HHHH", self._rows, self._cols, self.char_height, self.char_width
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def start(self, command: list[str]) -> None:
|
|
132
|
+
"""Spawn *command* in a PTY and start reading."""
|
|
133
|
+
if self._pid is not None:
|
|
134
|
+
self.stop()
|
|
135
|
+
|
|
136
|
+
self._set_pty_size()
|
|
137
|
+
|
|
138
|
+
pid, master_fd = pty.fork()
|
|
139
|
+
if pid == 0:
|
|
140
|
+
try:
|
|
141
|
+
env = os.environ.copy()
|
|
142
|
+
env["TERM"] = "xterm-256color"
|
|
143
|
+
os.execvpe(command[0], command, env)
|
|
144
|
+
except Exception:
|
|
145
|
+
os._exit(127)
|
|
146
|
+
else:
|
|
147
|
+
self._master_fd = master_fd
|
|
148
|
+
self._pid = pid
|
|
149
|
+
self._notifier = QSocketNotifier(master_fd, QSocketNotifier.Type.Read, self)
|
|
150
|
+
self._notifier.activated.connect(self._read_pty)
|
|
151
|
+
self._notifier.setEnabled(True)
|
|
152
|
+
|
|
153
|
+
def stop(self) -> None:
|
|
154
|
+
"""Kill the spawned process and stop reading."""
|
|
155
|
+
if self._notifier is not None:
|
|
156
|
+
self._notifier.setEnabled(False)
|
|
157
|
+
self._notifier.deleteLater()
|
|
158
|
+
self._notifier = None
|
|
159
|
+
if self._pid is not None:
|
|
160
|
+
try:
|
|
161
|
+
os.kill(self._pid, signal.SIGTERM)
|
|
162
|
+
except (OSError, ProcessLookupError):
|
|
163
|
+
pass
|
|
164
|
+
# Retry WNOHANG a few times to avoid leaving a zombie
|
|
165
|
+
for _ in range(10):
|
|
166
|
+
try:
|
|
167
|
+
wpid, _ = os.waitpid(self._pid, os.WNOHANG)
|
|
168
|
+
if wpid != 0:
|
|
169
|
+
break
|
|
170
|
+
except (OSError, ChildProcessError):
|
|
171
|
+
break
|
|
172
|
+
time.sleep(0.01)
|
|
173
|
+
else:
|
|
174
|
+
# Last resort: blocking wait
|
|
175
|
+
try:
|
|
176
|
+
os.waitpid(self._pid, 0)
|
|
177
|
+
except (OSError, ChildProcessError):
|
|
178
|
+
pass
|
|
179
|
+
self._pid = None
|
|
180
|
+
if self._master_fd is not None:
|
|
181
|
+
try:
|
|
182
|
+
os.close(self._master_fd)
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
self._master_fd = None
|
|
186
|
+
|
|
187
|
+
# ── PTY read loop ───────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def _read_pty(self) -> None:
|
|
190
|
+
if self._master_fd is None:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
# Disable notifier while reading to prevent recursive signals
|
|
194
|
+
if self._notifier is not None:
|
|
195
|
+
self._notifier.setEnabled(False)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
try:
|
|
199
|
+
data = os.read(self._master_fd, 65536)
|
|
200
|
+
except OSError:
|
|
201
|
+
data = b""
|
|
202
|
+
|
|
203
|
+
if not data:
|
|
204
|
+
# EOF — child exited
|
|
205
|
+
self._handle_exit()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
self._stream.feed(data.decode("utf-8", errors="replace"))
|
|
209
|
+
|
|
210
|
+
self._update_scrollbar()
|
|
211
|
+
self._viewport.update()
|
|
212
|
+
finally:
|
|
213
|
+
# Re-enable notifier for next read (unless fd was closed)
|
|
214
|
+
if self._notifier is not None and self._master_fd is not None:
|
|
215
|
+
self._notifier.setEnabled(True)
|
|
216
|
+
|
|
217
|
+
def _handle_exit(self) -> None:
|
|
218
|
+
"""Clean up after the child process exits."""
|
|
219
|
+
if self._notifier is not None:
|
|
220
|
+
self._notifier.setEnabled(False)
|
|
221
|
+
self._notifier.deleteLater()
|
|
222
|
+
self._notifier = None
|
|
223
|
+
if self._pid is not None:
|
|
224
|
+
# Non-blocking reap first; fall back to blocking only if needed
|
|
225
|
+
try:
|
|
226
|
+
wpid, status = os.waitpid(self._pid, os.WNOHANG)
|
|
227
|
+
if wpid == 0:
|
|
228
|
+
# Child hasn't exited yet — give it a brief grace period
|
|
229
|
+
for _ in range(5):
|
|
230
|
+
time.sleep(0.01)
|
|
231
|
+
wpid, status = os.waitpid(self._pid, os.WNOHANG)
|
|
232
|
+
if wpid != 0:
|
|
233
|
+
break
|
|
234
|
+
else:
|
|
235
|
+
# Last resort: blocking wait (rare, short)
|
|
236
|
+
_, status = os.waitpid(self._pid, 0)
|
|
237
|
+
code = os.waitstatus_to_exitcode(status)
|
|
238
|
+
except (OSError, ChildProcessError):
|
|
239
|
+
code = 0
|
|
240
|
+
self._pid = None
|
|
241
|
+
else:
|
|
242
|
+
code = 0
|
|
243
|
+
if self._master_fd is not None:
|
|
244
|
+
try:
|
|
245
|
+
os.close(self._master_fd)
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
self._master_fd = None
|
|
249
|
+
self.closed.emit(code)
|
|
250
|
+
|
|
251
|
+
# ── scrollbar ───────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def _history_len(self) -> int:
|
|
254
|
+
return len(self._screen.history.top)
|
|
255
|
+
|
|
256
|
+
def _max_scroll_offset(self) -> int:
|
|
257
|
+
return self._history_len()
|
|
258
|
+
|
|
259
|
+
def _update_scrollbar(self) -> None:
|
|
260
|
+
vbar = self.verticalScrollBar()
|
|
261
|
+
hist = self._history_len()
|
|
262
|
+
total = hist + self._rows
|
|
263
|
+
vbar.blockSignals(True)
|
|
264
|
+
vbar.setRange(0, max(0, total - self._rows))
|
|
265
|
+
if self._scroll_offset == 0:
|
|
266
|
+
vbar.setValue(hist) # bottom
|
|
267
|
+
else:
|
|
268
|
+
vbar.setValue(hist - self._scroll_offset)
|
|
269
|
+
vbar.blockSignals(False)
|
|
270
|
+
|
|
271
|
+
def _on_scroll(self, value: int) -> None:
|
|
272
|
+
hist = self._history_len()
|
|
273
|
+
self._scroll_offset = max(0, hist - value)
|
|
274
|
+
self._viewport.update()
|
|
275
|
+
|
|
276
|
+
# ── keyboard input ──────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
279
|
+
if self._master_fd is None:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
# If scrolled up, any key sends us back to the live bottom
|
|
283
|
+
if self._scroll_offset > 0:
|
|
284
|
+
self._scroll_offset = 0
|
|
285
|
+
self._update_scrollbar()
|
|
286
|
+
|
|
287
|
+
text = event.text()
|
|
288
|
+
key = event.key()
|
|
289
|
+
|
|
290
|
+
seq = self._key_to_sequence(key, event.modifiers(), text)
|
|
291
|
+
if seq is None:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
os.write(self._master_fd, seq)
|
|
296
|
+
except OSError:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
def _key_to_sequence(self, key: int, mods: Qt.KeyboardModifier, text: str) -> bytes | None:
|
|
300
|
+
if key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
|
301
|
+
return b"\r"
|
|
302
|
+
if key == Qt.Key.Key_Backspace:
|
|
303
|
+
return b"\x7f"
|
|
304
|
+
if key == Qt.Key.Key_Tab:
|
|
305
|
+
return b"\t"
|
|
306
|
+
if key == Qt.Key.Key_Escape:
|
|
307
|
+
return b"\x1b"
|
|
308
|
+
if key == Qt.Key.Key_Up:
|
|
309
|
+
return b"\x1b[A"
|
|
310
|
+
if key == Qt.Key.Key_Down:
|
|
311
|
+
return b"\x1b[B"
|
|
312
|
+
if key == Qt.Key.Key_Right:
|
|
313
|
+
return b"\x1b[C"
|
|
314
|
+
if key == Qt.Key.Key_Left:
|
|
315
|
+
return b"\x1b[D"
|
|
316
|
+
if key == Qt.Key.Key_Home:
|
|
317
|
+
return b"\x1b[H"
|
|
318
|
+
if key == Qt.Key.Key_End:
|
|
319
|
+
return b"\x1b[F"
|
|
320
|
+
if key == Qt.Key.Key_Delete:
|
|
321
|
+
return b"\x1b[3~"
|
|
322
|
+
if key == Qt.Key.Key_PageUp:
|
|
323
|
+
self._scroll_page(-1)
|
|
324
|
+
return None
|
|
325
|
+
if key == Qt.Key.Key_PageDown:
|
|
326
|
+
self._scroll_page(1)
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
# Ctrl sequences
|
|
330
|
+
if mods & Qt.KeyboardModifier.ControlModifier:
|
|
331
|
+
if Qt.Key.Key_A <= key <= Qt.Key.Key_Z:
|
|
332
|
+
return bytes([key - Qt.Key.Key_A + 1])
|
|
333
|
+
if key == Qt.Key.Key_BracketLeft:
|
|
334
|
+
return b"\x1b"
|
|
335
|
+
|
|
336
|
+
# Regular text
|
|
337
|
+
if text:
|
|
338
|
+
return text.encode("utf-8")
|
|
339
|
+
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def _scroll_page(self, direction: int) -> None:
|
|
343
|
+
"""Scroll one page. direction < 0 = up (into history), > 0 = down (toward live)."""
|
|
344
|
+
delta = -direction * self._rows
|
|
345
|
+
self._scroll_offset = max(
|
|
346
|
+
0, min(self._max_scroll_offset(), self._scroll_offset + delta)
|
|
347
|
+
)
|
|
348
|
+
self._update_scrollbar()
|
|
349
|
+
self._viewport.update()
|
|
350
|
+
|
|
351
|
+
# ── mouse wheel ─────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
def wheelEvent(self, event) -> None:
|
|
354
|
+
delta = event.angleDelta().y()
|
|
355
|
+
lines = delta // 120 * 3 # 3 lines per notch; positive = scroll up into history
|
|
356
|
+
self._scroll_offset = max(
|
|
357
|
+
0, min(self._max_scroll_offset(), self._scroll_offset + lines)
|
|
358
|
+
)
|
|
359
|
+
self._update_scrollbar()
|
|
360
|
+
self._viewport.update()
|
|
361
|
+
|
|
362
|
+
# ── resize ──────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
def resizeEvent(self, event) -> None:
|
|
365
|
+
super().resizeEvent(event)
|
|
366
|
+
cw = self.char_width
|
|
367
|
+
ch = self.char_height
|
|
368
|
+
# Account for scrollbar width
|
|
369
|
+
vbar_w = 0
|
|
370
|
+
vbar = self.verticalScrollBar()
|
|
371
|
+
if vbar.isVisible():
|
|
372
|
+
vbar_w = vbar.width()
|
|
373
|
+
avail_w = self.width() - vbar_w
|
|
374
|
+
new_cols = max(1, avail_w // cw)
|
|
375
|
+
new_rows = max(1, self.height() // ch)
|
|
376
|
+
if new_cols != self._cols or new_rows != self._rows:
|
|
377
|
+
self._cols = new_cols
|
|
378
|
+
self._rows = new_rows
|
|
379
|
+
self._screen.resize(self._cols, self._rows)
|
|
380
|
+
self._set_pty_size()
|
|
381
|
+
self._update_scrollbar()
|
|
382
|
+
|
|
383
|
+
# ── paint ───────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
def _paint_terminal(self, painter: QPainter) -> None:
|
|
386
|
+
painter.setFont(self._font)
|
|
387
|
+
cw = self.char_width
|
|
388
|
+
ch = self.char_height
|
|
389
|
+
|
|
390
|
+
# Background
|
|
391
|
+
painter.fillRect(self.viewport().rect(), _DEFAULT_BG)
|
|
392
|
+
|
|
393
|
+
hist = self._history_len()
|
|
394
|
+
scroll = self._scroll_offset
|
|
395
|
+
|
|
396
|
+
# Build the list of lines to render: history (scrolled) + visible screen
|
|
397
|
+
# When scroll_offset > 0, we show history lines from the top.
|
|
398
|
+
if scroll > 0:
|
|
399
|
+
# Render history lines + current screen, shifted up by scroll
|
|
400
|
+
history_lines = list(self._screen.history.top)
|
|
401
|
+
# We want the last (hist - scroll) history lines, then all screen lines
|
|
402
|
+
start = max(0, hist - scroll)
|
|
403
|
+
visible_hist = history_lines[start:]
|
|
404
|
+
screen_lines = list(self._screen.buffer)
|
|
405
|
+
|
|
406
|
+
all_lines = visible_hist + screen_lines
|
|
407
|
+
# How many lines from the top of the viewport are "scrolled back"
|
|
408
|
+
# We render all_lines from index 0, but only the last self._rows fit
|
|
409
|
+
total = len(all_lines)
|
|
410
|
+
render_start = max(0, total - self._rows)
|
|
411
|
+
else:
|
|
412
|
+
all_lines = list(self._screen.buffer)
|
|
413
|
+
render_start = 0
|
|
414
|
+
|
|
415
|
+
# Render each line
|
|
416
|
+
for row_idx, line in enumerate(all_lines[render_start:render_start + self._rows]):
|
|
417
|
+
y = row_idx
|
|
418
|
+
py = (y + 1) * ch - self._fm.descent()
|
|
419
|
+
|
|
420
|
+
for col_idx in range(self._cols):
|
|
421
|
+
char: Char = line[col_idx] if col_idx < len(line) else Char(data=" ")
|
|
422
|
+
|
|
423
|
+
if char.data == " " and char.bg == "default" and not char.reverse:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
# Determine colours
|
|
427
|
+
fg = _char_colour(char.fg, _DEFAULT_FG)
|
|
428
|
+
bg = _char_colour(char.bg, _DEFAULT_BG)
|
|
429
|
+
|
|
430
|
+
if char.reverse:
|
|
431
|
+
fg, bg = bg, fg
|
|
432
|
+
|
|
433
|
+
# Fill background if non-default
|
|
434
|
+
if char.bg != "default" or char.reverse:
|
|
435
|
+
painter.fillRect(col_idx * cw, y * ch, cw, ch, bg)
|
|
436
|
+
|
|
437
|
+
# Draw character
|
|
438
|
+
if char.data != " ":
|
|
439
|
+
font = self._font
|
|
440
|
+
if char.bold:
|
|
441
|
+
font = QFont(self._font)
|
|
442
|
+
font.setBold(True)
|
|
443
|
+
if char.italics:
|
|
444
|
+
font = QFont(font)
|
|
445
|
+
font.setItalic(True)
|
|
446
|
+
painter.setFont(font)
|
|
447
|
+
painter.setPen(fg)
|
|
448
|
+
painter.drawText(col_idx * cw, py, char.data)
|
|
449
|
+
|
|
450
|
+
# Underline
|
|
451
|
+
if char.underscore:
|
|
452
|
+
pen = QPen(fg)
|
|
453
|
+
pen.setWidth(1)
|
|
454
|
+
painter.setPen(pen)
|
|
455
|
+
painter.drawLine(
|
|
456
|
+
col_idx * cw, y * ch + ch - 1,
|
|
457
|
+
col_idx * cw + cw, y * ch + ch - 1,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Cursor (only when at live bottom)
|
|
461
|
+
if scroll == 0:
|
|
462
|
+
cursor = self._screen.cursor
|
|
463
|
+
if cursor.y < self._rows and cursor.x < self._cols:
|
|
464
|
+
cx = cursor.x * cw
|
|
465
|
+
cy = cursor.y * ch
|
|
466
|
+
painter.fillRect(cx, cy, cw, ch, QColor(80, 80, 80))
|
|
467
|
+
# Redraw the character under the cursor in bg colour
|
|
468
|
+
if cursor.x < len(self._screen.buffer[cursor.y]):
|
|
469
|
+
char = self._screen.buffer[cursor.y][cursor.x]
|
|
470
|
+
if char.data != " ":
|
|
471
|
+
painter.setPen(_DEFAULT_BG)
|
|
472
|
+
painter.setFont(self._font)
|
|
473
|
+
painter.drawText(cx, (cursor.y + 1) * ch - self._fm.descent(), char.data)
|
|
474
|
+
|
|
475
|
+
def closeEvent(self, event) -> None:
|
|
476
|
+
self.stop()
|
|
477
|
+
super().closeEvent(event)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class _TerminalViewport(QWidget):
|
|
481
|
+
"""Internal viewport that delegates painting to the terminal."""
|
|
482
|
+
|
|
483
|
+
def __init__(self, terminal: TerminalWidget) -> None:
|
|
484
|
+
super().__init__(terminal)
|
|
485
|
+
self._terminal = terminal
|
|
486
|
+
|
|
487
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
488
|
+
painter = QPainter(self)
|
|
489
|
+
self._terminal._paint_terminal(painter)
|
|
490
|
+
painter.end()
|