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.
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()