Coverage for vibefpmgui/terminal.py: 82%
951 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The *Terminal* tab: your shell, in the project's folder, with exactly the
2environment VibeFPMGUI was started with.
4The shell gets the app's environment variables as they are (only ``TERM``
5is set, for the terminal, and a prompt: ``PS1``) and is started without its startup files
6(``zsh -f``, ``bash --noprofile --norc``, ``fish --no-config``), which
7would change them: conda's "base" activation, Homebrew's or your own PATH
8changes. Started from an activated pixi or conda environment, the terminal
9is in that environment, with everything its activation set (PATH,
10CONDA_PREFIX, the compilers' variables...): ``fpm`` and the compilers are
11the ones the app uses. (So your prompt, aliases and other shell settings
12don't load; the prompt is a plain one, starting with the environment's
13name, e.g. ``(VibeFPMGUI) src %``, as activation would show it.)
15On macOS and Linux the shell runs in a pseudo-terminal read with a
16`QSocketNotifier`, so everything happens in the GUI thread; on Windows it
17runs through pywinpty (ConPTY), read in a thread.
19The terminal is [pyte](https://github.com/selectel/pyte) (the screen: escape
20codes, colors, cursor) drawn by `TerminalView`, with scrollback, the
21alternate screen (full-screen programs: vim, less, htop) and reflow (long
22lines wrapped again when the terminal is resized) added in
23`TerminalScreen`. `TerminalView` handles the keys as xterm sends them
24(application cursor keys, function keys, modifiers), ⌘C/⌘V (macOS) or
25Ctrl+Shift+C/V (elsewhere) to copy and paste while Ctrl keys go to the
26shell, bracketed paste, selection, scrolling, and colors that follow
27light/dark mode.
28"""
30import os
31import shutil
32import signal
33import struct
34import sys
35import threading
36import warnings
37from collections import deque
38from pathlib import Path
39from typing import NamedTuple
41import pyte
42from pyte.graphics import FG_BG_256
43from qtpy.QtCore import (
44 QEvent,
45 QObject,
46 QPointF,
47 QRectF,
48 QSize,
49 QSocketNotifier,
50 Qt,
51 QTimer,
52 Signal,
53)
54from qtpy.QtGui import QClipboard, QColor, QFont, QFontMetricsF, QPainter, QPalette
55from qtpy.QtWidgets import (
56 QApplication,
57 QGridLayout,
58 QHBoxLayout,
59 QLabel,
60 QScrollBar,
61 QToolButton,
62 QVBoxLayout,
63 QWidget,
64)
66from .ansi import DARK, LIGHT
67from .icons import make_icon
69MAC = sys.platform == "darwin"
70WINDOWS = sys.platform == "win32"
73def user_shell() -> str:
74 if WINDOWS: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 return os.environ.get("COMSPEC", "cmd.exe")
76 return os.environ.get("SHELL") or shutil.which("zsh") or shutil.which("bash") or "/bin/sh"
79class Launch(NamedTuple):
80 argv: list[str]
81 env: dict[str, str]
84# Options that keep each shell from reading its startup files (they would
85# change the environment: conda's "base", Homebrew, PATH edits...).
86NO_STARTUP_FILES = {
87 "zsh": ["-f"], # no .zshenv, .zprofile, .zshrc, .zlogin
88 "bash": ["--noprofile", "--norc"],
89 "fish": ["--no-config"],
90 "sh": [], "dash": [], # (read startup files only with $ENV / as login shells)
91}
94# A plain prompt (the startup files that would set one aren't read): the
95# folder's name, after the environment's name when one is active.
96PROMPTS = {"zsh": "%1~ %# ", "bash": "\\W \\$ ", "sh": "$ ", "dash": "$ "}
99def shell_launch() -> Launch:
100 """How to start the user's shell with exactly the app's environment:
101 the same variables (only TERM is set, for the terminal, and PS1, the
102 prompt), and no startup files to change them."""
103 shell = user_shell()
104 env = dict(os.environ)
105 env["TERM"] = "xterm-256color"
106 if WINDOWS: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true
107 return Launch([shell], env)
108 name = Path(shell).name
109 if name in PROMPTS and "PS1" not in env: # (fish has its own prompt function)
110 active = environment_name()
111 env["PS1"] = (f"({active}) " if active else "") + PROMPTS[name]
112 return Launch([shell, *NO_STARTUP_FILES.get(name, []), "-i"], env)
115def environment_name() -> str:
116 """The activated environment the app (and the terminal) runs in, if any:
117 a pixi workspace's name (plus its environment, when not "default"), or a
118 conda environment's."""
119 pixi = os.environ.get("PIXI_PROJECT_NAME", "")
120 if pixi:
121 which = os.environ.get("PIXI_ENVIRONMENT_NAME", "default")
122 return pixi if which in ("", "default") else f"{pixi}:{which}"
123 return os.environ.get("CONDA_DEFAULT_ENV", "")
126# ------------------------------------------------------------ the shell
127class PtyShell(QObject):
128 """A shell in a pseudo-terminal (macOS, Linux), read in the GUI thread."""
130 output = Signal(bytes)
131 exited = Signal()
133 def __init__(self, launch: Launch, cwd: Path, rows: int, cols: int, parent=None):
134 super().__init__(parent)
135 self.launch, self.cwd, self.rows, self.cols = launch, cwd, rows, cols
136 self.pid = -1
137 self.fd = -1
138 self._notifier: QSocketNotifier | None = None
139 # Input the shell hasn't taken yet (it isn't reading: a long command,
140 # a big paste); sent when the pty can take more (see _flush).
141 self._pending = bytearray()
142 self._write_notifier: QSocketNotifier | None = None
144 def start(self):
145 import pty
147 argv, env, cwd = self.launch.argv, self.launch.env, str(self.cwd)
148 program = shutil.which(argv[0]) or argv[0]
149 with warnings.catch_warnings(): # (the child only execs: fork's thread caveat doesn't apply)
150 warnings.simplefilter("ignore", DeprecationWarning)
151 pid, fd = pty.fork()
152 if pid == 0: # the child: become the shell 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 try:
154 os.chdir(cwd)
155 except OSError:
156 pass
157 try:
158 os.execve(program, argv, env)
159 finally:
160 os._exit(127)
161 self.pid, self.fd = pid, fd
162 os.set_blocking(fd, False)
163 self.resize(self.rows, self.cols)
164 self._notifier = QSocketNotifier(fd, QSocketNotifier.Read, self)
165 self._notifier.activated.connect(self._read)
167 def _read(self, *_):
168 try:
169 data = os.read(self.fd, 65536)
170 except BlockingIOError:
171 return
172 except OSError: # (Linux: EIO once the shell has exited)
173 data = b""
174 if data:
175 self.output.emit(data)
176 else:
177 self._finish()
179 def write(self, data: bytes):
180 """Send input to the shell, now or (what doesn't fit yet) once the
181 pty can take more: never waiting for it in the GUI thread."""
182 if self.fd < 0: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 return
184 self._pending += data
185 self._flush()
187 def _flush(self, *_):
188 while self._pending and self.fd >= 0:
189 try:
190 written = os.write(self.fd, self._pending)
191 except BlockingIOError:
192 break # (full: the rest when it's writable again)
193 except OSError:
194 self._pending.clear()
195 break
196 del self._pending[:written]
197 waiting = bool(self._pending) and self.fd >= 0
198 if waiting and self._write_notifier is None:
199 self._write_notifier = QSocketNotifier(self.fd, QSocketNotifier.Write, self)
200 self._write_notifier.activated.connect(self._flush)
201 if self._write_notifier is not None:
202 self._write_notifier.setEnabled(waiting)
204 def resize(self, rows: int, cols: int):
205 self.rows, self.cols = rows, cols
206 if self.fd < 0 or rows <= 0 or cols <= 0:
207 return
208 import fcntl
209 import termios
211 try: # (the shell gets SIGWINCH from the kernel)
212 fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
213 except OSError:
214 pass
216 def _finish(self):
217 if self._notifier is not None:
218 self._notifier.setEnabled(False)
219 self._notifier = None
220 if self._write_notifier is not None:
221 self._write_notifier.setEnabled(False)
222 self._write_notifier = None
223 self._pending.clear()
224 if self.fd >= 0:
225 try:
226 os.close(self.fd)
227 except OSError:
228 pass
229 self.fd = -1
230 if self.pid > 0:
231 try:
232 os.waitpid(self.pid, os.WNOHANG)
233 except ChildProcessError:
234 pass
235 self.pid = -1
236 self.exited.emit()
238 def stop(self):
239 """End the shell (and what it runs), e.g. when the window closes."""
240 pid = self.pid
241 if pid > 0:
242 for sig in (signal.SIGHUP, signal.SIGCONT):
243 try:
244 os.kill(pid, sig)
245 except ProcessLookupError:
246 break
247 self._finish()
248 if pid > 0:
249 def reap(): # (no zombie: force it if SIGHUP was ignored)
250 try:
251 if os.waitpid(pid, os.WNOHANG) == (0, 0): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 os.kill(pid, signal.SIGKILL)
253 os.waitpid(pid, 0)
254 except (ChildProcessError, ProcessLookupError):
255 pass
256 QTimer.singleShot(1000, reap)
258 def is_running(self) -> bool:
259 return self.fd >= 0
262class WinptyShell(QObject):
263 """A shell through pywinpty (Windows ConPTY). Its reads block, so they
264 run in a thread; the output comes back to the GUI thread as a signal."""
266 output = Signal(bytes)
267 exited = Signal()
269 def __init__(self, launch: Launch, cwd: Path, rows: int, cols: int, parent=None):
270 super().__init__(parent)
271 self.launch, self.cwd, self.rows, self.cols = launch, cwd, rows, cols
272 self._process = None
274 def start(self):
275 from winpty import PtyProcess
277 self._process = PtyProcess.spawn(self.launch.argv, cwd=str(self.cwd), env=self.launch.env,
278 dimensions=(self.rows, self.cols))
279 threading.Thread(target=self._read_loop, args=(self._process,), daemon=True,
280 name="Terminal output").start()
282 def _read_loop(self, process):
283 try:
284 while True:
285 data = process.read(65536)
286 if not data and not process.isalive():
287 break
288 if data:
289 self.output.emit(data.encode("utf-8") if isinstance(data, str) else data)
290 except (EOFError, OSError):
291 pass
292 self.exited.emit()
294 def write(self, data: bytes):
295 if self._process is not None:
296 try:
297 self._process.write(data.decode("utf-8", errors="replace"))
298 except (EOFError, OSError):
299 pass
301 def resize(self, rows: int, cols: int):
302 self.rows, self.cols = rows, cols
303 if self._process is not None and rows > 0 and cols > 0:
304 try:
305 self._process.setwinsize(rows, cols)
306 except (EOFError, OSError):
307 pass
309 def stop(self):
310 if self._process is not None:
311 try:
312 self._process.terminate(force=True)
313 except (EOFError, OSError):
314 pass
315 self._process = None
317 def is_running(self) -> bool:
318 return self._process is not None and self._process.isalive()
321# --------------------------------------------------------------- screen
322SCROLLBACK = 10_000 # lines kept above the screen
323NO_WRAP_COLUMNS = 300 # the terminal's width without word wrap (wider than the window: scroll sideways)
325# Private modes (pyte keeps them shifted, see pyte.modes).
326DECCKM = 1 << 5 # application cursor keys
327ALT_SCREEN = {47 << 5, 1047 << 5, 1049 << 5}
328BRACKETED_PASTE = 2004 << 5
331class Row(list):
332 """A line of the scrollback: its Chars (trailing blanks dropped, unless
333 it was wrapped), and whether it was wrapped (the next row continues it)."""
334 wrapped = False
337def _wrapped(line) -> bool:
338 return getattr(line, "wrapped", False)
341class TerminalScreen(pyte.Screen):
342 """pyte's screen, with what pyte lacks: scrollback (the lines that
343 scroll off the top), the alternate screen full-screen programs draw on,
344 and reflow: lines pyte wrapped at the right edge are marked, and joined
345 and wrapped again at the new width when the terminal is resized (pyte
346 would cut them off, or leave them wrapped short)."""
348 def __init__(self, columns: int, lines: int):
349 self.history: deque = deque(maxlen=SCROLLBACK) # Rows above the screen
350 self.alternate = False
351 self._saved_main = None
352 self._drawing = False
353 self.reply = lambda data: None # answers to the terminal's queries go to the shell
354 super().__init__(columns, lines)
356 # ------------------------------------------------------- wrapped lines
357 def draw(self, data):
358 self._drawing = True # (a line feed now is pyte wrapping at the right edge)
359 try:
360 super().draw(data)
361 finally:
362 self._drawing = False
364 def linefeed(self):
365 # Mark the row being left: wrapped (it continues on the next) or not.
366 self.buffer[self.cursor.y].wrapped = self._drawing
367 super().linefeed()
369 def _row(self, y: int) -> Row:
370 line = self.buffer[y]
371 row = Row(line[x] for x in range(self.columns))
372 row.wrapped = _wrapped(line)
373 if not row.wrapped:
374 while row and row[-1] == self.default_char:
375 row.pop()
376 return row
378 def index(self):
379 top, bottom = self.margins or pyte.screens.Margins(0, self.lines - 1)
380 if self.cursor.y == bottom and top == 0 and not self.alternate:
381 self.history.append(self._row(0))
382 super().index()
384 def erase_in_display(self, how=0, *args, **kwargs):
385 if how == 3: # ("clear" also clears the scrollback)
386 self.history.clear()
387 rows = {0: range(self.cursor.y, self.lines), 1: range(self.cursor.y)}.get(how, range(self.lines))
388 for y in rows:
389 self.buffer[y].wrapped = False
390 super().erase_in_display(how, *args, **kwargs)
392 def erase_in_line(self, how=0, private=False):
393 if how in (0, 2): # (the end of the line is gone: it no longer continues) 393 ↛ 395line 393 didn't jump to line 395 because the condition on line 393 was always true
394 self.buffer[self.cursor.y].wrapped = False
395 super().erase_in_line(how, private)
397 # -------------------------------------------------------------- resize
398 def resize(self, lines=None, columns=None):
399 lines, columns = lines or self.lines, columns or self.columns
400 if (lines, columns) == (self.lines, self.columns):
401 return
402 if columns != self.columns:
403 # Tab stops every 8 columns across the new width (pyte sets them
404 # once, for the first width: a tab past it went to the last column).
405 self.tabstops = {x for x in self.tabstops if x < columns}
406 first_new = (min(self.columns, columns) + 7) // 8 * 8 # (the old ones stopped before the old width)
407 self.tabstops.update(range(max(8, first_new), columns, 8))
408 if self.alternate: # (full-screen programs redraw themselves at the new size)
409 super().resize(lines, columns)
410 self.cursor.y = min(self.cursor.y, self.lines - 1)
411 self.cursor.x = min(self.cursor.x, self.columns - 1)
412 return
413 self._reflow(lines, columns)
415 def _reflow(self, lines: int, columns: int):
416 """Join the wrapped rows (scrollback and screen) into whole lines and
417 wrap them again at the new width, keeping the cursor on its character
418 and the bottom of the output at the bottom of the screen."""
419 last = max([self.cursor.y] + [y for y in range(self.lines) if self._row(y)])
420 rows = list(self.history) + [self._row(y) for y in range(last + 1)]
421 cursor_row = len(self.history) + self.cursor.y
422 # Whole lines (lists of Chars); where the cursor is in them.
423 logical, cursor_at = [], (0, 0)
424 current: list = []
425 for n, row in enumerate(rows):
426 if n == cursor_row:
427 cursor_at = (len(logical), len(current) + self.cursor.x)
428 current.extend(row)
429 if not row.wrapped or n == len(rows) - 1:
430 logical.append(current)
431 current = []
432 # Wrap them at the new width.
433 new_rows: list[Row] = []
434 cursor_pos = (0, 0)
435 for index, cells in enumerate(logical):
436 start_row = len(new_rows)
437 pieces = [cells[i:i + columns] for i in range(0, len(cells), columns)] or [[]]
438 for k, piece in enumerate(pieces):
439 row = Row(piece)
440 row.wrapped = k < len(pieces) - 1
441 new_rows.append(row)
442 if index == cursor_at[0]:
443 offset = cursor_at[1]
444 y, x = divmod(offset, columns)
445 if y >= len(pieces) and x == 0 and offset: # (just past a full row: stay at its end) 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
446 y, x = y - 1, columns
447 cursor_pos = (start_row + y, x)
448 while len(new_rows) <= cursor_pos[0]: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 new_rows.append(Row())
450 top = max(0, len(new_rows) - lines, cursor_pos[0] - lines + 1)
451 top = min(top, cursor_pos[0])
452 self.history.clear()
453 self.history.extend(new_rows[:top])
454 self.buffer.clear()
455 for y, row in enumerate(new_rows[top:top + lines]):
456 line = self.buffer[y]
457 for x, char in enumerate(row):
458 line[x] = char
459 line.wrapped = row.wrapped
460 self.lines, self.columns = lines, columns
461 self.set_margins()
462 self.cursor.y = cursor_pos[0] - top
463 self.cursor.x = min(cursor_pos[1], columns)
464 self.dirty.update(range(lines))
466 # ---------------------------------------------------- alternate screen
467 def set_mode(self, *modes, **kwargs):
468 if kwargs.get("private") and not self.alternate and {m << 5 for m in modes} & ALT_SCREEN:
469 self._saved_main = (dict(self.buffer), self.cursor.x, self.cursor.y, self.cursor.attrs,
470 self.lines, self.columns)
471 self.alternate = True
472 self.buffer.clear()
473 self.dirty.update(range(self.lines))
474 super().set_mode(*modes, **kwargs)
476 def reset_mode(self, *modes, **kwargs):
477 super().reset_mode(*modes, **kwargs)
478 if kwargs.get("private") and self.alternate and {m << 5 for m in modes} & ALT_SCREEN:
479 buffer, x, y, attrs, lines, columns = self._saved_main
480 self.alternate = False
481 new_size = self.lines, self.columns
482 # Back to the main screen as it was, then reflowed if the size changed meanwhile.
483 self.buffer.clear()
484 self.buffer.update(buffer)
485 self.lines, self.columns = lines, columns
486 self.cursor.x, self.cursor.y, self.cursor.attrs = x, y, attrs
487 self.set_margins()
488 self.resize(*new_size)
489 self.dirty.update(range(self.lines))
491 def write_process_input(self, data: str):
492 self.reply(data.encode("utf-8"))
495class SafeScreen:
496 """The screen as pyte's parser sees it. A sequence pyte can't handle
497 (e.g. vim's "\\e[>4;2m", which pyte passes on with private=True to a
498 method that doesn't take it) is dropped: an error inside pyte's parser
499 would end it for good, and the terminal would show nothing more."""
501 def __init__(self, screen):
502 self._screen = screen
504 def __getattr__(self, name):
505 value = getattr(self._screen, name)
506 if not callable(value): 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true
507 return value
509 def safe(*args, **kwargs):
510 try:
511 return value(*args, **kwargs)
512 except Exception: # noqa: BLE001 - one odd sequence must not stop the terminal
513 return None
515 return safe
518# --------------------------------------------------------------- widget
519def terminal_colors(palette: QPalette) -> tuple[QColor, QColor, list[str]]:
520 """(background, text, the 16 ANSI colors) for the terminal: dark or
521 light like the app (the ANSI colors are the Output panel's)."""
522 if palette.color(QPalette.Base).lightness() < 128:
523 return QColor("#1e1e1e"), QColor("#d4d4d4"), DARK
524 return QColor("#ffffff"), QColor("#1f1f1f"), LIGHT
527# pyte's color names -> index in the 16 ANSI colors.
528ANSI_NAMES = {name: i for i, name in enumerate(
529 ["black", "red", "green", "brown", "blue", "magenta", "cyan", "white"])}
530ANSI_NAMES.update({"bright" + name: i + 8 for name, i in list(ANSI_NAMES.items())})
531ANSI_NAMES.update({"yellow": 3, "brightyellow": 11})
532ANSI_HEX = {FG_BG_256[i]: i for i in range(16)} # (38;5;n with n < 16: the theme's colors too)
534# Keys -> what xterm sends ("{m}": the modifier parameter, when there is one).
535CURSOR_KEYS = {Qt.Key_Up: "A", Qt.Key_Down: "B", Qt.Key_Right: "C", Qt.Key_Left: "D",
536 Qt.Key_Home: "H", Qt.Key_End: "F"}
537TILDE_KEYS = {Qt.Key_Insert: 2, Qt.Key_Delete: 3, Qt.Key_PageUp: 5, Qt.Key_PageDown: 6,
538 Qt.Key_F5: 15, Qt.Key_F6: 17, Qt.Key_F7: 18, Qt.Key_F8: 19, Qt.Key_F9: 20,
539 Qt.Key_F10: 21, Qt.Key_F11: 23, Qt.Key_F12: 24}
540SS3_KEYS = {Qt.Key_F1: "P", Qt.Key_F2: "Q", Qt.Key_F3: "R", Qt.Key_F4: "S"}
541# On macOS ⌘ is Qt's ControlModifier and the Control key its MetaModifier.
542COMMAND = Qt.ControlModifier if MAC else Qt.NoModifier
543CONTROL = Qt.MetaModifier if MAC else Qt.ControlModifier
544PADDING = 4
547class TerminalView(QWidget):
548 """Draws a `TerminalScreen` and sends keys to the shell (see the
549 module's description)."""
551 size_changed = Signal(int, int) # rows, columns
553 def __init__(self, parent=None):
554 super().__init__(parent)
555 self.setFocusPolicy(Qt.StrongFocus)
556 self.setAttribute(Qt.WA_OpaquePaintEvent)
557 self.setCursor(Qt.IBeamCursor)
558 self.setMinimumSize(80, 40)
559 self.write = lambda data: None # bytes to the shell (set by the panel)
560 self.screen = TerminalScreen(80, 24)
561 self.screen.reply = lambda data: self.write(data)
562 self.stream = pyte.ByteStream(SafeScreen(self.screen))
563 self.scroll_bar: QScrollBar | None = None
564 self.hscroll_bar: QScrollBar | None = None
565 self.word_wrap = True # View → Word Wrap (off: a wide terminal, scrolled sideways)
566 self._hscroll = 0 # pixels scrolled to the right (without word wrap)
567 self._scroll = 0 # lines scrolled up from the bottom (0: following the output)
568 self._scroll_px = 0.0 # and pixels more, into the line above (trackpad: smooth, see scroll_pixels)
569 self._selection = None # ((line, column), (line, column)) in all lines (scrollback + screen)
570 self._colors: dict[str, QColor] = {}
571 self.set_colors(QColor("#1e1e1e"), QColor("#d4d4d4"), DARK)
572 self.set_terminal_font(QFont("Menlo" if MAC else "Monospace"))
573 self._redraw_all = True # (else only the rows the output changed: see _after_output)
574 self._wheel_rest = 0.0 # part of a line scrolled, not yet moved (see wheelEvent)
575 self._cursor_drawn_at = 0 # the screen row the cursor was last drawn on
576 self._repaint_timer = QTimer(self) # (output in bursts: draw at most every few ms)
577 self._repaint_timer.setSingleShot(True)
578 self._repaint_timer.setInterval(10)
579 self._repaint_timer.timeout.connect(self._after_output)
581 # ------------------------------------------------------------ setup
582 def set_colors(self, background: QColor, text: QColor, ansi: list[str]):
583 self.background, self.foreground = QColor(background), QColor(text)
584 self.ansi = [QColor(c) for c in ansi]
585 self._colors.clear()
586 self.update()
588 def set_terminal_font(self, font: QFont):
589 # Family and size set explicitly: a size left unset would be measured
590 # at the application's default size but drawn at the widget's.
591 size = font.pointSizeF() if font.pointSizeF() > 0 else QApplication.font().pointSizeF()
592 font = QFont(font)
593 font.setFamily(font.family())
594 font.setPointSizeF(size)
595 font.setStyleHint(QFont.Monospace)
596 font.setFixedPitch(True)
597 font.setKerning(False)
598 self.font_normal = font
599 self.font_bold = QFont(font)
600 self.font_bold.setBold(True)
601 metrics = QFontMetricsF(font, self) # (as drawn on this widget: its screen's resolution)
602 self.cell_width = metrics.horizontalAdvance("M")
603 self.cell_height = metrics.lineSpacing()
604 self.ascent = metrics.ascent()
605 self._fit()
606 self.update()
608 def connect_scroll_bar(self, bar: QScrollBar):
609 self.scroll_bar = bar
610 bar.valueChanged.connect(self._scrolled)
611 self._update_scroll_bar()
613 def connect_hscroll_bar(self, bar: QScrollBar):
614 self.hscroll_bar = bar
615 bar.valueChanged.connect(self._hscrolled)
616 self._update_hscroll_bar()
618 def set_word_wrap(self, on: bool):
619 """With word wrap the terminal is as wide as the window and long
620 lines wrap; without, it's NO_WRAP_COLUMNS wide and the window shows
621 part of it (the shell is told the real width either way, so line
622 editing and full-screen programs stay right)."""
623 self.word_wrap = on
624 if on:
625 self._hscroll = 0
626 self._fit()
627 self._update_hscroll_bar()
628 self.update()
630 # ----------------------------------------------------------- output
631 def feed(self, data: bytes):
632 """Output from the shell."""
633 before = len(self.screen.history)
634 self.stream.feed(data)
635 added = len(self.screen.history) - before
636 if added: # (lines scrolled into the scrollback: everything in view moved)
637 self._redraw_all = True
638 if self._scroll: # (reading the scrollback: keep the same lines in view)
639 self._scroll = min(self._scroll + max(0, added), len(self.screen.history))
640 if not self._repaint_timer.isActive():
641 self._repaint_timer.start()
643 def _after_output(self):
644 hscroll = self._hscroll
645 self._update_scroll_bar()
646 self.ensure_cursor_visible()
647 self._update_hscroll_bar()
648 if self._redraw_all or self._scroll or self._scroll_px or self._hscroll != hscroll:
649 self.update() # (scrolled: every row in view is another)
650 else:
651 self._update_rows(set(self.screen.dirty))
652 self._redraw_all = False
653 self.screen.dirty.clear()
655 def _update_rows(self, rows: set[int]):
656 """Repaint only these screen rows (what the output changed), and the
657 cursor's old and new rows."""
658 cursor = self.screen.cursor.y
659 rows |= {cursor, self._cursor_drawn_at}
660 for row in rows:
661 if 0 <= row < self.screen.lines:
662 top = int(PADDING + row * self.cell_height)
663 self.update(0, top, self.width(), int(self.cell_height) + 2)
665 # ------------------------------------------------- sideways (no wrap)
666 def _used_columns(self) -> int:
667 """How far the text in view (and the cursor) reaches, in columns."""
668 screen = self.screen
669 first = len(screen.history) - self._scroll
670 cursor_row = len(screen.history) + screen.cursor.y - first
671 widest = screen.cursor.x + 1 if 0 <= cursor_row < screen.lines else 0
672 blank = screen.default_char
673 for n in range(first, first + screen.lines):
674 line = self.line(n)
675 if isinstance(line, list): 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true
676 end = len(line)
677 while end > widest and line[end - 1] == blank:
678 end -= 1
679 else: # (a screen line: only the cells written are in it)
680 end = max((x + 1 for x, cell in line.items() if cell != blank), default=0)
681 widest = max(widest, min(end, screen.columns))
682 return widest
684 def _scroll_range(self) -> int:
685 """How far the view can scroll sideways (pixels): to the end of the
686 widest text in view, not the terminal's full width."""
687 if self.word_wrap:
688 return 0
689 return max(0, int(self._used_columns() * self.cell_width + 2 * PADDING) - self.width())
691 def _update_hscroll_bar(self):
692 bar = self.hscroll_bar
693 maximum = self._scroll_range()
694 self._hscroll = min(self._hscroll, maximum)
695 if bar is None:
696 return
697 bar.blockSignals(True)
698 bar.setRange(0, maximum)
699 bar.setPageStep(max(1, self.width()))
700 bar.setSingleStep(max(1, int(self.cell_width * 4)))
701 bar.setValue(self._hscroll)
702 bar.blockSignals(False)
703 # Shown only when there's something to scroll to; without word wrap
704 # its room is kept while hidden, so the terminal's height (and the
705 # shell's idea of it) doesn't change as it comes and goes.
706 policy = bar.sizePolicy()
707 if policy.retainSizeWhenHidden() == self.word_wrap:
708 policy.setRetainSizeWhenHidden(not self.word_wrap)
709 bar.setSizePolicy(policy)
710 layout = bar.parentWidget().layout() if bar.parentWidget() else None
711 if layout is not None: # (a hidden widget's new policy doesn't re-lay-out by itself) 711 ↛ 714line 711 didn't jump to line 714 because the condition on line 711 was always true
712 layout.invalidate()
713 layout.activate()
714 bar.setVisible(maximum > 0)
716 def _hscrolled(self, value: int):
717 self._hscroll = value
718 self.update()
720 def scroll_sideways(self, pixels: int):
721 if not self.word_wrap:
722 self._hscroll = max(0, min(self._scroll_range(), self._hscroll + pixels))
723 self._update_hscroll_bar()
724 self.update()
726 def ensure_cursor_visible(self):
727 """Without word wrap: scroll sideways to the cursor (as you type, and
728 back to the left at a new prompt)."""
729 if self.word_wrap or self._scroll or self._scroll_px:
730 return
731 x = PADDING + self.screen.cursor.x * self.cell_width
732 margin = 4 * self.cell_width
733 if x < self._hscroll + margin:
734 self._hscroll = int(max(0, x - margin)) if x > self.width() - margin else 0
735 elif x + self.cell_width > self._hscroll + self.width() - margin:
736 self._hscroll = int(x + self.cell_width + margin - self.width())
737 self._update_hscroll_bar()
739 def _update_scroll_bar(self):
740 bar = self.scroll_bar
741 if bar is None:
742 return
743 history = len(self.screen.history)
744 bar.blockSignals(True)
745 bar.setRange(0, history)
746 bar.setPageStep(self.screen.lines)
747 bar.setValue(history - self._scroll)
748 bar.blockSignals(False)
750 def _scrolled(self, value: int):
751 self._scroll = max(0, len(self.screen.history) - value)
752 self._scroll_px = 0.0
753 self._update_hscroll_bar() # (the lines now in view may be wider or narrower)
754 self.update()
756 def scroll_pixels(self, pixels: float):
757 """Scroll the view up (positive) or down by pixels, through the
758 scrollback: smoothly, part of a line at a time (a trackpad)."""
759 history, ch = len(self.screen.history), self.cell_height
760 total = max(0.0, min(history * ch, self._scroll * ch + self._scroll_px + pixels))
761 self._scroll = min(history, int(total // ch))
762 self._scroll_px = total - self._scroll * ch if self._scroll < history else 0.0
763 self._update_scroll_bar()
764 self._update_hscroll_bar()
765 self.update()
767 def scroll_lines(self, lines: int):
768 """Scroll the view up (positive) or down through the scrollback."""
769 self._scroll = max(0, min(len(self.screen.history), self._scroll + lines))
770 self._scroll_px = 0.0
771 self._update_scroll_bar()
772 self._update_hscroll_bar()
773 self.update()
775 # ------------------------------------------------------------ text
776 def total_lines(self) -> int:
777 return len(self.screen.history) + self.screen.lines
779 def line(self, n: int):
780 """Line ``n`` of the scrollback and screen (a list or pyte line of Chars)."""
781 history = self.screen.history
782 return history[n] if n < len(history) else self.screen.buffer[n - len(history)]
784 def _line_text(self, n: int, start: int = 0, end: int | None = None) -> str:
785 line = self.line(n)
786 width = len(line) if isinstance(line, list) else self.screen.columns
787 end = width if end is None else min(end, width)
788 return "".join(line[x].data for x in range(start, end)).rstrip()
790 def text(self) -> str:
791 """All the text: the scrollback, then the screen."""
792 lines = [self._line_text(n) for n in range(self.total_lines())]
793 while lines and not lines[-1]:
794 lines.pop()
795 return "\n".join(lines)
797 def selected_text(self) -> str:
798 if not self._selection: 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true
799 return ""
800 (l1, c1), (l2, c2) = sorted(self._selection)
801 if l1 == l2: 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true
802 return self._line_text(l1, c1, c2 + 1)
803 parts = [self._line_text(l1, c1)] + [self._line_text(n) for n in range(l1 + 1, l2)]
804 return "\n".join(parts + [self._line_text(l2, 0, c2 + 1)])
806 def _row_selection(self, n: int, width: int) -> tuple[int, int] | None:
807 """The columns of line ``n`` that are selected, [start, end), or None."""
808 if not self._selection:
809 return None
810 (l1, c1), (l2, c2) = sorted(self._selection)
811 if not l1 <= n <= l2:
812 return None
813 return (c1 if n == l1 else 0), (c2 + 1 if n == l2 else width)
815 # ---------------------------------------------------- Edit menu, clipboard
816 def copy(self):
817 text = self.selected_text()
818 if text: 818 ↛ exitline 818 didn't return from function 'copy' because the condition on line 818 was always true
819 QApplication.clipboard().setText(text)
821 def paste(self):
822 text = QApplication.clipboard().text()
823 if not text: 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true
824 return
825 data = text.replace("\r\n", "\n").replace("\n", "\r").encode("utf-8")
826 if BRACKETED_PASTE in self.screen.mode: # (so the shell doesn't run it line by line)
827 data = b"\x1b[200~" + data + b"\x1b[201~"
828 self._input(data)
830 def cut(self):
831 self.copy()
833 def selectAll(self):
834 last = self.total_lines() - 1
835 self._selection = ((0, 0), (last, self.screen.columns - 1))
836 self.update()
838 # (These exist so the Edit menu doesn't reach past the terminal to the editor.)
839 def undo(self):
840 pass
842 def redo(self):
843 pass
845 def clear_scrollback(self):
846 """⌘K: forget the scrollback and redraw the prompt at the top."""
847 self.screen.history.clear()
848 self._scroll, self._scroll_px = 0, 0.0
849 self._selection = None
850 self._update_scroll_bar()
851 self._input(b"\x0c") # Ctrl+L
853 # ------------------------------------------------------------ keys
854 def _input(self, data: bytes):
855 """Typing goes to the shell, and back to the bottom of the output."""
856 if self._scroll or self._scroll_px or self._selection:
857 self._scroll, self._scroll_px, self._selection = 0, 0.0, None
858 self._update_scroll_bar()
859 self.update()
860 self.write(data)
862 def focusNextPrevChild(self, _next):
863 return False # Tab goes to the shell (completion), not to the next widget
865 def _wanted(self, event) -> bool:
866 """Keys the terminal takes before the window's shortcuts: Control
867 combinations (Ctrl+C must interrupt, not copy), Escape and Tab."""
868 mods = event.modifiers() & ~Qt.KeypadModifier
869 if mods & CONTROL and not mods & Qt.ShiftModifier and not (MAC and mods & COMMAND):
870 return True
871 return mods == Qt.NoModifier and event.key() in (Qt.Key_Escape, Qt.Key_Tab)
873 def event(self, event):
874 if event.type() == QEvent.ShortcutOverride and self._wanted(event):
875 event.accept()
876 return True
877 return super().event(event)
879 def key_bytes(self, key: int, mods, text: str) -> bytes | None:
880 """What xterm sends for a key (None: nothing)."""
881 shift, alt, ctrl = bool(mods & Qt.ShiftModifier), bool(mods & Qt.AltModifier), bool(mods & CONTROL)
882 modifier = 1 + shift + 2 * alt + 4 * ctrl # (xterm's modifier parameter)
883 if MAC and alt and not ctrl and key in (Qt.Key_Left, Qt.Key_Right): 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true
884 return b"\x1bb" if key == Qt.Key_Left else b"\x1bf" # Option+arrow: a word
885 if key in CURSOR_KEYS:
886 final = CURSOR_KEYS[key]
887 if modifier > 1:
888 return f"\x1b[1;{modifier}{final}".encode()
889 return f"\x1b{'O' if DECCKM in self.screen.mode else '['}{final}".encode()
890 if key in TILDE_KEYS:
891 number = TILDE_KEYS[key]
892 return (f"\x1b[{number};{modifier}~" if modifier > 1 else f"\x1b[{number}~").encode()
893 if key in SS3_KEYS:
894 return (f"\x1b[1;{modifier}{SS3_KEYS[key]}" if modifier > 1 else f"\x1bO{SS3_KEYS[key]}").encode()
895 if key == Qt.Key_Backtab: 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true
896 return b"\x1b[Z"
897 if key in (Qt.Key_Return, Qt.Key_Enter):
898 return b"\r"
899 if key == Qt.Key_Backspace:
900 return b"\x08" if ctrl else b"\x7f"
901 if key == Qt.Key_Escape: 901 ↛ 902line 901 didn't jump to line 902 because the condition on line 901 was never true
902 return b"\x1b"
903 if key == Qt.Key_Tab: 903 ↛ 904line 903 didn't jump to line 904 because the condition on line 903 was never true
904 return b"\t"
905 if ctrl:
906 if Qt.Key_A <= key <= Qt.Key_Z:
907 return bytes([key - Qt.Key_A + 1])
908 control = {Qt.Key_Space: 0, Qt.Key_At: 0, Qt.Key_BracketLeft: 27, Qt.Key_Backslash: 28,
909 Qt.Key_BracketRight: 29, Qt.Key_AsciiCircum: 30, Qt.Key_Underscore: 31,
910 Qt.Key_Minus: 31, Qt.Key_Slash: 31}
911 if key in control:
912 return bytes([control[key]])
913 if text:
914 data = text.encode("utf-8")
915 # Alt+key: ESC first (on macOS Option types characters instead).
916 return b"\x1b" + data if alt and not MAC else data
917 return None
919 def keyPressEvent(self, event):
920 key, mods, text = event.key(), event.modifiers() & ~Qt.KeypadModifier, event.text()
921 if MAC and mods & COMMAND: # ⌘: the app's, not the shell's 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true
922 actions = {Qt.Key_C: self.copy, Qt.Key_V: self.paste, Qt.Key_K: self.clear_scrollback,
923 Qt.Key_A: self.selectAll}
924 if key in actions:
925 actions[key]()
926 else:
927 event.ignore()
928 return
929 if not MAC and mods == (Qt.ControlModifier | Qt.ShiftModifier) and key in (Qt.Key_C, Qt.Key_V): 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true
930 self.copy() if key == Qt.Key_C else self.paste()
931 return
932 if mods == Qt.ShiftModifier and key in (Qt.Key_PageUp, Qt.Key_PageDown): # read the scrollback 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true
933 self.scroll_lines(self.screen.lines - 1 if key == Qt.Key_PageUp else 1 - self.screen.lines)
934 return
935 data = self.key_bytes(key, mods, text)
936 if data:
937 self._input(data)
938 else:
939 event.ignore()
941 # ------------------------------------------------------------ mouse
942 def _cell_at(self, pos) -> tuple[int, int]:
943 """(line in scrollback + screen, column) under a point."""
944 row = int((pos.y() - PADDING - self._scroll_px) // self.cell_height)
945 col = int((pos.x() + self._hscroll - PADDING) // self.cell_width)
946 row = max(-1 if self._scroll_px else 0, min(self.screen.lines - 1, row))
947 col = max(0, min(self.screen.columns - 1, col))
948 return len(self.screen.history) - self._scroll + row, col
950 def mousePressEvent(self, event):
951 if event.button() == Qt.LeftButton:
952 cell = self._cell_at(event.position())
953 self._selection = (cell, cell)
954 self._selecting = True
955 self.update()
956 super().mousePressEvent(event)
958 def mouseMoveEvent(self, event):
959 if getattr(self, "_selecting", False) and self._selection:
960 self._selection = (self._selection[0], self._cell_at(event.position()))
961 self.update()
963 def mouseReleaseEvent(self, event):
964 self._selecting = False
965 if self._selection and self._selection[0] == self._selection[1]:
966 self._selection = None # (a click, not a selection)
967 self.update()
968 elif self._selection and not MAC and sys.platform.startswith("linux"):
969 QApplication.clipboard().setText(self.selected_text(), QClipboard.Selection)
971 def mouseDoubleClickEvent(self, event):
972 """Select the word under the mouse."""
973 n, x = self._cell_at(event.position())
974 text = self._line_text(n).ljust(self.screen.columns)
976 def in_word(c):
977 return c.isalnum() or c in "_-./~:@%+"
979 if x < len(text) and in_word(text[x]):
980 start, end = x, x
981 while start > 0 and in_word(text[start - 1]):
982 start -= 1
983 while end + 1 < len(text) and in_word(text[end + 1]):
984 end += 1
985 self._selection = ((n, start), (n, end))
986 self.update()
988 def wheelEvent(self, event):
989 """Scroll through the scrollback (or, in a full-screen program, send it
990 arrow keys, as xterm does). A trackpad's distance is in pixels and
991 comes in many small steps: they add up, the part of a line left over
992 kept for the next one, so a swipe up and back down the same distance
993 ends where it started."""
994 pixels, angle = event.pixelDelta(), event.angleDelta()
995 dx, dy = pixels.x() or angle.x(), pixels.y() or angle.y()
996 if event.modifiers() & Qt.ShiftModifier: 996 ↛ 997line 996 didn't jump to line 997 because the condition on line 996 was never true
997 dx, dy = dx or dy, 0 # (Shift+wheel: sideways)
998 if abs(dx) > abs(dy): # (mostly sideways: a vertical swipe's bit of sideways doesn't count) 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true
999 self.scroll_sideways(-dx)
1000 return
1001 # Up and down: a trackpad in its exact pixels; a mouse wheel 3 lines a notch.
1002 distance = pixels.y() if pixels.y() else angle.y() / 120 * 3 * self.cell_height
1003 if not distance: 1003 ↛ 1004line 1003 didn't jump to line 1004 because the condition on line 1003 was never true
1004 return
1005 if not self.screen.alternate: 1005 ↛ 1009line 1005 didn't jump to line 1009 because the condition on line 1005 was always true
1006 self.scroll_pixels(distance)
1007 return
1008 # Full-screen programs: the wheel is arrow keys (as in xterm), a line at a time.
1009 self._wheel_rest += distance / max(1.0, self.cell_height)
1010 whole = int(self._wheel_rest) # (towards zero: the rest waits for the next step)
1011 self._wheel_rest -= whole
1012 if whole:
1013 key = Qt.Key_Up if whole > 0 else Qt.Key_Down
1014 self.write(self.key_bytes(key, Qt.NoModifier, "") * abs(whole))
1016 def contextMenuEvent(self, event):
1017 from qtpy.QtWidgets import QMenu
1019 menu = QMenu(self)
1020 copy = menu.addAction("Copy", self.copy)
1021 copy.setEnabled(bool(self._selection))
1022 menu.addAction("Paste", self.paste)
1023 menu.addAction("Select All", self.selectAll)
1024 menu.addSeparator()
1025 menu.addAction("Clear Scrollback", self.clear_scrollback)
1026 menu.exec(event.globalPos())
1028 # -------------------------------------------------------- drawing
1029 def _fit(self):
1030 """Columns and rows for the widget's size, or wider without word
1031 wrap (the shell is told)."""
1032 cols = max(2, int((self.width() - 2 * PADDING) // self.cell_width))
1033 if not self.word_wrap:
1034 cols = max(cols, NO_WRAP_COLUMNS)
1035 rows = max(1, int((self.height() - 2 * PADDING) // self.cell_height))
1036 if (rows, cols) != (self.screen.lines, self.screen.columns):
1037 self.screen.resize(rows, cols)
1038 self._scroll, self._scroll_px = min(self._scroll, len(self.screen.history)), 0.0
1039 self._update_scroll_bar()
1040 self.size_changed.emit(rows, cols)
1042 def resizeEvent(self, event):
1043 super().resizeEvent(event)
1044 self._fit()
1045 self._update_hscroll_bar()
1047 def focusInEvent(self, event):
1048 super().focusInEvent(event)
1049 self.update()
1051 def focusOutEvent(self, event):
1052 super().focusOutEvent(event)
1053 self.update()
1055 def _color(self, name: str, default: QColor) -> QColor:
1056 if name == "default":
1057 return default
1058 color = self._colors.get(name)
1059 if color is None: 1059 ↛ 1067line 1059 didn't jump to line 1067 because the condition on line 1059 was always true
1060 if name in ANSI_NAMES:
1061 color = self.ansi[ANSI_NAMES[name]]
1062 elif name in ANSI_HEX:
1063 color = self.ansi[ANSI_HEX[name]]
1064 else:
1065 color = QColor("#" + name) if len(name) == 6 else QColor(default)
1066 self._colors[name] = color
1067 return color
1069 def paintEvent(self, event):
1070 painter = QPainter(self)
1071 painter.fillRect(self.rect(), self.background)
1072 offset = self._scroll_px # (scrolled part of a line further: everything a bit lower)
1073 painter.translate(-self._hscroll, offset) # (and sideways, without word wrap)
1074 screen = self.screen
1075 history = len(screen.history)
1076 first = history - self._scroll
1077 ch = self.cell_height
1078 palette = self.palette()
1079 select_bg = palette.color(QPalette.Highlight)
1080 select_fg = palette.color(QPalette.HighlightedText)
1081 top_row = max(-1 if offset else 0, int((event.rect().top() - PADDING - offset) // ch))
1082 bottom_row = min(screen.lines - 1, int((event.rect().bottom() - PADDING - offset) // ch))
1083 for row in range(top_row, bottom_row + 1):
1084 n = first + row
1085 if n < 0: 1085 ↛ 1086line 1085 didn't jump to line 1086 because the condition on line 1085 was never true
1086 continue
1087 line = self.line(n)
1088 width = min(len(line), screen.columns) if isinstance(line, list) else screen.columns
1089 span = self._row_selection(n, width) # (once per row: not per cell)
1090 y = PADDING + row * ch
1091 x = 0
1092 while x < width:
1093 cell = line[x]
1094 selected = span is not None and span[0] <= x < span[1]
1095 style = (cell.fg, cell.bg, cell.bold, cell.italics, cell.underscore,
1096 cell.strikethrough, cell.reverse, selected)
1097 start, chars = x, [cell.data]
1098 x += 1
1099 while x < width: # (a run of cells drawn alike)
1100 other = line[x]
1101 if (other.fg, other.bg, other.bold, other.italics, other.underscore, other.strikethrough,
1102 other.reverse, span is not None and span[0] <= x < span[1]) != style:
1103 break
1104 chars.append(other.data)
1105 x += 1
1106 self._draw_run(painter, start, y, chars, cell, selected, select_bg, select_fg)
1107 self._draw_cursor(painter, first)
1109 def _draw_run(self, painter, column, y, chars, cell, selected, select_bg, select_fg):
1110 cw, ch = self.cell_width, self.cell_height
1111 fg = self._color(cell.fg, self.foreground)
1112 bg = self._color(cell.bg, self.background)
1113 if cell.reverse:
1114 fg, bg = bg, fg
1115 if selected: 1115 ↛ 1116line 1115 didn't jump to line 1116 because the condition on line 1115 was never true
1116 fg, bg = select_fg, select_bg
1117 x0 = PADDING + column * cw
1118 if bg != self.background:
1119 painter.fillRect(QRectF(x0, y, len(chars) * cw, ch), bg)
1120 text = "".join(chars)
1121 if not text.strip():
1122 return
1123 font = self.font_bold if cell.bold else self.font_normal
1124 if cell.italics or cell.underscore or cell.strikethrough: 1124 ↛ 1125line 1124 didn't jump to line 1125 because the condition on line 1124 was never true
1125 font = QFont(font)
1126 font.setItalic(cell.italics)
1127 font.setUnderline(cell.underscore)
1128 font.setStrikeOut(cell.strikethrough)
1129 painter.setFont(font)
1130 painter.setPen(fg)
1131 baseline = y + self.ascent
1132 if text.isascii():
1133 painter.drawText(QPointF(x0, baseline), text)
1134 else: # (each character in its cell: wide ones take two, the next is empty)
1135 for i, c in enumerate(chars):
1136 if c:
1137 painter.drawText(QPointF(x0 + i * cw, baseline), c)
1139 def _draw_cursor(self, painter, first):
1140 screen = self.screen
1141 self._cursor_drawn_at = screen.cursor.y
1142 row = len(screen.history) + screen.cursor.y - first
1143 if screen.cursor.hidden or not 0 <= row < screen.lines:
1144 return
1145 x = PADDING + screen.cursor.x * self.cell_width
1146 y = PADDING + row * self.cell_height
1147 rect = QRectF(x, y, self.cell_width, self.cell_height)
1148 if self.hasFocus():
1149 painter.fillRect(rect, self.foreground)
1150 cell = screen.buffer[screen.cursor.y][screen.cursor.x]
1151 if cell.data.strip(): 1151 ↛ 1152line 1151 didn't jump to line 1152 because the condition on line 1151 was never true
1152 painter.setFont(self.font_bold if cell.bold else self.font_normal)
1153 painter.setPen(self.background)
1154 painter.drawText(QPointF(x, y + self.ascent), cell.data)
1155 else:
1156 painter.setPen(self.foreground)
1157 painter.setBrush(Qt.NoBrush)
1158 painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5))
1161class TerminalPanel(QWidget):
1162 """The *Terminal* tab: a header (shell, folder, environment, Restart)
1163 over the terminal."""
1165 def __init__(self, parent=None):
1166 super().__init__(parent)
1167 self.directory: Path = Path.home()
1168 self.shell: PtyShell | WinptyShell | None = None
1169 self.title = QLabel()
1170 self.title.setContentsMargins(6, 2, 6, 2)
1171 self.restart_button = QToolButton() # (an icon button, as the Debug and Git panels': see refresh_colors)
1172 self.restart_button.setText("Restart")
1173 self.restart_button.setToolButtonStyle(Qt.ToolButtonIconOnly)
1174 self.restart_button.setIconSize(QSize(18, 18))
1175 self.restart_button.setAutoRaise(True)
1176 self.restart_button.setToolTip("End this shell and start a new one in the project's folder")
1177 self.restart_button.clicked.connect(self.restart)
1178 header = QHBoxLayout()
1179 header.setContentsMargins(0, 0, 0, 0)
1180 header.addWidget(self.title, 1)
1181 header.addWidget(self.restart_button)
1182 self.view = TerminalView(self)
1183 self.scroll_bar = QScrollBar(Qt.Vertical)
1184 self.view.connect_scroll_bar(self.scroll_bar)
1185 self.hscroll_bar = QScrollBar(Qt.Horizontal) # (shown without word wrap)
1186 self.view.connect_hscroll_bar(self.hscroll_bar)
1187 body = QGridLayout()
1188 body.setContentsMargins(0, 0, 0, 0)
1189 body.setSpacing(0)
1190 body.addWidget(self.view, 0, 0)
1191 body.addWidget(self.scroll_bar, 0, 1)
1192 body.addWidget(self.hscroll_bar, 1, 0)
1193 layout = QVBoxLayout(self)
1194 layout.setContentsMargins(0, 0, 0, 0)
1195 layout.setSpacing(0)
1196 layout.addLayout(header)
1197 layout.addLayout(body, 1)
1198 self.view.size_changed.connect(self._resized)
1199 self.refresh_colors()
1201 def set_directory(self, directory: Path | None):
1202 """The project's folder: where the shell starts. A different one (another
1203 project opened) restarts a running shell there; the same one (the
1204 project reloaded) leaves it alone."""
1205 directory = directory if directory and directory.is_dir() else Path.home()
1206 moved = directory.resolve() != self.directory.resolve()
1207 self.directory = directory
1208 if moved and self.shell is not None and self.shell.is_running():
1209 self.restart(focus=False)
1210 elif self.shell is None:
1211 self._update_title()
1213 def _update_title(self, note: str = ""):
1214 shell = Path(user_shell()).name
1215 folder = str(self.directory).replace(str(Path.home()), "~", 1)
1216 name = environment_name()
1217 env = f" · environment <b>{name}</b>" if name else ""
1218 self.title.setText(f"<b>{shell}</b> — {folder}{env}" + (f" <i>({note})</i>" if note else ""))
1219 self.title.setToolTip("The shell has exactly the environment VibeFPMGUI was started with "
1220 "(your shell's startup files aren't read, so nothing changes it).")
1222 def start(self):
1223 """Start the shell (if it isn't running)."""
1224 if self.shell is not None and self.shell.is_running():
1225 return
1226 view = self.view
1227 kind = WinptyShell if WINDOWS else PtyShell
1228 launch = shell_launch()
1229 self.shell = kind(launch, self.directory, view.screen.lines, view.screen.columns, self)
1230 self.shell.output.connect(view.feed)
1231 self.shell.exited.connect(self._exited)
1232 view.write = self.shell.write
1233 try:
1234 self.shell.start()
1235 except (OSError, ImportError) as exc:
1236 view.feed(f"Could not start {launch.argv[0]}: {exc}\r\n".encode())
1237 self.shell = None
1238 return
1239 self._update_title()
1241 def _resized(self, rows: int, cols: int):
1242 if self.shell is not None:
1243 self.shell.resize(rows, cols)
1245 def _exited(self):
1246 self._update_title("exited — Restart for a new shell")
1247 self.view.feed(b"\r\n[The shell has exited.]\r\n")
1249 def restart(self, focus: bool = True):
1250 """A new shell (in ``directory``), on a clean screen."""
1251 self.shutdown()
1252 self.view.feed(b"\x1bc") # (reset: a clean screen)
1253 self.view.screen.history.clear()
1254 self.start()
1255 if focus:
1256 self.focus()
1258 def focus(self):
1259 self.view.setFocus()
1261 def type_command(self, command: str):
1262 """Type ``command`` at the shell's prompt and press Return (starting
1263 the shell first if needed)."""
1264 self.start()
1265 if self.shell is not None: 1265 ↛ exitline 1265 didn't return from function 'type_command' because the condition on line 1265 was always true
1266 self.shell.write((command + "\r").encode())
1268 def showEvent(self, event):
1269 super().showEvent(event)
1270 QTimer.singleShot(0, self, self.start) # (the first time the tab is shown: after it's laid out)
1272 def refresh_colors(self):
1273 self.view.set_colors(*terminal_colors(QApplication.palette()))
1274 self.restart_button.setIcon(make_icon("refresh", QApplication.palette().color(QPalette.WindowText)))
1276 def set_font(self, font: QFont):
1277 self.view.set_terminal_font(font)
1279 def set_word_wrap(self, on: bool):
1280 """View → Word Wrap (see `TerminalView.set_word_wrap`)."""
1281 self.view.set_word_wrap(on)
1283 def shutdown(self):
1284 if self.shell is not None:
1285 self.shell.exited.disconnect()
1286 self.shell.stop()
1287 self.shell = None