vibefpmgui.terminal

The Terminal tab: your shell, in the project's folder, with exactly the environment VibeFPMGUI was started with.

The shell gets the app's environment variables as they are (only TERM is set, for the terminal, and a prompt: PS1) and is started without its startup files (zsh -f, bash --noprofile --norc, fish --no-config), which would change them: conda's "base" activation, Homebrew's or your own PATH changes. Started from an activated pixi or conda environment, the terminal is in that environment, with everything its activation set (PATH, CONDA_PREFIX, the compilers' variables...): fpm and the compilers are the ones the app uses. (So your prompt, aliases and other shell settings don't load; the prompt is a plain one, starting with the environment's name, e.g. (VibeFPMGUI) src %, as activation would show it.)

On macOS and Linux the shell runs in a pseudo-terminal read with a QSocketNotifier, so everything happens in the GUI thread; on Windows it runs through pywinpty (ConPTY), read in a thread.

The terminal is pyte (the screen: escape codes, colors, cursor) drawn by TerminalView, with scrollback, the alternate screen (full-screen programs: vim, less, htop) and reflow (long lines wrapped again when the terminal is resized) added in TerminalScreen. TerminalView handles the keys as xterm sends them (application cursor keys, function keys, modifiers), ⌘C/⌘V (macOS) or Ctrl+Shift+C/V (elsewhere) to copy and paste while Ctrl keys go to the shell, bracketed paste, selection, scrolling, and colors that follow light/dark mode.

   1"""The *Terminal* tab: your shell, in the project's folder, with exactly the
   2environment VibeFPMGUI was started with.
   3
   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.)
  14
  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.
  18
  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"""
  29
  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
  40
  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)
  65
  66from .ansi import DARK, LIGHT
  67from .icons import make_icon
  68
  69MAC = sys.platform == "darwin"
  70WINDOWS = sys.platform == "win32"
  71
  72
  73def user_shell() -> str:
  74    if WINDOWS:
  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"
  77
  78
  79class Launch(NamedTuple):
  80    argv: list[str]
  81    env: dict[str, str]
  82
  83
  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}
  92
  93
  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": "$ "}
  97
  98
  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:
 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)
 113
 114
 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", "")
 124
 125
 126# ------------------------------------------------------------ the shell
 127class PtyShell(QObject):
 128    """A shell in a pseudo-terminal (macOS, Linux), read in the GUI thread."""
 129
 130    output = Signal(bytes)
 131    exited = Signal()
 132
 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
 143
 144    def start(self):
 145        import pty
 146
 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
 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)
 166
 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()
 178
 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:
 183            return
 184        self._pending += data
 185        self._flush()
 186
 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)
 203
 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
 210
 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
 215
 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()
 237
 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):
 252                        os.kill(pid, signal.SIGKILL)
 253                        os.waitpid(pid, 0)
 254                except (ChildProcessError, ProcessLookupError):
 255                    pass
 256            QTimer.singleShot(1000, reap)
 257
 258    def is_running(self) -> bool:
 259        return self.fd >= 0
 260
 261
 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."""
 265
 266    output = Signal(bytes)
 267    exited = Signal()
 268
 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
 273
 274    def start(self):
 275        from winpty import PtyProcess
 276
 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()
 281
 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()
 293
 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
 300
 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
 308
 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
 316
 317    def is_running(self) -> bool:
 318        return self._process is not None and self._process.isalive()
 319
 320
 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)
 324
 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
 329
 330
 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
 335
 336
 337def _wrapped(line) -> bool:
 338    return getattr(line, "wrapped", False)
 339
 340
 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)."""
 347
 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)
 355
 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
 363
 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()
 368
 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
 377
 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()
 383
 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)
 391
 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)
 394            self.buffer[self.cursor.y].wrapped = False
 395        super().erase_in_line(how, private)
 396
 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)
 414
 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)
 446                    y, x = y - 1, columns
 447                cursor_pos = (start_row + y, x)
 448        while len(new_rows) <= cursor_pos[0]:
 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))
 465
 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)
 475
 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))
 490
 491    def write_process_input(self, data: str):
 492        self.reply(data.encode("utf-8"))
 493
 494
 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."""
 500
 501    def __init__(self, screen):
 502        self._screen = screen
 503
 504    def __getattr__(self, name):
 505        value = getattr(self._screen, name)
 506        if not callable(value):
 507            return value
 508
 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
 514
 515        return safe
 516
 517
 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
 525
 526
 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)
 533
 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
 545
 546
 547class TerminalView(QWidget):
 548    """Draws a `TerminalScreen` and sends keys to the shell (see the
 549    module's description)."""
 550
 551    size_changed = Signal(int, int)  # rows, columns
 552
 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)
 580
 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()
 587
 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()
 607
 608    def connect_scroll_bar(self, bar: QScrollBar):
 609        self.scroll_bar = bar
 610        bar.valueChanged.connect(self._scrolled)
 611        self._update_scroll_bar()
 612
 613    def connect_hscroll_bar(self, bar: QScrollBar):
 614        self.hscroll_bar = bar
 615        bar.valueChanged.connect(self._hscrolled)
 616        self._update_hscroll_bar()
 617
 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()
 629
 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()
 642
 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()
 654
 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)
 664
 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):
 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
 683
 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())
 690
 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)
 712                layout.invalidate()
 713                layout.activate()
 714        bar.setVisible(maximum > 0)
 715
 716    def _hscrolled(self, value: int):
 717        self._hscroll = value
 718        self.update()
 719
 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()
 725
 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()
 738
 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)
 749
 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()
 755
 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()
 766
 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()
 774
 775    # ------------------------------------------------------------ text
 776    def total_lines(self) -> int:
 777        return len(self.screen.history) + self.screen.lines
 778
 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)]
 783
 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()
 789
 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)
 796
 797    def selected_text(self) -> str:
 798        if not self._selection:
 799            return ""
 800        (l1, c1), (l2, c2) = sorted(self._selection)
 801        if l1 == l2:
 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)])
 805
 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)
 814
 815    # ---------------------------------------------------- Edit menu, clipboard
 816    def copy(self):
 817        text = self.selected_text()
 818        if text:
 819            QApplication.clipboard().setText(text)
 820
 821    def paste(self):
 822        text = QApplication.clipboard().text()
 823        if not text:
 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)
 829
 830    def cut(self):
 831        self.copy()
 832
 833    def selectAll(self):
 834        last = self.total_lines() - 1
 835        self._selection = ((0, 0), (last, self.screen.columns - 1))
 836        self.update()
 837
 838    # (These exist so the Edit menu doesn't reach past the terminal to the editor.)
 839    def undo(self):
 840        pass
 841
 842    def redo(self):
 843        pass
 844
 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
 852
 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)
 861
 862    def focusNextPrevChild(self, _next):
 863        return False  # Tab goes to the shell (completion), not to the next widget
 864
 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)
 872
 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)
 878
 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):
 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:
 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:
 902            return b"\x1b"
 903        if key == Qt.Key_Tab:
 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
 918
 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
 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):
 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
 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()
 940
 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
 949
 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)
 957
 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()
 962
 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)
 970
 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)
 975
 976        def in_word(c):
 977            return c.isalnum() or c in "_-./~:@%+"
 978
 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()
 987
 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:
 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)
 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:
1004            return
1005        if not self.screen.alternate:
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))
1015
1016    def contextMenuEvent(self, event):
1017        from qtpy.QtWidgets import QMenu
1018
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())
1027
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)
1041
1042    def resizeEvent(self, event):
1043        super().resizeEvent(event)
1044        self._fit()
1045        self._update_hscroll_bar()
1046
1047    def focusInEvent(self, event):
1048        super().focusInEvent(event)
1049        self.update()
1050
1051    def focusOutEvent(self, event):
1052        super().focusOutEvent(event)
1053        self.update()
1054
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:
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
1068
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:
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)
1108
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:
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:
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)
1138
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():
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))
1159
1160
1161class TerminalPanel(QWidget):
1162    """The *Terminal* tab: a header (shell, folder, environment, Restart)
1163    over the terminal."""
1164
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()
1200
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()
1212
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).")
1221
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()
1240
1241    def _resized(self, rows: int, cols: int):
1242        if self.shell is not None:
1243            self.shell.resize(rows, cols)
1244
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")
1248
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()
1257
1258    def focus(self):
1259        self.view.setFocus()
1260
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:
1266            self.shell.write((command + "\r").encode())
1267
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)
1271
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)))
1275
1276    def set_font(self, font: QFont):
1277        self.view.set_terminal_font(font)
1278
1279    def set_word_wrap(self, on: bool):
1280        """View → Word Wrap (see `TerminalView.set_word_wrap`)."""
1281        self.view.set_word_wrap(on)
1282
1283    def shutdown(self):
1284        if self.shell is not None:
1285            self.shell.exited.disconnect()
1286            self.shell.stop()
1287            self.shell = None
MAC = False
WINDOWS = False
def user_shell() -> str:
74def user_shell() -> str:
75    if WINDOWS:
76        return os.environ.get("COMSPEC", "cmd.exe")
77    return os.environ.get("SHELL") or shutil.which("zsh") or shutil.which("bash") or "/bin/sh"
class Launch(typing.NamedTuple):
80class Launch(NamedTuple):
81    argv: list[str]
82    env: dict[str, str]

Launch(argv, env)

Launch(argv: list[str], env: dict[str, str])

Create new instance of Launch(argv, env)

argv: list[str]

Alias for field number 0

env: dict[str, str]

Alias for field number 1

NO_STARTUP_FILES = {'zsh': ['-f'], 'bash': ['--noprofile', '--norc'], 'fish': ['--no-config'], 'sh': [], 'dash': []}
PROMPTS = {'zsh': '%1~ %# ', 'bash': '\\W \\$ ', 'sh': '$ ', 'dash': '$ '}
def shell_launch() -> Launch:
100def shell_launch() -> Launch:
101    """How to start the user's shell with exactly the app's environment:
102    the same variables (only TERM is set, for the terminal, and PS1, the
103    prompt), and no startup files to change them."""
104    shell = user_shell()
105    env = dict(os.environ)
106    env["TERM"] = "xterm-256color"
107    if WINDOWS:
108        return Launch([shell], env)
109    name = Path(shell).name
110    if name in PROMPTS and "PS1" not in env:  # (fish has its own prompt function)
111        active = environment_name()
112        env["PS1"] = (f"({active}) " if active else "") + PROMPTS[name]
113    return Launch([shell, *NO_STARTUP_FILES.get(name, []), "-i"], env)

How to start the user's shell with exactly the app's environment: the same variables (only TERM is set, for the terminal, and PS1, the prompt), and no startup files to change them.

def environment_name() -> str:
116def environment_name() -> str:
117    """The activated environment the app (and the terminal) runs in, if any:
118    a pixi workspace's name (plus its environment, when not "default"), or a
119    conda environment's."""
120    pixi = os.environ.get("PIXI_PROJECT_NAME", "")
121    if pixi:
122        which = os.environ.get("PIXI_ENVIRONMENT_NAME", "default")
123        return pixi if which in ("", "default") else f"{pixi}:{which}"
124    return os.environ.get("CONDA_DEFAULT_ENV", "")

The activated environment the app (and the terminal) runs in, if any: a pixi workspace's name (plus its environment, when not "default"), or a conda environment's.

class PtyShell(PySide6.QtCore.QObject):
128class PtyShell(QObject):
129    """A shell in a pseudo-terminal (macOS, Linux), read in the GUI thread."""
130
131    output = Signal(bytes)
132    exited = Signal()
133
134    def __init__(self, launch: Launch, cwd: Path, rows: int, cols: int, parent=None):
135        super().__init__(parent)
136        self.launch, self.cwd, self.rows, self.cols = launch, cwd, rows, cols
137        self.pid = -1
138        self.fd = -1
139        self._notifier: QSocketNotifier | None = None
140        # Input the shell hasn't taken yet (it isn't reading: a long command,
141        # a big paste); sent when the pty can take more (see _flush).
142        self._pending = bytearray()
143        self._write_notifier: QSocketNotifier | None = None
144
145    def start(self):
146        import pty
147
148        argv, env, cwd = self.launch.argv, self.launch.env, str(self.cwd)
149        program = shutil.which(argv[0]) or argv[0]
150        with warnings.catch_warnings():  # (the child only execs: fork's thread caveat doesn't apply)
151            warnings.simplefilter("ignore", DeprecationWarning)
152            pid, fd = pty.fork()
153        if pid == 0:  # the child: become the shell
154            try:
155                os.chdir(cwd)
156            except OSError:
157                pass
158            try:
159                os.execve(program, argv, env)
160            finally:
161                os._exit(127)
162        self.pid, self.fd = pid, fd
163        os.set_blocking(fd, False)
164        self.resize(self.rows, self.cols)
165        self._notifier = QSocketNotifier(fd, QSocketNotifier.Read, self)
166        self._notifier.activated.connect(self._read)
167
168    def _read(self, *_):
169        try:
170            data = os.read(self.fd, 65536)
171        except BlockingIOError:
172            return
173        except OSError:  # (Linux: EIO once the shell has exited)
174            data = b""
175        if data:
176            self.output.emit(data)
177        else:
178            self._finish()
179
180    def write(self, data: bytes):
181        """Send input to the shell, now or (what doesn't fit yet) once the
182        pty can take more: never waiting for it in the GUI thread."""
183        if self.fd < 0:
184            return
185        self._pending += data
186        self._flush()
187
188    def _flush(self, *_):
189        while self._pending and self.fd >= 0:
190            try:
191                written = os.write(self.fd, self._pending)
192            except BlockingIOError:
193                break  # (full: the rest when it's writable again)
194            except OSError:
195                self._pending.clear()
196                break
197            del self._pending[:written]
198        waiting = bool(self._pending) and self.fd >= 0
199        if waiting and self._write_notifier is None:
200            self._write_notifier = QSocketNotifier(self.fd, QSocketNotifier.Write, self)
201            self._write_notifier.activated.connect(self._flush)
202        if self._write_notifier is not None:
203            self._write_notifier.setEnabled(waiting)
204
205    def resize(self, rows: int, cols: int):
206        self.rows, self.cols = rows, cols
207        if self.fd < 0 or rows <= 0 or cols <= 0:
208            return
209        import fcntl
210        import termios
211
212        try:  # (the shell gets SIGWINCH from the kernel)
213            fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
214        except OSError:
215            pass
216
217    def _finish(self):
218        if self._notifier is not None:
219            self._notifier.setEnabled(False)
220            self._notifier = None
221        if self._write_notifier is not None:
222            self._write_notifier.setEnabled(False)
223            self._write_notifier = None
224        self._pending.clear()
225        if self.fd >= 0:
226            try:
227                os.close(self.fd)
228            except OSError:
229                pass
230            self.fd = -1
231        if self.pid > 0:
232            try:
233                os.waitpid(self.pid, os.WNOHANG)
234            except ChildProcessError:
235                pass
236            self.pid = -1
237            self.exited.emit()
238
239    def stop(self):
240        """End the shell (and what it runs), e.g. when the window closes."""
241        pid = self.pid
242        if pid > 0:
243            for sig in (signal.SIGHUP, signal.SIGCONT):
244                try:
245                    os.kill(pid, sig)
246                except ProcessLookupError:
247                    break
248        self._finish()
249        if pid > 0:
250            def reap():  # (no zombie: force it if SIGHUP was ignored)
251                try:
252                    if os.waitpid(pid, os.WNOHANG) == (0, 0):
253                        os.kill(pid, signal.SIGKILL)
254                        os.waitpid(pid, 0)
255                except (ChildProcessError, ProcessLookupError):
256                    pass
257            QTimer.singleShot(1000, reap)
258
259    def is_running(self) -> bool:
260        return self.fd >= 0

A shell in a pseudo-terminal (macOS, Linux), read in the GUI thread.

PtyShell( launch: Launch, cwd: pathlib.Path, rows: int, cols: int, parent=None)
134    def __init__(self, launch: Launch, cwd: Path, rows: int, cols: int, parent=None):
135        super().__init__(parent)
136        self.launch, self.cwd, self.rows, self.cols = launch, cwd, rows, cols
137        self.pid = -1
138        self.fd = -1
139        self._notifier: QSocketNotifier | None = None
140        # Input the shell hasn't taken yet (it isn't reading: a long command,
141        # a big paste); sent when the pty can take more (see _flush).
142        self._pending = bytearray()
143        self._write_notifier: QSocketNotifier | None = None

__init__(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def output(unknown):

The type of the None singleton.

def exited(unknown):

The type of the None singleton.

pid
fd
def start(self):
145    def start(self):
146        import pty
147
148        argv, env, cwd = self.launch.argv, self.launch.env, str(self.cwd)
149        program = shutil.which(argv[0]) or argv[0]
150        with warnings.catch_warnings():  # (the child only execs: fork's thread caveat doesn't apply)
151            warnings.simplefilter("ignore", DeprecationWarning)
152            pid, fd = pty.fork()
153        if pid == 0:  # the child: become the shell
154            try:
155                os.chdir(cwd)
156            except OSError:
157                pass
158            try:
159                os.execve(program, argv, env)
160            finally:
161                os._exit(127)
162        self.pid, self.fd = pid, fd
163        os.set_blocking(fd, False)
164        self.resize(self.rows, self.cols)
165        self._notifier = QSocketNotifier(fd, QSocketNotifier.Read, self)
166        self._notifier.activated.connect(self._read)
def write(self, data: bytes):
180    def write(self, data: bytes):
181        """Send input to the shell, now or (what doesn't fit yet) once the
182        pty can take more: never waiting for it in the GUI thread."""
183        if self.fd < 0:
184            return
185        self._pending += data
186        self._flush()

Send input to the shell, now or (what doesn't fit yet) once the pty can take more: never waiting for it in the GUI thread.

def resize(self, rows: int, cols: int):
205    def resize(self, rows: int, cols: int):
206        self.rows, self.cols = rows, cols
207        if self.fd < 0 or rows <= 0 or cols <= 0:
208            return
209        import fcntl
210        import termios
211
212        try:  # (the shell gets SIGWINCH from the kernel)
213            fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
214        except OSError:
215            pass
def stop(self):
239    def stop(self):
240        """End the shell (and what it runs), e.g. when the window closes."""
241        pid = self.pid
242        if pid > 0:
243            for sig in (signal.SIGHUP, signal.SIGCONT):
244                try:
245                    os.kill(pid, sig)
246                except ProcessLookupError:
247                    break
248        self._finish()
249        if pid > 0:
250            def reap():  # (no zombie: force it if SIGHUP was ignored)
251                try:
252                    if os.waitpid(pid, os.WNOHANG) == (0, 0):
253                        os.kill(pid, signal.SIGKILL)
254                        os.waitpid(pid, 0)
255                except (ChildProcessError, ProcessLookupError):
256                    pass
257            QTimer.singleShot(1000, reap)

End the shell (and what it runs), e.g. when the window closes.

def is_running(self) -> bool:
259    def is_running(self) -> bool:
260        return self.fd >= 0
staticMetaObject = PySide6.QtCore.QMetaObject("PtyShell" inherits "QObject": Methods: #4 type=Signal, signature=output(PyObject), parameters=PyObject #5 type=Signal, signature=exited() )
class WinptyShell(PySide6.QtCore.QObject):
263class WinptyShell(QObject):
264    """A shell through pywinpty (Windows ConPTY). Its reads block, so they
265    run in a thread; the output comes back to the GUI thread as a signal."""
266
267    output = Signal(bytes)
268    exited = Signal()
269
270    def __init__(self, launch: Launch, cwd: Path, rows: int, cols: int, parent=None):
271        super().__init__(parent)
272        self.launch, self.cwd, self.rows, self.cols = launch, cwd, rows, cols
273        self._process = None
274
275    def start(self):
276        from winpty import PtyProcess
277
278        self._process = PtyProcess.spawn(self.launch.argv, cwd=str(self.cwd), env=self.launch.env,
279                                         dimensions=(self.rows, self.cols))
280        threading.Thread(target=self._read_loop, args=(self._process,), daemon=True,
281                         name="Terminal output").start()
282
283    def _read_loop(self, process):
284        try:
285            while True:
286                data = process.read(65536)
287                if not data and not process.isalive():
288                    break
289                if data:
290                    self.output.emit(data.encode("utf-8") if isinstance(data, str) else data)
291        except (EOFError, OSError):
292            pass
293        self.exited.emit()
294
295    def write(self, data: bytes):
296        if self._process is not None:
297            try:
298                self._process.write(data.decode("utf-8", errors="replace"))
299            except (EOFError, OSError):
300                pass
301
302    def resize(self, rows: int, cols: int):
303        self.rows, self.cols = rows, cols
304        if self._process is not None and rows > 0 and cols > 0:
305            try:
306                self._process.setwinsize(rows, cols)
307            except (EOFError, OSError):
308                pass
309
310    def stop(self):
311        if self._process is not None:
312            try:
313                self._process.terminate(force=True)
314            except (EOFError, OSError):
315                pass
316            self._process = None
317
318    def is_running(self) -> bool:
319        return self._process is not None and self._process.isalive()

A shell through pywinpty (Windows ConPTY). Its reads block, so they run in a thread; the output comes back to the GUI thread as a signal.

WinptyShell( launch: Launch, cwd: pathlib.Path, rows: int, cols: int, parent=None)
270    def __init__(self, launch: Launch, cwd: Path, rows: int, cols: int, parent=None):
271        super().__init__(parent)
272        self.launch, self.cwd, self.rows, self.cols = launch, cwd, rows, cols
273        self._process = None

__init__(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def output(unknown):

The type of the None singleton.

def exited(unknown):

The type of the None singleton.

def start(self):
275    def start(self):
276        from winpty import PtyProcess
277
278        self._process = PtyProcess.spawn(self.launch.argv, cwd=str(self.cwd), env=self.launch.env,
279                                         dimensions=(self.rows, self.cols))
280        threading.Thread(target=self._read_loop, args=(self._process,), daemon=True,
281                         name="Terminal output").start()
def write(self, data: bytes):
295    def write(self, data: bytes):
296        if self._process is not None:
297            try:
298                self._process.write(data.decode("utf-8", errors="replace"))
299            except (EOFError, OSError):
300                pass
def resize(self, rows: int, cols: int):
302    def resize(self, rows: int, cols: int):
303        self.rows, self.cols = rows, cols
304        if self._process is not None and rows > 0 and cols > 0:
305            try:
306                self._process.setwinsize(rows, cols)
307            except (EOFError, OSError):
308                pass
def stop(self):
310    def stop(self):
311        if self._process is not None:
312            try:
313                self._process.terminate(force=True)
314            except (EOFError, OSError):
315                pass
316            self._process = None
def is_running(self) -> bool:
318    def is_running(self) -> bool:
319        return self._process is not None and self._process.isalive()
staticMetaObject = PySide6.QtCore.QMetaObject("WinptyShell" inherits "QObject": Methods: #4 type=Signal, signature=output(PyObject), parameters=PyObject #5 type=Signal, signature=exited() )
SCROLLBACK = 10000
NO_WRAP_COLUMNS = 300
DECCKM = 32
ALT_SCREEN = {33568, 33504, 1504}
BRACKETED_PASTE = 64128
class Row(builtins.list):
332class Row(list):
333    """A line of the scrollback: its Chars (trailing blanks dropped, unless
334    it was wrapped), and whether it was wrapped (the next row continues it)."""
335    wrapped = False

A line of the scrollback: its Chars (trailing blanks dropped, unless it was wrapped), and whether it was wrapped (the next row continues it).

wrapped = False
class TerminalScreen(pyte.screens.Screen):
342class TerminalScreen(pyte.Screen):
343    """pyte's screen, with what pyte lacks: scrollback (the lines that
344    scroll off the top), the alternate screen full-screen programs draw on,
345    and reflow: lines pyte wrapped at the right edge are marked, and joined
346    and wrapped again at the new width when the terminal is resized (pyte
347    would cut them off, or leave them wrapped short)."""
348
349    def __init__(self, columns: int, lines: int):
350        self.history: deque = deque(maxlen=SCROLLBACK)  # Rows above the screen
351        self.alternate = False
352        self._saved_main = None
353        self._drawing = False
354        self.reply = lambda data: None  # answers to the terminal's queries go to the shell
355        super().__init__(columns, lines)
356
357    # ------------------------------------------------------- wrapped lines
358    def draw(self, data):
359        self._drawing = True  # (a line feed now is pyte wrapping at the right edge)
360        try:
361            super().draw(data)
362        finally:
363            self._drawing = False
364
365    def linefeed(self):
366        # Mark the row being left: wrapped (it continues on the next) or not.
367        self.buffer[self.cursor.y].wrapped = self._drawing
368        super().linefeed()
369
370    def _row(self, y: int) -> Row:
371        line = self.buffer[y]
372        row = Row(line[x] for x in range(self.columns))
373        row.wrapped = _wrapped(line)
374        if not row.wrapped:
375            while row and row[-1] == self.default_char:
376                row.pop()
377        return row
378
379    def index(self):
380        top, bottom = self.margins or pyte.screens.Margins(0, self.lines - 1)
381        if self.cursor.y == bottom and top == 0 and not self.alternate:
382            self.history.append(self._row(0))
383        super().index()
384
385    def erase_in_display(self, how=0, *args, **kwargs):
386        if how == 3:  # ("clear" also clears the scrollback)
387            self.history.clear()
388        rows = {0: range(self.cursor.y, self.lines), 1: range(self.cursor.y)}.get(how, range(self.lines))
389        for y in rows:
390            self.buffer[y].wrapped = False
391        super().erase_in_display(how, *args, **kwargs)
392
393    def erase_in_line(self, how=0, private=False):
394        if how in (0, 2):  # (the end of the line is gone: it no longer continues)
395            self.buffer[self.cursor.y].wrapped = False
396        super().erase_in_line(how, private)
397
398    # -------------------------------------------------------------- resize
399    def resize(self, lines=None, columns=None):
400        lines, columns = lines or self.lines, columns or self.columns
401        if (lines, columns) == (self.lines, self.columns):
402            return
403        if columns != self.columns:
404            # Tab stops every 8 columns across the new width (pyte sets them
405            # once, for the first width: a tab past it went to the last column).
406            self.tabstops = {x for x in self.tabstops if x < columns}
407            first_new = (min(self.columns, columns) + 7) // 8 * 8  # (the old ones stopped before the old width)
408            self.tabstops.update(range(max(8, first_new), columns, 8))
409        if self.alternate:  # (full-screen programs redraw themselves at the new size)
410            super().resize(lines, columns)
411            self.cursor.y = min(self.cursor.y, self.lines - 1)
412            self.cursor.x = min(self.cursor.x, self.columns - 1)
413            return
414        self._reflow(lines, columns)
415
416    def _reflow(self, lines: int, columns: int):
417        """Join the wrapped rows (scrollback and screen) into whole lines and
418        wrap them again at the new width, keeping the cursor on its character
419        and the bottom of the output at the bottom of the screen."""
420        last = max([self.cursor.y] + [y for y in range(self.lines) if self._row(y)])
421        rows = list(self.history) + [self._row(y) for y in range(last + 1)]
422        cursor_row = len(self.history) + self.cursor.y
423        # Whole lines (lists of Chars); where the cursor is in them.
424        logical, cursor_at = [], (0, 0)
425        current: list = []
426        for n, row in enumerate(rows):
427            if n == cursor_row:
428                cursor_at = (len(logical), len(current) + self.cursor.x)
429            current.extend(row)
430            if not row.wrapped or n == len(rows) - 1:
431                logical.append(current)
432                current = []
433        # Wrap them at the new width.
434        new_rows: list[Row] = []
435        cursor_pos = (0, 0)
436        for index, cells in enumerate(logical):
437            start_row = len(new_rows)
438            pieces = [cells[i:i + columns] for i in range(0, len(cells), columns)] or [[]]
439            for k, piece in enumerate(pieces):
440                row = Row(piece)
441                row.wrapped = k < len(pieces) - 1
442                new_rows.append(row)
443            if index == cursor_at[0]:
444                offset = cursor_at[1]
445                y, x = divmod(offset, columns)
446                if y >= len(pieces) and x == 0 and offset:  # (just past a full row: stay at its end)
447                    y, x = y - 1, columns
448                cursor_pos = (start_row + y, x)
449        while len(new_rows) <= cursor_pos[0]:
450            new_rows.append(Row())
451        top = max(0, len(new_rows) - lines, cursor_pos[0] - lines + 1)
452        top = min(top, cursor_pos[0])
453        self.history.clear()
454        self.history.extend(new_rows[:top])
455        self.buffer.clear()
456        for y, row in enumerate(new_rows[top:top + lines]):
457            line = self.buffer[y]
458            for x, char in enumerate(row):
459                line[x] = char
460            line.wrapped = row.wrapped
461        self.lines, self.columns = lines, columns
462        self.set_margins()
463        self.cursor.y = cursor_pos[0] - top
464        self.cursor.x = min(cursor_pos[1], columns)
465        self.dirty.update(range(lines))
466
467    # ---------------------------------------------------- alternate screen
468    def set_mode(self, *modes, **kwargs):
469        if kwargs.get("private") and not self.alternate and {m << 5 for m in modes} & ALT_SCREEN:
470            self._saved_main = (dict(self.buffer), self.cursor.x, self.cursor.y, self.cursor.attrs,
471                                self.lines, self.columns)
472            self.alternate = True
473            self.buffer.clear()
474            self.dirty.update(range(self.lines))
475        super().set_mode(*modes, **kwargs)
476
477    def reset_mode(self, *modes, **kwargs):
478        super().reset_mode(*modes, **kwargs)
479        if kwargs.get("private") and self.alternate and {m << 5 for m in modes} & ALT_SCREEN:
480            buffer, x, y, attrs, lines, columns = self._saved_main
481            self.alternate = False
482            new_size = self.lines, self.columns
483            # Back to the main screen as it was, then reflowed if the size changed meanwhile.
484            self.buffer.clear()
485            self.buffer.update(buffer)
486            self.lines, self.columns = lines, columns
487            self.cursor.x, self.cursor.y, self.cursor.attrs = x, y, attrs
488            self.set_margins()
489            self.resize(*new_size)
490            self.dirty.update(range(self.lines))
491
492    def write_process_input(self, data: str):
493        self.reply(data.encode("utf-8"))

pyte's screen, with what pyte lacks: scrollback (the lines that scroll off the top), the alternate screen full-screen programs draw on, and reflow: lines pyte wrapped at the right edge are marked, and joined and wrapped again at the new width when the terminal is resized (pyte would cut them off, or leave them wrapped short).

TerminalScreen(columns: int, lines: int)
349    def __init__(self, columns: int, lines: int):
350        self.history: deque = deque(maxlen=SCROLLBACK)  # Rows above the screen
351        self.alternate = False
352        self._saved_main = None
353        self._drawing = False
354        self.reply = lambda data: None  # answers to the terminal's queries go to the shell
355        super().__init__(columns, lines)
history: collections.deque
alternate
reply
def draw(self, data):
358    def draw(self, data):
359        self._drawing = True  # (a line feed now is pyte wrapping at the right edge)
360        try:
361            super().draw(data)
362        finally:
363            self._drawing = False

Display decoded characters at the current cursor position and advances the cursor if ~pyte.modes.DECAWM is set.

Parameters
  • str data: text to display.

Changed in version 0.5.0: Character width is taken into account. Specifically, zero-width and unprintable characters do not affect screen state. Full-width characters are rendered into two consecutive character containers.

def linefeed(self):
365    def linefeed(self):
366        # Mark the row being left: wrapped (it continues on the next) or not.
367        self.buffer[self.cursor.y].wrapped = self._drawing
368        super().linefeed()

Perform an index and, if ~pyte.modes.LNM is set, a carriage return.

def index(self):
379    def index(self):
380        top, bottom = self.margins or pyte.screens.Margins(0, self.lines - 1)
381        if self.cursor.y == bottom and top == 0 and not self.alternate:
382            self.history.append(self._row(0))
383        super().index()

Move the cursor down one line in the same column. If the cursor is at the last line, create a new line at the bottom.

def erase_in_display(self, how=0, *args, **kwargs):
385    def erase_in_display(self, how=0, *args, **kwargs):
386        if how == 3:  # ("clear" also clears the scrollback)
387            self.history.clear()
388        rows = {0: range(self.cursor.y, self.lines), 1: range(self.cursor.y)}.get(how, range(self.lines))
389        for y in rows:
390            self.buffer[y].wrapped = False
391        super().erase_in_display(how, *args, **kwargs)

Erases display in a specific way.

Character attributes are set to cursor attributes.

Parameters
  • int how: defines the way the line should be erased in:

    • 0 -- Erases from cursor to end of screen, including cursor position.
    • 1 -- Erases from beginning of screen to cursor, including cursor position.
    • 2 and 3 -- Erases complete display. All lines are erased and changed to single-width. Cursor does not move.
  • bool private: when True only characters marked as erasable are affected not implemented.

Changed in version 0.8.1: The method accepts any number of positional arguments as some clear implementations include a ; after the first parameter causing the stream to assume a 0 second parameter.

def erase_in_line(self, how=0, private=False):
393    def erase_in_line(self, how=0, private=False):
394        if how in (0, 2):  # (the end of the line is gone: it no longer continues)
395            self.buffer[self.cursor.y].wrapped = False
396        super().erase_in_line(how, private)

Erase a line in a specific way.

Character attributes are set to cursor attributes.

Parameters
  • int how: defines the way the line should be erased in:

    • 0 -- Erases from cursor to end of line, including cursor position.
    • 1 -- Erases from beginning of line to cursor, including cursor position.
    • 2 -- Erases complete line.
  • bool private: when True only characters marked as erasable are affected not implemented.
def resize(self, lines=None, columns=None):
399    def resize(self, lines=None, columns=None):
400        lines, columns = lines or self.lines, columns or self.columns
401        if (lines, columns) == (self.lines, self.columns):
402            return
403        if columns != self.columns:
404            # Tab stops every 8 columns across the new width (pyte sets them
405            # once, for the first width: a tab past it went to the last column).
406            self.tabstops = {x for x in self.tabstops if x < columns}
407            first_new = (min(self.columns, columns) + 7) // 8 * 8  # (the old ones stopped before the old width)
408            self.tabstops.update(range(max(8, first_new), columns, 8))
409        if self.alternate:  # (full-screen programs redraw themselves at the new size)
410            super().resize(lines, columns)
411            self.cursor.y = min(self.cursor.y, self.lines - 1)
412            self.cursor.x = min(self.cursor.x, self.columns - 1)
413            return
414        self._reflow(lines, columns)

Resize the screen to the given size.

If the requested screen size has more lines than the existing screen, lines will be added at the bottom. If the requested size has less lines than the existing screen lines will be clipped at the top of the screen. Similarly, if the existing screen has less columns than the requested screen, columns will be added at the right, and if it has more -- columns will be clipped at the right.

Parameters
  • int lines: number of lines in the new screen.
  • int columns: number of columns in the new screen.

Changed in version 0.7.0: If the requested screen size is identical to the current screen size, the method does nothing.

def set_mode(self, *modes, **kwargs):
468    def set_mode(self, *modes, **kwargs):
469        if kwargs.get("private") and not self.alternate and {m << 5 for m in modes} & ALT_SCREEN:
470            self._saved_main = (dict(self.buffer), self.cursor.x, self.cursor.y, self.cursor.attrs,
471                                self.lines, self.columns)
472            self.alternate = True
473            self.buffer.clear()
474            self.dirty.update(range(self.lines))
475        super().set_mode(*modes, **kwargs)

Set (enable) a given list of modes.

Parameters
  • list modes: modes to set, where each mode is a constant from pyte.modes.
def reset_mode(self, *modes, **kwargs):
477    def reset_mode(self, *modes, **kwargs):
478        super().reset_mode(*modes, **kwargs)
479        if kwargs.get("private") and self.alternate and {m << 5 for m in modes} & ALT_SCREEN:
480            buffer, x, y, attrs, lines, columns = self._saved_main
481            self.alternate = False
482            new_size = self.lines, self.columns
483            # Back to the main screen as it was, then reflowed if the size changed meanwhile.
484            self.buffer.clear()
485            self.buffer.update(buffer)
486            self.lines, self.columns = lines, columns
487            self.cursor.x, self.cursor.y, self.cursor.attrs = x, y, attrs
488            self.set_margins()
489            self.resize(*new_size)
490            self.dirty.update(range(self.lines))

Reset (disable) a given list of modes.

Parameters
  • list modes: modes to reset -- hopefully, each mode is a constant from pyte.modes.
def write_process_input(self, data: str):
492    def write_process_input(self, data: str):
493        self.reply(data.encode("utf-8"))

Write data to the process running inside the terminal.

By default is a noop.

Parameters
  • str data: text to write to the process stdin.

New in version 0.5.0.

class SafeScreen:
496class SafeScreen:
497    """The screen as pyte's parser sees it. A sequence pyte can't handle
498    (e.g. vim's "\\e[>4;2m", which pyte passes on with private=True to a
499    method that doesn't take it) is dropped: an error inside pyte's parser
500    would end it for good, and the terminal would show nothing more."""
501
502    def __init__(self, screen):
503        self._screen = screen
504
505    def __getattr__(self, name):
506        value = getattr(self._screen, name)
507        if not callable(value):
508            return value
509
510        def safe(*args, **kwargs):
511            try:
512                return value(*args, **kwargs)
513            except Exception:  # noqa: BLE001 - one odd sequence must not stop the terminal
514                return None
515
516        return safe

The screen as pyte's parser sees it. A sequence pyte can't handle (e.g. vim's "\e[>4;2m", which pyte passes on with private=True to a method that doesn't take it) is dropped: an error inside pyte's parser would end it for good, and the terminal would show nothing more.

SafeScreen(screen)
502    def __init__(self, screen):
503        self._screen = screen
def terminal_colors( palette: PySide6.QtGui.QPalette) -> tuple[PySide6.QtGui.QColor, PySide6.QtGui.QColor, list[str]]:
520def terminal_colors(palette: QPalette) -> tuple[QColor, QColor, list[str]]:
521    """(background, text, the 16 ANSI colors) for the terminal: dark or
522    light like the app (the ANSI colors are the Output panel's)."""
523    if palette.color(QPalette.Base).lightness() < 128:
524        return QColor("#1e1e1e"), QColor("#d4d4d4"), DARK
525    return QColor("#ffffff"), QColor("#1f1f1f"), LIGHT

(background, text, the 16 ANSI colors) for the terminal: dark or light like the app (the ANSI colors are the Output panel's).

ANSI_NAMES = {'black': 0, 'red': 1, 'green': 2, 'brown': 3, 'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7, 'brightblack': 8, 'brightred': 9, 'brightgreen': 10, 'brightbrown': 11, 'brightblue': 12, 'brightmagenta': 13, 'brightcyan': 14, 'brightwhite': 15, 'yellow': 3, 'brightyellow': 11}
ANSI_HEX = {'000000': 0, 'cd0000': 1, '00cd00': 2, 'cdcd00': 3, '0000ee': 4, 'cd00cd': 5, '00cdcd': 6, 'e5e5e5': 7, '7f7f7f': 8, 'ff0000': 9, '00ff00': 10, 'ffff00': 11, '5c5cff': 12, 'ff00ff': 13, '00ffff': 14, 'ffffff': 15}
CURSOR_KEYS = {<Key.Key_Up: 16777235>: 'A', <Key.Key_Down: 16777237>: 'B', <Key.Key_Right: 16777236>: 'C', <Key.Key_Left: 16777234>: 'D', <Key.Key_Home: 16777232>: 'H', <Key.Key_End: 16777233>: 'F'}
TILDE_KEYS = {<Key.Key_Insert: 16777222>: 2, <Key.Key_Delete: 16777223>: 3, <Key.Key_PageUp: 16777238>: 5, <Key.Key_PageDown: 16777239>: 6, <Key.Key_F5: 16777268>: 15, <Key.Key_F6: 16777269>: 17, <Key.Key_F7: 16777270>: 18, <Key.Key_F8: 16777271>: 19, <Key.Key_F9: 16777272>: 20, <Key.Key_F10: 16777273>: 21, <Key.Key_F11: 16777274>: 23, <Key.Key_F12: 16777275>: 24}
SS3_KEYS = {<Key.Key_F1: 16777264>: 'P', <Key.Key_F2: 16777265>: 'Q', <Key.Key_F3: 16777266>: 'R', <Key.Key_F4: 16777267>: 'S'}
COMMAND = <KeyboardModifier.NoModifier: 0>
CONTROL = <KeyboardModifier.ControlModifier: 67108864>
PADDING = 4
class TerminalView(PySide6.QtWidgets.QWidget):
 548class TerminalView(QWidget):
 549    """Draws a `TerminalScreen` and sends keys to the shell (see the
 550    module's description)."""
 551
 552    size_changed = Signal(int, int)  # rows, columns
 553
 554    def __init__(self, parent=None):
 555        super().__init__(parent)
 556        self.setFocusPolicy(Qt.StrongFocus)
 557        self.setAttribute(Qt.WA_OpaquePaintEvent)
 558        self.setCursor(Qt.IBeamCursor)
 559        self.setMinimumSize(80, 40)
 560        self.write = lambda data: None  # bytes to the shell (set by the panel)
 561        self.screen = TerminalScreen(80, 24)
 562        self.screen.reply = lambda data: self.write(data)
 563        self.stream = pyte.ByteStream(SafeScreen(self.screen))
 564        self.scroll_bar: QScrollBar | None = None
 565        self.hscroll_bar: QScrollBar | None = None
 566        self.word_wrap = True       # View → Word Wrap (off: a wide terminal, scrolled sideways)
 567        self._hscroll = 0           # pixels scrolled to the right (without word wrap)
 568        self._scroll = 0            # lines scrolled up from the bottom (0: following the output)
 569        self._scroll_px = 0.0       # and pixels more, into the line above (trackpad: smooth, see scroll_pixels)
 570        self._selection = None      # ((line, column), (line, column)) in all lines (scrollback + screen)
 571        self._colors: dict[str, QColor] = {}
 572        self.set_colors(QColor("#1e1e1e"), QColor("#d4d4d4"), DARK)
 573        self.set_terminal_font(QFont("Menlo" if MAC else "Monospace"))
 574        self._redraw_all = True     # (else only the rows the output changed: see _after_output)
 575        self._wheel_rest = 0.0      # part of a line scrolled, not yet moved (see wheelEvent)
 576        self._cursor_drawn_at = 0   # the screen row the cursor was last drawn on
 577        self._repaint_timer = QTimer(self)  # (output in bursts: draw at most every few ms)
 578        self._repaint_timer.setSingleShot(True)
 579        self._repaint_timer.setInterval(10)
 580        self._repaint_timer.timeout.connect(self._after_output)
 581
 582    # ------------------------------------------------------------ setup
 583    def set_colors(self, background: QColor, text: QColor, ansi: list[str]):
 584        self.background, self.foreground = QColor(background), QColor(text)
 585        self.ansi = [QColor(c) for c in ansi]
 586        self._colors.clear()
 587        self.update()
 588
 589    def set_terminal_font(self, font: QFont):
 590        # Family and size set explicitly: a size left unset would be measured
 591        # at the application's default size but drawn at the widget's.
 592        size = font.pointSizeF() if font.pointSizeF() > 0 else QApplication.font().pointSizeF()
 593        font = QFont(font)
 594        font.setFamily(font.family())
 595        font.setPointSizeF(size)
 596        font.setStyleHint(QFont.Monospace)
 597        font.setFixedPitch(True)
 598        font.setKerning(False)
 599        self.font_normal = font
 600        self.font_bold = QFont(font)
 601        self.font_bold.setBold(True)
 602        metrics = QFontMetricsF(font, self)  # (as drawn on this widget: its screen's resolution)
 603        self.cell_width = metrics.horizontalAdvance("M")
 604        self.cell_height = metrics.lineSpacing()
 605        self.ascent = metrics.ascent()
 606        self._fit()
 607        self.update()
 608
 609    def connect_scroll_bar(self, bar: QScrollBar):
 610        self.scroll_bar = bar
 611        bar.valueChanged.connect(self._scrolled)
 612        self._update_scroll_bar()
 613
 614    def connect_hscroll_bar(self, bar: QScrollBar):
 615        self.hscroll_bar = bar
 616        bar.valueChanged.connect(self._hscrolled)
 617        self._update_hscroll_bar()
 618
 619    def set_word_wrap(self, on: bool):
 620        """With word wrap the terminal is as wide as the window and long
 621        lines wrap; without, it's NO_WRAP_COLUMNS wide and the window shows
 622        part of it (the shell is told the real width either way, so line
 623        editing and full-screen programs stay right)."""
 624        self.word_wrap = on
 625        if on:
 626            self._hscroll = 0
 627        self._fit()
 628        self._update_hscroll_bar()
 629        self.update()
 630
 631    # ----------------------------------------------------------- output
 632    def feed(self, data: bytes):
 633        """Output from the shell."""
 634        before = len(self.screen.history)
 635        self.stream.feed(data)
 636        added = len(self.screen.history) - before
 637        if added:  # (lines scrolled into the scrollback: everything in view moved)
 638            self._redraw_all = True
 639        if self._scroll:  # (reading the scrollback: keep the same lines in view)
 640            self._scroll = min(self._scroll + max(0, added), len(self.screen.history))
 641        if not self._repaint_timer.isActive():
 642            self._repaint_timer.start()
 643
 644    def _after_output(self):
 645        hscroll = self._hscroll
 646        self._update_scroll_bar()
 647        self.ensure_cursor_visible()
 648        self._update_hscroll_bar()
 649        if self._redraw_all or self._scroll or self._scroll_px or self._hscroll != hscroll:
 650            self.update()  # (scrolled: every row in view is another)
 651        else:
 652            self._update_rows(set(self.screen.dirty))
 653        self._redraw_all = False
 654        self.screen.dirty.clear()
 655
 656    def _update_rows(self, rows: set[int]):
 657        """Repaint only these screen rows (what the output changed), and the
 658        cursor's old and new rows."""
 659        cursor = self.screen.cursor.y
 660        rows |= {cursor, self._cursor_drawn_at}
 661        for row in rows:
 662            if 0 <= row < self.screen.lines:
 663                top = int(PADDING + row * self.cell_height)
 664                self.update(0, top, self.width(), int(self.cell_height) + 2)
 665
 666    # ------------------------------------------------- sideways (no wrap)
 667    def _used_columns(self) -> int:
 668        """How far the text in view (and the cursor) reaches, in columns."""
 669        screen = self.screen
 670        first = len(screen.history) - self._scroll
 671        cursor_row = len(screen.history) + screen.cursor.y - first
 672        widest = screen.cursor.x + 1 if 0 <= cursor_row < screen.lines else 0
 673        blank = screen.default_char
 674        for n in range(first, first + screen.lines):
 675            line = self.line(n)
 676            if isinstance(line, list):
 677                end = len(line)
 678                while end > widest and line[end - 1] == blank:
 679                    end -= 1
 680            else:  # (a screen line: only the cells written are in it)
 681                end = max((x + 1 for x, cell in line.items() if cell != blank), default=0)
 682            widest = max(widest, min(end, screen.columns))
 683        return widest
 684
 685    def _scroll_range(self) -> int:
 686        """How far the view can scroll sideways (pixels): to the end of the
 687        widest text in view, not the terminal's full width."""
 688        if self.word_wrap:
 689            return 0
 690        return max(0, int(self._used_columns() * self.cell_width + 2 * PADDING) - self.width())
 691
 692    def _update_hscroll_bar(self):
 693        bar = self.hscroll_bar
 694        maximum = self._scroll_range()
 695        self._hscroll = min(self._hscroll, maximum)
 696        if bar is None:
 697            return
 698        bar.blockSignals(True)
 699        bar.setRange(0, maximum)
 700        bar.setPageStep(max(1, self.width()))
 701        bar.setSingleStep(max(1, int(self.cell_width * 4)))
 702        bar.setValue(self._hscroll)
 703        bar.blockSignals(False)
 704        # Shown only when there's something to scroll to; without word wrap
 705        # its room is kept while hidden, so the terminal's height (and the
 706        # shell's idea of it) doesn't change as it comes and goes.
 707        policy = bar.sizePolicy()
 708        if policy.retainSizeWhenHidden() == self.word_wrap:
 709            policy.setRetainSizeWhenHidden(not self.word_wrap)
 710            bar.setSizePolicy(policy)
 711            layout = bar.parentWidget().layout() if bar.parentWidget() else None
 712            if layout is not None:  # (a hidden widget's new policy doesn't re-lay-out by itself)
 713                layout.invalidate()
 714                layout.activate()
 715        bar.setVisible(maximum > 0)
 716
 717    def _hscrolled(self, value: int):
 718        self._hscroll = value
 719        self.update()
 720
 721    def scroll_sideways(self, pixels: int):
 722        if not self.word_wrap:
 723            self._hscroll = max(0, min(self._scroll_range(), self._hscroll + pixels))
 724            self._update_hscroll_bar()
 725            self.update()
 726
 727    def ensure_cursor_visible(self):
 728        """Without word wrap: scroll sideways to the cursor (as you type, and
 729        back to the left at a new prompt)."""
 730        if self.word_wrap or self._scroll or self._scroll_px:
 731            return
 732        x = PADDING + self.screen.cursor.x * self.cell_width
 733        margin = 4 * self.cell_width
 734        if x < self._hscroll + margin:
 735            self._hscroll = int(max(0, x - margin)) if x > self.width() - margin else 0
 736        elif x + self.cell_width > self._hscroll + self.width() - margin:
 737            self._hscroll = int(x + self.cell_width + margin - self.width())
 738        self._update_hscroll_bar()
 739
 740    def _update_scroll_bar(self):
 741        bar = self.scroll_bar
 742        if bar is None:
 743            return
 744        history = len(self.screen.history)
 745        bar.blockSignals(True)
 746        bar.setRange(0, history)
 747        bar.setPageStep(self.screen.lines)
 748        bar.setValue(history - self._scroll)
 749        bar.blockSignals(False)
 750
 751    def _scrolled(self, value: int):
 752        self._scroll = max(0, len(self.screen.history) - value)
 753        self._scroll_px = 0.0
 754        self._update_hscroll_bar()  # (the lines now in view may be wider or narrower)
 755        self.update()
 756
 757    def scroll_pixels(self, pixels: float):
 758        """Scroll the view up (positive) or down by pixels, through the
 759        scrollback: smoothly, part of a line at a time (a trackpad)."""
 760        history, ch = len(self.screen.history), self.cell_height
 761        total = max(0.0, min(history * ch, self._scroll * ch + self._scroll_px + pixels))
 762        self._scroll = min(history, int(total // ch))
 763        self._scroll_px = total - self._scroll * ch if self._scroll < history else 0.0
 764        self._update_scroll_bar()
 765        self._update_hscroll_bar()
 766        self.update()
 767
 768    def scroll_lines(self, lines: int):
 769        """Scroll the view up (positive) or down through the scrollback."""
 770        self._scroll = max(0, min(len(self.screen.history), self._scroll + lines))
 771        self._scroll_px = 0.0
 772        self._update_scroll_bar()
 773        self._update_hscroll_bar()
 774        self.update()
 775
 776    # ------------------------------------------------------------ text
 777    def total_lines(self) -> int:
 778        return len(self.screen.history) + self.screen.lines
 779
 780    def line(self, n: int):
 781        """Line ``n`` of the scrollback and screen (a list or pyte line of Chars)."""
 782        history = self.screen.history
 783        return history[n] if n < len(history) else self.screen.buffer[n - len(history)]
 784
 785    def _line_text(self, n: int, start: int = 0, end: int | None = None) -> str:
 786        line = self.line(n)
 787        width = len(line) if isinstance(line, list) else self.screen.columns
 788        end = width if end is None else min(end, width)
 789        return "".join(line[x].data for x in range(start, end)).rstrip()
 790
 791    def text(self) -> str:
 792        """All the text: the scrollback, then the screen."""
 793        lines = [self._line_text(n) for n in range(self.total_lines())]
 794        while lines and not lines[-1]:
 795            lines.pop()
 796        return "\n".join(lines)
 797
 798    def selected_text(self) -> str:
 799        if not self._selection:
 800            return ""
 801        (l1, c1), (l2, c2) = sorted(self._selection)
 802        if l1 == l2:
 803            return self._line_text(l1, c1, c2 + 1)
 804        parts = [self._line_text(l1, c1)] + [self._line_text(n) for n in range(l1 + 1, l2)]
 805        return "\n".join(parts + [self._line_text(l2, 0, c2 + 1)])
 806
 807    def _row_selection(self, n: int, width: int) -> tuple[int, int] | None:
 808        """The columns of line ``n`` that are selected, [start, end), or None."""
 809        if not self._selection:
 810            return None
 811        (l1, c1), (l2, c2) = sorted(self._selection)
 812        if not l1 <= n <= l2:
 813            return None
 814        return (c1 if n == l1 else 0), (c2 + 1 if n == l2 else width)
 815
 816    # ---------------------------------------------------- Edit menu, clipboard
 817    def copy(self):
 818        text = self.selected_text()
 819        if text:
 820            QApplication.clipboard().setText(text)
 821
 822    def paste(self):
 823        text = QApplication.clipboard().text()
 824        if not text:
 825            return
 826        data = text.replace("\r\n", "\n").replace("\n", "\r").encode("utf-8")
 827        if BRACKETED_PASTE in self.screen.mode:  # (so the shell doesn't run it line by line)
 828            data = b"\x1b[200~" + data + b"\x1b[201~"
 829        self._input(data)
 830
 831    def cut(self):
 832        self.copy()
 833
 834    def selectAll(self):
 835        last = self.total_lines() - 1
 836        self._selection = ((0, 0), (last, self.screen.columns - 1))
 837        self.update()
 838
 839    # (These exist so the Edit menu doesn't reach past the terminal to the editor.)
 840    def undo(self):
 841        pass
 842
 843    def redo(self):
 844        pass
 845
 846    def clear_scrollback(self):
 847        """⌘K: forget the scrollback and redraw the prompt at the top."""
 848        self.screen.history.clear()
 849        self._scroll, self._scroll_px = 0, 0.0
 850        self._selection = None
 851        self._update_scroll_bar()
 852        self._input(b"\x0c")  # Ctrl+L
 853
 854    # ------------------------------------------------------------ keys
 855    def _input(self, data: bytes):
 856        """Typing goes to the shell, and back to the bottom of the output."""
 857        if self._scroll or self._scroll_px or self._selection:
 858            self._scroll, self._scroll_px, self._selection = 0, 0.0, None
 859            self._update_scroll_bar()
 860            self.update()
 861        self.write(data)
 862
 863    def focusNextPrevChild(self, _next):
 864        return False  # Tab goes to the shell (completion), not to the next widget
 865
 866    def _wanted(self, event) -> bool:
 867        """Keys the terminal takes before the window's shortcuts: Control
 868        combinations (Ctrl+C must interrupt, not copy), Escape and Tab."""
 869        mods = event.modifiers() & ~Qt.KeypadModifier
 870        if mods & CONTROL and not mods & Qt.ShiftModifier and not (MAC and mods & COMMAND):
 871            return True
 872        return mods == Qt.NoModifier and event.key() in (Qt.Key_Escape, Qt.Key_Tab)
 873
 874    def event(self, event):
 875        if event.type() == QEvent.ShortcutOverride and self._wanted(event):
 876            event.accept()
 877            return True
 878        return super().event(event)
 879
 880    def key_bytes(self, key: int, mods, text: str) -> bytes | None:
 881        """What xterm sends for a key (None: nothing)."""
 882        shift, alt, ctrl = bool(mods & Qt.ShiftModifier), bool(mods & Qt.AltModifier), bool(mods & CONTROL)
 883        modifier = 1 + shift + 2 * alt + 4 * ctrl  # (xterm's modifier parameter)
 884        if MAC and alt and not ctrl and key in (Qt.Key_Left, Qt.Key_Right):
 885            return b"\x1bb" if key == Qt.Key_Left else b"\x1bf"  # Option+arrow: a word
 886        if key in CURSOR_KEYS:
 887            final = CURSOR_KEYS[key]
 888            if modifier > 1:
 889                return f"\x1b[1;{modifier}{final}".encode()
 890            return f"\x1b{'O' if DECCKM in self.screen.mode else '['}{final}".encode()
 891        if key in TILDE_KEYS:
 892            number = TILDE_KEYS[key]
 893            return (f"\x1b[{number};{modifier}~" if modifier > 1 else f"\x1b[{number}~").encode()
 894        if key in SS3_KEYS:
 895            return (f"\x1b[1;{modifier}{SS3_KEYS[key]}" if modifier > 1 else f"\x1bO{SS3_KEYS[key]}").encode()
 896        if key == Qt.Key_Backtab:
 897            return b"\x1b[Z"
 898        if key in (Qt.Key_Return, Qt.Key_Enter):
 899            return b"\r"
 900        if key == Qt.Key_Backspace:
 901            return b"\x08" if ctrl else b"\x7f"
 902        if key == Qt.Key_Escape:
 903            return b"\x1b"
 904        if key == Qt.Key_Tab:
 905            return b"\t"
 906        if ctrl:
 907            if Qt.Key_A <= key <= Qt.Key_Z:
 908                return bytes([key - Qt.Key_A + 1])
 909            control = {Qt.Key_Space: 0, Qt.Key_At: 0, Qt.Key_BracketLeft: 27, Qt.Key_Backslash: 28,
 910                       Qt.Key_BracketRight: 29, Qt.Key_AsciiCircum: 30, Qt.Key_Underscore: 31,
 911                       Qt.Key_Minus: 31, Qt.Key_Slash: 31}
 912            if key in control:
 913                return bytes([control[key]])
 914        if text:
 915            data = text.encode("utf-8")
 916            # Alt+key: ESC first (on macOS Option types characters instead).
 917            return b"\x1b" + data if alt and not MAC else data
 918        return None
 919
 920    def keyPressEvent(self, event):
 921        key, mods, text = event.key(), event.modifiers() & ~Qt.KeypadModifier, event.text()
 922        if MAC and mods & COMMAND:  # ⌘: the app's, not the shell's
 923            actions = {Qt.Key_C: self.copy, Qt.Key_V: self.paste, Qt.Key_K: self.clear_scrollback,
 924                       Qt.Key_A: self.selectAll}
 925            if key in actions:
 926                actions[key]()
 927            else:
 928                event.ignore()
 929            return
 930        if not MAC and mods == (Qt.ControlModifier | Qt.ShiftModifier) and key in (Qt.Key_C, Qt.Key_V):
 931            self.copy() if key == Qt.Key_C else self.paste()
 932            return
 933        if mods == Qt.ShiftModifier and key in (Qt.Key_PageUp, Qt.Key_PageDown):  # read the scrollback
 934            self.scroll_lines(self.screen.lines - 1 if key == Qt.Key_PageUp else 1 - self.screen.lines)
 935            return
 936        data = self.key_bytes(key, mods, text)
 937        if data:
 938            self._input(data)
 939        else:
 940            event.ignore()
 941
 942    # ------------------------------------------------------------ mouse
 943    def _cell_at(self, pos) -> tuple[int, int]:
 944        """(line in scrollback + screen, column) under a point."""
 945        row = int((pos.y() - PADDING - self._scroll_px) // self.cell_height)
 946        col = int((pos.x() + self._hscroll - PADDING) // self.cell_width)
 947        row = max(-1 if self._scroll_px else 0, min(self.screen.lines - 1, row))
 948        col = max(0, min(self.screen.columns - 1, col))
 949        return len(self.screen.history) - self._scroll + row, col
 950
 951    def mousePressEvent(self, event):
 952        if event.button() == Qt.LeftButton:
 953            cell = self._cell_at(event.position())
 954            self._selection = (cell, cell)
 955            self._selecting = True
 956            self.update()
 957        super().mousePressEvent(event)
 958
 959    def mouseMoveEvent(self, event):
 960        if getattr(self, "_selecting", False) and self._selection:
 961            self._selection = (self._selection[0], self._cell_at(event.position()))
 962            self.update()
 963
 964    def mouseReleaseEvent(self, event):
 965        self._selecting = False
 966        if self._selection and self._selection[0] == self._selection[1]:
 967            self._selection = None  # (a click, not a selection)
 968            self.update()
 969        elif self._selection and not MAC and sys.platform.startswith("linux"):
 970            QApplication.clipboard().setText(self.selected_text(), QClipboard.Selection)
 971
 972    def mouseDoubleClickEvent(self, event):
 973        """Select the word under the mouse."""
 974        n, x = self._cell_at(event.position())
 975        text = self._line_text(n).ljust(self.screen.columns)
 976
 977        def in_word(c):
 978            return c.isalnum() or c in "_-./~:@%+"
 979
 980        if x < len(text) and in_word(text[x]):
 981            start, end = x, x
 982            while start > 0 and in_word(text[start - 1]):
 983                start -= 1
 984            while end + 1 < len(text) and in_word(text[end + 1]):
 985                end += 1
 986            self._selection = ((n, start), (n, end))
 987            self.update()
 988
 989    def wheelEvent(self, event):
 990        """Scroll through the scrollback (or, in a full-screen program, send it
 991        arrow keys, as xterm does). A trackpad's distance is in pixels and
 992        comes in many small steps: they add up, the part of a line left over
 993        kept for the next one, so a swipe up and back down the same distance
 994        ends where it started."""
 995        pixels, angle = event.pixelDelta(), event.angleDelta()
 996        dx, dy = pixels.x() or angle.x(), pixels.y() or angle.y()
 997        if event.modifiers() & Qt.ShiftModifier:
 998            dx, dy = dx or dy, 0  # (Shift+wheel: sideways)
 999        if abs(dx) > abs(dy):  # (mostly sideways: a vertical swipe's bit of sideways doesn't count)
1000            self.scroll_sideways(-dx)
1001            return
1002        # Up and down: a trackpad in its exact pixels; a mouse wheel 3 lines a notch.
1003        distance = pixels.y() if pixels.y() else angle.y() / 120 * 3 * self.cell_height
1004        if not distance:
1005            return
1006        if not self.screen.alternate:
1007            self.scroll_pixels(distance)
1008            return
1009        # Full-screen programs: the wheel is arrow keys (as in xterm), a line at a time.
1010        self._wheel_rest += distance / max(1.0, self.cell_height)
1011        whole = int(self._wheel_rest)  # (towards zero: the rest waits for the next step)
1012        self._wheel_rest -= whole
1013        if whole:
1014            key = Qt.Key_Up if whole > 0 else Qt.Key_Down
1015            self.write(self.key_bytes(key, Qt.NoModifier, "") * abs(whole))
1016
1017    def contextMenuEvent(self, event):
1018        from qtpy.QtWidgets import QMenu
1019
1020        menu = QMenu(self)
1021        copy = menu.addAction("Copy", self.copy)
1022        copy.setEnabled(bool(self._selection))
1023        menu.addAction("Paste", self.paste)
1024        menu.addAction("Select All", self.selectAll)
1025        menu.addSeparator()
1026        menu.addAction("Clear Scrollback", self.clear_scrollback)
1027        menu.exec(event.globalPos())
1028
1029    # -------------------------------------------------------- drawing
1030    def _fit(self):
1031        """Columns and rows for the widget's size, or wider without word
1032        wrap (the shell is told)."""
1033        cols = max(2, int((self.width() - 2 * PADDING) // self.cell_width))
1034        if not self.word_wrap:
1035            cols = max(cols, NO_WRAP_COLUMNS)
1036        rows = max(1, int((self.height() - 2 * PADDING) // self.cell_height))
1037        if (rows, cols) != (self.screen.lines, self.screen.columns):
1038            self.screen.resize(rows, cols)
1039            self._scroll, self._scroll_px = min(self._scroll, len(self.screen.history)), 0.0
1040            self._update_scroll_bar()
1041            self.size_changed.emit(rows, cols)
1042
1043    def resizeEvent(self, event):
1044        super().resizeEvent(event)
1045        self._fit()
1046        self._update_hscroll_bar()
1047
1048    def focusInEvent(self, event):
1049        super().focusInEvent(event)
1050        self.update()
1051
1052    def focusOutEvent(self, event):
1053        super().focusOutEvent(event)
1054        self.update()
1055
1056    def _color(self, name: str, default: QColor) -> QColor:
1057        if name == "default":
1058            return default
1059        color = self._colors.get(name)
1060        if color is None:
1061            if name in ANSI_NAMES:
1062                color = self.ansi[ANSI_NAMES[name]]
1063            elif name in ANSI_HEX:
1064                color = self.ansi[ANSI_HEX[name]]
1065            else:
1066                color = QColor("#" + name) if len(name) == 6 else QColor(default)
1067            self._colors[name] = color
1068        return color
1069
1070    def paintEvent(self, event):
1071        painter = QPainter(self)
1072        painter.fillRect(self.rect(), self.background)
1073        offset = self._scroll_px  # (scrolled part of a line further: everything a bit lower)
1074        painter.translate(-self._hscroll, offset)  # (and sideways, without word wrap)
1075        screen = self.screen
1076        history = len(screen.history)
1077        first = history - self._scroll
1078        ch = self.cell_height
1079        palette = self.palette()
1080        select_bg = palette.color(QPalette.Highlight)
1081        select_fg = palette.color(QPalette.HighlightedText)
1082        top_row = max(-1 if offset else 0, int((event.rect().top() - PADDING - offset) // ch))
1083        bottom_row = min(screen.lines - 1, int((event.rect().bottom() - PADDING - offset) // ch))
1084        for row in range(top_row, bottom_row + 1):
1085            n = first + row
1086            if n < 0:
1087                continue
1088            line = self.line(n)
1089            width = min(len(line), screen.columns) if isinstance(line, list) else screen.columns
1090            span = self._row_selection(n, width)  # (once per row: not per cell)
1091            y = PADDING + row * ch
1092            x = 0
1093            while x < width:
1094                cell = line[x]
1095                selected = span is not None and span[0] <= x < span[1]
1096                style = (cell.fg, cell.bg, cell.bold, cell.italics, cell.underscore,
1097                         cell.strikethrough, cell.reverse, selected)
1098                start, chars = x, [cell.data]
1099                x += 1
1100                while x < width:  # (a run of cells drawn alike)
1101                    other = line[x]
1102                    if (other.fg, other.bg, other.bold, other.italics, other.underscore, other.strikethrough,
1103                            other.reverse, span is not None and span[0] <= x < span[1]) != style:
1104                        break
1105                    chars.append(other.data)
1106                    x += 1
1107                self._draw_run(painter, start, y, chars, cell, selected, select_bg, select_fg)
1108        self._draw_cursor(painter, first)
1109
1110    def _draw_run(self, painter, column, y, chars, cell, selected, select_bg, select_fg):
1111        cw, ch = self.cell_width, self.cell_height
1112        fg = self._color(cell.fg, self.foreground)
1113        bg = self._color(cell.bg, self.background)
1114        if cell.reverse:
1115            fg, bg = bg, fg
1116        if selected:
1117            fg, bg = select_fg, select_bg
1118        x0 = PADDING + column * cw
1119        if bg != self.background:
1120            painter.fillRect(QRectF(x0, y, len(chars) * cw, ch), bg)
1121        text = "".join(chars)
1122        if not text.strip():
1123            return
1124        font = self.font_bold if cell.bold else self.font_normal
1125        if cell.italics or cell.underscore or cell.strikethrough:
1126            font = QFont(font)
1127            font.setItalic(cell.italics)
1128            font.setUnderline(cell.underscore)
1129            font.setStrikeOut(cell.strikethrough)
1130        painter.setFont(font)
1131        painter.setPen(fg)
1132        baseline = y + self.ascent
1133        if text.isascii():
1134            painter.drawText(QPointF(x0, baseline), text)
1135        else:  # (each character in its cell: wide ones take two, the next is empty)
1136            for i, c in enumerate(chars):
1137                if c:
1138                    painter.drawText(QPointF(x0 + i * cw, baseline), c)
1139
1140    def _draw_cursor(self, painter, first):
1141        screen = self.screen
1142        self._cursor_drawn_at = screen.cursor.y
1143        row = len(screen.history) + screen.cursor.y - first
1144        if screen.cursor.hidden or not 0 <= row < screen.lines:
1145            return
1146        x = PADDING + screen.cursor.x * self.cell_width
1147        y = PADDING + row * self.cell_height
1148        rect = QRectF(x, y, self.cell_width, self.cell_height)
1149        if self.hasFocus():
1150            painter.fillRect(rect, self.foreground)
1151            cell = screen.buffer[screen.cursor.y][screen.cursor.x]
1152            if cell.data.strip():
1153                painter.setFont(self.font_bold if cell.bold else self.font_normal)
1154                painter.setPen(self.background)
1155                painter.drawText(QPointF(x, y + self.ascent), cell.data)
1156        else:
1157            painter.setPen(self.foreground)
1158            painter.setBrush(Qt.NoBrush)
1159            painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5))

Draws a TerminalScreen and sends keys to the shell (see the module's description).

TerminalView(parent=None)
554    def __init__(self, parent=None):
555        super().__init__(parent)
556        self.setFocusPolicy(Qt.StrongFocus)
557        self.setAttribute(Qt.WA_OpaquePaintEvent)
558        self.setCursor(Qt.IBeamCursor)
559        self.setMinimumSize(80, 40)
560        self.write = lambda data: None  # bytes to the shell (set by the panel)
561        self.screen = TerminalScreen(80, 24)
562        self.screen.reply = lambda data: self.write(data)
563        self.stream = pyte.ByteStream(SafeScreen(self.screen))
564        self.scroll_bar: QScrollBar | None = None
565        self.hscroll_bar: QScrollBar | None = None
566        self.word_wrap = True       # View → Word Wrap (off: a wide terminal, scrolled sideways)
567        self._hscroll = 0           # pixels scrolled to the right (without word wrap)
568        self._scroll = 0            # lines scrolled up from the bottom (0: following the output)
569        self._scroll_px = 0.0       # and pixels more, into the line above (trackpad: smooth, see scroll_pixels)
570        self._selection = None      # ((line, column), (line, column)) in all lines (scrollback + screen)
571        self._colors: dict[str, QColor] = {}
572        self.set_colors(QColor("#1e1e1e"), QColor("#d4d4d4"), DARK)
573        self.set_terminal_font(QFont("Menlo" if MAC else "Monospace"))
574        self._redraw_all = True     # (else only the rows the output changed: see _after_output)
575        self._wheel_rest = 0.0      # part of a line scrolled, not yet moved (see wheelEvent)
576        self._cursor_drawn_at = 0   # the screen row the cursor was last drawn on
577        self._repaint_timer = QTimer(self)  # (output in bursts: draw at most every few ms)
578        self._repaint_timer.setSingleShot(True)
579        self._repaint_timer.setInterval(10)
580        self._repaint_timer.timeout.connect(self._after_output)

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def size_changed(unknown):

The type of the None singleton.

write
def screen(self, /):

screen(self, /) -> PySide6.QtGui.QScreen

stream
scroll_bar: PySide6.QtWidgets.QScrollBar | None
hscroll_bar: PySide6.QtWidgets.QScrollBar | None
word_wrap
def set_colors( self, background: PySide6.QtGui.QColor, text: PySide6.QtGui.QColor, ansi: list[str]):
583    def set_colors(self, background: QColor, text: QColor, ansi: list[str]):
584        self.background, self.foreground = QColor(background), QColor(text)
585        self.ansi = [QColor(c) for c in ansi]
586        self._colors.clear()
587        self.update()
def set_terminal_font(self, font: PySide6.QtGui.QFont):
589    def set_terminal_font(self, font: QFont):
590        # Family and size set explicitly: a size left unset would be measured
591        # at the application's default size but drawn at the widget's.
592        size = font.pointSizeF() if font.pointSizeF() > 0 else QApplication.font().pointSizeF()
593        font = QFont(font)
594        font.setFamily(font.family())
595        font.setPointSizeF(size)
596        font.setStyleHint(QFont.Monospace)
597        font.setFixedPitch(True)
598        font.setKerning(False)
599        self.font_normal = font
600        self.font_bold = QFont(font)
601        self.font_bold.setBold(True)
602        metrics = QFontMetricsF(font, self)  # (as drawn on this widget: its screen's resolution)
603        self.cell_width = metrics.horizontalAdvance("M")
604        self.cell_height = metrics.lineSpacing()
605        self.ascent = metrics.ascent()
606        self._fit()
607        self.update()
def connect_scroll_bar(self, bar: PySide6.QtWidgets.QScrollBar):
609    def connect_scroll_bar(self, bar: QScrollBar):
610        self.scroll_bar = bar
611        bar.valueChanged.connect(self._scrolled)
612        self._update_scroll_bar()
def connect_hscroll_bar(self, bar: PySide6.QtWidgets.QScrollBar):
614    def connect_hscroll_bar(self, bar: QScrollBar):
615        self.hscroll_bar = bar
616        bar.valueChanged.connect(self._hscrolled)
617        self._update_hscroll_bar()
def set_word_wrap(self, on: bool):
619    def set_word_wrap(self, on: bool):
620        """With word wrap the terminal is as wide as the window and long
621        lines wrap; without, it's NO_WRAP_COLUMNS wide and the window shows
622        part of it (the shell is told the real width either way, so line
623        editing and full-screen programs stay right)."""
624        self.word_wrap = on
625        if on:
626            self._hscroll = 0
627        self._fit()
628        self._update_hscroll_bar()
629        self.update()

With word wrap the terminal is as wide as the window and long lines wrap; without, it's NO_WRAP_COLUMNS wide and the window shows part of it (the shell is told the real width either way, so line editing and full-screen programs stay right).

def feed(self, data: bytes):
632    def feed(self, data: bytes):
633        """Output from the shell."""
634        before = len(self.screen.history)
635        self.stream.feed(data)
636        added = len(self.screen.history) - before
637        if added:  # (lines scrolled into the scrollback: everything in view moved)
638            self._redraw_all = True
639        if self._scroll:  # (reading the scrollback: keep the same lines in view)
640            self._scroll = min(self._scroll + max(0, added), len(self.screen.history))
641        if not self._repaint_timer.isActive():
642            self._repaint_timer.start()

Output from the shell.

def scroll_sideways(self, pixels: int):
721    def scroll_sideways(self, pixels: int):
722        if not self.word_wrap:
723            self._hscroll = max(0, min(self._scroll_range(), self._hscroll + pixels))
724            self._update_hscroll_bar()
725            self.update()
def ensure_cursor_visible(self):
727    def ensure_cursor_visible(self):
728        """Without word wrap: scroll sideways to the cursor (as you type, and
729        back to the left at a new prompt)."""
730        if self.word_wrap or self._scroll or self._scroll_px:
731            return
732        x = PADDING + self.screen.cursor.x * self.cell_width
733        margin = 4 * self.cell_width
734        if x < self._hscroll + margin:
735            self._hscroll = int(max(0, x - margin)) if x > self.width() - margin else 0
736        elif x + self.cell_width > self._hscroll + self.width() - margin:
737            self._hscroll = int(x + self.cell_width + margin - self.width())
738        self._update_hscroll_bar()

Without word wrap: scroll sideways to the cursor (as you type, and back to the left at a new prompt).

def scroll_pixels(self, pixels: float):
757    def scroll_pixels(self, pixels: float):
758        """Scroll the view up (positive) or down by pixels, through the
759        scrollback: smoothly, part of a line at a time (a trackpad)."""
760        history, ch = len(self.screen.history), self.cell_height
761        total = max(0.0, min(history * ch, self._scroll * ch + self._scroll_px + pixels))
762        self._scroll = min(history, int(total // ch))
763        self._scroll_px = total - self._scroll * ch if self._scroll < history else 0.0
764        self._update_scroll_bar()
765        self._update_hscroll_bar()
766        self.update()

Scroll the view up (positive) or down by pixels, through the scrollback: smoothly, part of a line at a time (a trackpad).

def scroll_lines(self, lines: int):
768    def scroll_lines(self, lines: int):
769        """Scroll the view up (positive) or down through the scrollback."""
770        self._scroll = max(0, min(len(self.screen.history), self._scroll + lines))
771        self._scroll_px = 0.0
772        self._update_scroll_bar()
773        self._update_hscroll_bar()
774        self.update()

Scroll the view up (positive) or down through the scrollback.

def total_lines(self) -> int:
777    def total_lines(self) -> int:
778        return len(self.screen.history) + self.screen.lines
def line(self, n: int):
780    def line(self, n: int):
781        """Line ``n`` of the scrollback and screen (a list or pyte line of Chars)."""
782        history = self.screen.history
783        return history[n] if n < len(history) else self.screen.buffer[n - len(history)]

Line n of the scrollback and screen (a list or pyte line of Chars).

def text(self) -> str:
791    def text(self) -> str:
792        """All the text: the scrollback, then the screen."""
793        lines = [self._line_text(n) for n in range(self.total_lines())]
794        while lines and not lines[-1]:
795            lines.pop()
796        return "\n".join(lines)

All the text: the scrollback, then the screen.

def selected_text(self) -> str:
798    def selected_text(self) -> str:
799        if not self._selection:
800            return ""
801        (l1, c1), (l2, c2) = sorted(self._selection)
802        if l1 == l2:
803            return self._line_text(l1, c1, c2 + 1)
804        parts = [self._line_text(l1, c1)] + [self._line_text(n) for n in range(l1 + 1, l2)]
805        return "\n".join(parts + [self._line_text(l2, 0, c2 + 1)])
def copy(self):
817    def copy(self):
818        text = self.selected_text()
819        if text:
820            QApplication.clipboard().setText(text)
def paste(self):
822    def paste(self):
823        text = QApplication.clipboard().text()
824        if not text:
825            return
826        data = text.replace("\r\n", "\n").replace("\n", "\r").encode("utf-8")
827        if BRACKETED_PASTE in self.screen.mode:  # (so the shell doesn't run it line by line)
828            data = b"\x1b[200~" + data + b"\x1b[201~"
829        self._input(data)
def cut(self):
831    def cut(self):
832        self.copy()
def selectAll(self):
834    def selectAll(self):
835        last = self.total_lines() - 1
836        self._selection = ((0, 0), (last, self.screen.columns - 1))
837        self.update()
def undo(self):
840    def undo(self):
841        pass
def redo(self):
843    def redo(self):
844        pass
def clear_scrollback(self):
846    def clear_scrollback(self):
847        """⌘K: forget the scrollback and redraw the prompt at the top."""
848        self.screen.history.clear()
849        self._scroll, self._scroll_px = 0, 0.0
850        self._selection = None
851        self._update_scroll_bar()
852        self._input(b"\x0c")  # Ctrl+L

⌘K: forget the scrollback and redraw the prompt at the top.

def focusNextPrevChild(self, _next):
863    def focusNextPrevChild(self, _next):
864        return False  # Tab goes to the shell (completion), not to the next widget

focusNextPrevChild(self, next: bool, /) -> bool

def event(self, event):
874    def event(self, event):
875        if event.type() == QEvent.ShortcutOverride and self._wanted(event):
876            event.accept()
877            return True
878        return super().event(event)

event(self, event: PySide6.QtCore.QEvent, /) -> bool

def key_bytes(self, key: int, mods, text: str) -> bytes | None:
880    def key_bytes(self, key: int, mods, text: str) -> bytes | None:
881        """What xterm sends for a key (None: nothing)."""
882        shift, alt, ctrl = bool(mods & Qt.ShiftModifier), bool(mods & Qt.AltModifier), bool(mods & CONTROL)
883        modifier = 1 + shift + 2 * alt + 4 * ctrl  # (xterm's modifier parameter)
884        if MAC and alt and not ctrl and key in (Qt.Key_Left, Qt.Key_Right):
885            return b"\x1bb" if key == Qt.Key_Left else b"\x1bf"  # Option+arrow: a word
886        if key in CURSOR_KEYS:
887            final = CURSOR_KEYS[key]
888            if modifier > 1:
889                return f"\x1b[1;{modifier}{final}".encode()
890            return f"\x1b{'O' if DECCKM in self.screen.mode else '['}{final}".encode()
891        if key in TILDE_KEYS:
892            number = TILDE_KEYS[key]
893            return (f"\x1b[{number};{modifier}~" if modifier > 1 else f"\x1b[{number}~").encode()
894        if key in SS3_KEYS:
895            return (f"\x1b[1;{modifier}{SS3_KEYS[key]}" if modifier > 1 else f"\x1bO{SS3_KEYS[key]}").encode()
896        if key == Qt.Key_Backtab:
897            return b"\x1b[Z"
898        if key in (Qt.Key_Return, Qt.Key_Enter):
899            return b"\r"
900        if key == Qt.Key_Backspace:
901            return b"\x08" if ctrl else b"\x7f"
902        if key == Qt.Key_Escape:
903            return b"\x1b"
904        if key == Qt.Key_Tab:
905            return b"\t"
906        if ctrl:
907            if Qt.Key_A <= key <= Qt.Key_Z:
908                return bytes([key - Qt.Key_A + 1])
909            control = {Qt.Key_Space: 0, Qt.Key_At: 0, Qt.Key_BracketLeft: 27, Qt.Key_Backslash: 28,
910                       Qt.Key_BracketRight: 29, Qt.Key_AsciiCircum: 30, Qt.Key_Underscore: 31,
911                       Qt.Key_Minus: 31, Qt.Key_Slash: 31}
912            if key in control:
913                return bytes([control[key]])
914        if text:
915            data = text.encode("utf-8")
916            # Alt+key: ESC first (on macOS Option types characters instead).
917            return b"\x1b" + data if alt and not MAC else data
918        return None

What xterm sends for a key (None: nothing).

def keyPressEvent(self, event):
920    def keyPressEvent(self, event):
921        key, mods, text = event.key(), event.modifiers() & ~Qt.KeypadModifier, event.text()
922        if MAC and mods & COMMAND:  # ⌘: the app's, not the shell's
923            actions = {Qt.Key_C: self.copy, Qt.Key_V: self.paste, Qt.Key_K: self.clear_scrollback,
924                       Qt.Key_A: self.selectAll}
925            if key in actions:
926                actions[key]()
927            else:
928                event.ignore()
929            return
930        if not MAC and mods == (Qt.ControlModifier | Qt.ShiftModifier) and key in (Qt.Key_C, Qt.Key_V):
931            self.copy() if key == Qt.Key_C else self.paste()
932            return
933        if mods == Qt.ShiftModifier and key in (Qt.Key_PageUp, Qt.Key_PageDown):  # read the scrollback
934            self.scroll_lines(self.screen.lines - 1 if key == Qt.Key_PageUp else 1 - self.screen.lines)
935            return
936        data = self.key_bytes(key, mods, text)
937        if data:
938            self._input(data)
939        else:
940            event.ignore()

keyPressEvent(self, event: PySide6.QtGui.QKeyEvent, /) -> None

def mousePressEvent(self, event):
951    def mousePressEvent(self, event):
952        if event.button() == Qt.LeftButton:
953            cell = self._cell_at(event.position())
954            self._selection = (cell, cell)
955            self._selecting = True
956            self.update()
957        super().mousePressEvent(event)

mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def mouseMoveEvent(self, event):
959    def mouseMoveEvent(self, event):
960        if getattr(self, "_selecting", False) and self._selection:
961            self._selection = (self._selection[0], self._cell_at(event.position()))
962            self.update()

mouseMoveEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def mouseReleaseEvent(self, event):
964    def mouseReleaseEvent(self, event):
965        self._selecting = False
966        if self._selection and self._selection[0] == self._selection[1]:
967            self._selection = None  # (a click, not a selection)
968            self.update()
969        elif self._selection and not MAC and sys.platform.startswith("linux"):
970            QApplication.clipboard().setText(self.selected_text(), QClipboard.Selection)

mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def mouseDoubleClickEvent(self, event):
972    def mouseDoubleClickEvent(self, event):
973        """Select the word under the mouse."""
974        n, x = self._cell_at(event.position())
975        text = self._line_text(n).ljust(self.screen.columns)
976
977        def in_word(c):
978            return c.isalnum() or c in "_-./~:@%+"
979
980        if x < len(text) and in_word(text[x]):
981            start, end = x, x
982            while start > 0 and in_word(text[start - 1]):
983                start -= 1
984            while end + 1 < len(text) and in_word(text[end + 1]):
985                end += 1
986            self._selection = ((n, start), (n, end))
987            self.update()

Select the word under the mouse.

def wheelEvent(self, event):
 989    def wheelEvent(self, event):
 990        """Scroll through the scrollback (or, in a full-screen program, send it
 991        arrow keys, as xterm does). A trackpad's distance is in pixels and
 992        comes in many small steps: they add up, the part of a line left over
 993        kept for the next one, so a swipe up and back down the same distance
 994        ends where it started."""
 995        pixels, angle = event.pixelDelta(), event.angleDelta()
 996        dx, dy = pixels.x() or angle.x(), pixels.y() or angle.y()
 997        if event.modifiers() & Qt.ShiftModifier:
 998            dx, dy = dx or dy, 0  # (Shift+wheel: sideways)
 999        if abs(dx) > abs(dy):  # (mostly sideways: a vertical swipe's bit of sideways doesn't count)
1000            self.scroll_sideways(-dx)
1001            return
1002        # Up and down: a trackpad in its exact pixels; a mouse wheel 3 lines a notch.
1003        distance = pixels.y() if pixels.y() else angle.y() / 120 * 3 * self.cell_height
1004        if not distance:
1005            return
1006        if not self.screen.alternate:
1007            self.scroll_pixels(distance)
1008            return
1009        # Full-screen programs: the wheel is arrow keys (as in xterm), a line at a time.
1010        self._wheel_rest += distance / max(1.0, self.cell_height)
1011        whole = int(self._wheel_rest)  # (towards zero: the rest waits for the next step)
1012        self._wheel_rest -= whole
1013        if whole:
1014            key = Qt.Key_Up if whole > 0 else Qt.Key_Down
1015            self.write(self.key_bytes(key, Qt.NoModifier, "") * abs(whole))

Scroll through the scrollback (or, in a full-screen program, send it arrow keys, as xterm does). A trackpad's distance is in pixels and comes in many small steps: they add up, the part of a line left over kept for the next one, so a swipe up and back down the same distance ends where it started.

def contextMenuEvent(self, event):
1017    def contextMenuEvent(self, event):
1018        from qtpy.QtWidgets import QMenu
1019
1020        menu = QMenu(self)
1021        copy = menu.addAction("Copy", self.copy)
1022        copy.setEnabled(bool(self._selection))
1023        menu.addAction("Paste", self.paste)
1024        menu.addAction("Select All", self.selectAll)
1025        menu.addSeparator()
1026        menu.addAction("Clear Scrollback", self.clear_scrollback)
1027        menu.exec(event.globalPos())

contextMenuEvent(self, event: PySide6.QtGui.QContextMenuEvent, /) -> None

def resizeEvent(self, event):
1043    def resizeEvent(self, event):
1044        super().resizeEvent(event)
1045        self._fit()
1046        self._update_hscroll_bar()

resizeEvent(self, event: PySide6.QtGui.QResizeEvent, /) -> None

def focusInEvent(self, event):
1048    def focusInEvent(self, event):
1049        super().focusInEvent(event)
1050        self.update()

focusInEvent(self, event: PySide6.QtGui.QFocusEvent, /) -> None

def focusOutEvent(self, event):
1052    def focusOutEvent(self, event):
1053        super().focusOutEvent(event)
1054        self.update()

focusOutEvent(self, event: PySide6.QtGui.QFocusEvent, /) -> None

def paintEvent(self, event):
1070    def paintEvent(self, event):
1071        painter = QPainter(self)
1072        painter.fillRect(self.rect(), self.background)
1073        offset = self._scroll_px  # (scrolled part of a line further: everything a bit lower)
1074        painter.translate(-self._hscroll, offset)  # (and sideways, without word wrap)
1075        screen = self.screen
1076        history = len(screen.history)
1077        first = history - self._scroll
1078        ch = self.cell_height
1079        palette = self.palette()
1080        select_bg = palette.color(QPalette.Highlight)
1081        select_fg = palette.color(QPalette.HighlightedText)
1082        top_row = max(-1 if offset else 0, int((event.rect().top() - PADDING - offset) // ch))
1083        bottom_row = min(screen.lines - 1, int((event.rect().bottom() - PADDING - offset) // ch))
1084        for row in range(top_row, bottom_row + 1):
1085            n = first + row
1086            if n < 0:
1087                continue
1088            line = self.line(n)
1089            width = min(len(line), screen.columns) if isinstance(line, list) else screen.columns
1090            span = self._row_selection(n, width)  # (once per row: not per cell)
1091            y = PADDING + row * ch
1092            x = 0
1093            while x < width:
1094                cell = line[x]
1095                selected = span is not None and span[0] <= x < span[1]
1096                style = (cell.fg, cell.bg, cell.bold, cell.italics, cell.underscore,
1097                         cell.strikethrough, cell.reverse, selected)
1098                start, chars = x, [cell.data]
1099                x += 1
1100                while x < width:  # (a run of cells drawn alike)
1101                    other = line[x]
1102                    if (other.fg, other.bg, other.bold, other.italics, other.underscore, other.strikethrough,
1103                            other.reverse, span is not None and span[0] <= x < span[1]) != style:
1104                        break
1105                    chars.append(other.data)
1106                    x += 1
1107                self._draw_run(painter, start, y, chars, cell, selected, select_bg, select_fg)
1108        self._draw_cursor(painter, first)

paintEvent(self, event: PySide6.QtGui.QPaintEvent, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("TerminalView" inherits "QWidget": Methods: #33 type=Signal, signature=size_changed(int,int), parameters=int, int )
class TerminalPanel(PySide6.QtWidgets.QWidget):
1162class TerminalPanel(QWidget):
1163    """The *Terminal* tab: a header (shell, folder, environment, Restart)
1164    over the terminal."""
1165
1166    def __init__(self, parent=None):
1167        super().__init__(parent)
1168        self.directory: Path = Path.home()
1169        self.shell: PtyShell | WinptyShell | None = None
1170        self.title = QLabel()
1171        self.title.setContentsMargins(6, 2, 6, 2)
1172        self.restart_button = QToolButton()  # (an icon button, as the Debug and Git panels': see refresh_colors)
1173        self.restart_button.setText("Restart")
1174        self.restart_button.setToolButtonStyle(Qt.ToolButtonIconOnly)
1175        self.restart_button.setIconSize(QSize(18, 18))
1176        self.restart_button.setAutoRaise(True)
1177        self.restart_button.setToolTip("End this shell and start a new one in the project's folder")
1178        self.restart_button.clicked.connect(self.restart)
1179        header = QHBoxLayout()
1180        header.setContentsMargins(0, 0, 0, 0)
1181        header.addWidget(self.title, 1)
1182        header.addWidget(self.restart_button)
1183        self.view = TerminalView(self)
1184        self.scroll_bar = QScrollBar(Qt.Vertical)
1185        self.view.connect_scroll_bar(self.scroll_bar)
1186        self.hscroll_bar = QScrollBar(Qt.Horizontal)  # (shown without word wrap)
1187        self.view.connect_hscroll_bar(self.hscroll_bar)
1188        body = QGridLayout()
1189        body.setContentsMargins(0, 0, 0, 0)
1190        body.setSpacing(0)
1191        body.addWidget(self.view, 0, 0)
1192        body.addWidget(self.scroll_bar, 0, 1)
1193        body.addWidget(self.hscroll_bar, 1, 0)
1194        layout = QVBoxLayout(self)
1195        layout.setContentsMargins(0, 0, 0, 0)
1196        layout.setSpacing(0)
1197        layout.addLayout(header)
1198        layout.addLayout(body, 1)
1199        self.view.size_changed.connect(self._resized)
1200        self.refresh_colors()
1201
1202    def set_directory(self, directory: Path | None):
1203        """The project's folder: where the shell starts. A different one (another
1204        project opened) restarts a running shell there; the same one (the
1205        project reloaded) leaves it alone."""
1206        directory = directory if directory and directory.is_dir() else Path.home()
1207        moved = directory.resolve() != self.directory.resolve()
1208        self.directory = directory
1209        if moved and self.shell is not None and self.shell.is_running():
1210            self.restart(focus=False)
1211        elif self.shell is None:
1212            self._update_title()
1213
1214    def _update_title(self, note: str = ""):
1215        shell = Path(user_shell()).name
1216        folder = str(self.directory).replace(str(Path.home()), "~", 1)
1217        name = environment_name()
1218        env = f" · environment <b>{name}</b>" if name else ""
1219        self.title.setText(f"<b>{shell}</b> — {folder}{env}" + (f"  <i>({note})</i>" if note else ""))
1220        self.title.setToolTip("The shell has exactly the environment VibeFPMGUI was started with "
1221                              "(your shell's startup files aren't read, so nothing changes it).")
1222
1223    def start(self):
1224        """Start the shell (if it isn't running)."""
1225        if self.shell is not None and self.shell.is_running():
1226            return
1227        view = self.view
1228        kind = WinptyShell if WINDOWS else PtyShell
1229        launch = shell_launch()
1230        self.shell = kind(launch, self.directory, view.screen.lines, view.screen.columns, self)
1231        self.shell.output.connect(view.feed)
1232        self.shell.exited.connect(self._exited)
1233        view.write = self.shell.write
1234        try:
1235            self.shell.start()
1236        except (OSError, ImportError) as exc:
1237            view.feed(f"Could not start {launch.argv[0]}: {exc}\r\n".encode())
1238            self.shell = None
1239            return
1240        self._update_title()
1241
1242    def _resized(self, rows: int, cols: int):
1243        if self.shell is not None:
1244            self.shell.resize(rows, cols)
1245
1246    def _exited(self):
1247        self._update_title("exited — Restart for a new shell")
1248        self.view.feed(b"\r\n[The shell has exited.]\r\n")
1249
1250    def restart(self, focus: bool = True):
1251        """A new shell (in ``directory``), on a clean screen."""
1252        self.shutdown()
1253        self.view.feed(b"\x1bc")  # (reset: a clean screen)
1254        self.view.screen.history.clear()
1255        self.start()
1256        if focus:
1257            self.focus()
1258
1259    def focus(self):
1260        self.view.setFocus()
1261
1262    def type_command(self, command: str):
1263        """Type ``command`` at the shell's prompt and press Return (starting
1264        the shell first if needed)."""
1265        self.start()
1266        if self.shell is not None:
1267            self.shell.write((command + "\r").encode())
1268
1269    def showEvent(self, event):
1270        super().showEvent(event)
1271        QTimer.singleShot(0, self, self.start)  # (the first time the tab is shown: after it's laid out)
1272
1273    def refresh_colors(self):
1274        self.view.set_colors(*terminal_colors(QApplication.palette()))
1275        self.restart_button.setIcon(make_icon("refresh", QApplication.palette().color(QPalette.WindowText)))
1276
1277    def set_font(self, font: QFont):
1278        self.view.set_terminal_font(font)
1279
1280    def set_word_wrap(self, on: bool):
1281        """View → Word Wrap (see `TerminalView.set_word_wrap`)."""
1282        self.view.set_word_wrap(on)
1283
1284    def shutdown(self):
1285        if self.shell is not None:
1286            self.shell.exited.disconnect()
1287            self.shell.stop()
1288            self.shell = None

The Terminal tab: a header (shell, folder, environment, Restart) over the terminal.

TerminalPanel(parent=None)
1166    def __init__(self, parent=None):
1167        super().__init__(parent)
1168        self.directory: Path = Path.home()
1169        self.shell: PtyShell | WinptyShell | None = None
1170        self.title = QLabel()
1171        self.title.setContentsMargins(6, 2, 6, 2)
1172        self.restart_button = QToolButton()  # (an icon button, as the Debug and Git panels': see refresh_colors)
1173        self.restart_button.setText("Restart")
1174        self.restart_button.setToolButtonStyle(Qt.ToolButtonIconOnly)
1175        self.restart_button.setIconSize(QSize(18, 18))
1176        self.restart_button.setAutoRaise(True)
1177        self.restart_button.setToolTip("End this shell and start a new one in the project's folder")
1178        self.restart_button.clicked.connect(self.restart)
1179        header = QHBoxLayout()
1180        header.setContentsMargins(0, 0, 0, 0)
1181        header.addWidget(self.title, 1)
1182        header.addWidget(self.restart_button)
1183        self.view = TerminalView(self)
1184        self.scroll_bar = QScrollBar(Qt.Vertical)
1185        self.view.connect_scroll_bar(self.scroll_bar)
1186        self.hscroll_bar = QScrollBar(Qt.Horizontal)  # (shown without word wrap)
1187        self.view.connect_hscroll_bar(self.hscroll_bar)
1188        body = QGridLayout()
1189        body.setContentsMargins(0, 0, 0, 0)
1190        body.setSpacing(0)
1191        body.addWidget(self.view, 0, 0)
1192        body.addWidget(self.scroll_bar, 0, 1)
1193        body.addWidget(self.hscroll_bar, 1, 0)
1194        layout = QVBoxLayout(self)
1195        layout.setContentsMargins(0, 0, 0, 0)
1196        layout.setSpacing(0)
1197        layout.addLayout(header)
1198        layout.addLayout(body, 1)
1199        self.view.size_changed.connect(self._resized)
1200        self.refresh_colors()

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

directory: pathlib.Path
shell: PtyShell | WinptyShell | None
title
restart_button
view
scroll_bar
hscroll_bar
def set_directory(self, directory: pathlib.Path | None):
1202    def set_directory(self, directory: Path | None):
1203        """The project's folder: where the shell starts. A different one (another
1204        project opened) restarts a running shell there; the same one (the
1205        project reloaded) leaves it alone."""
1206        directory = directory if directory and directory.is_dir() else Path.home()
1207        moved = directory.resolve() != self.directory.resolve()
1208        self.directory = directory
1209        if moved and self.shell is not None and self.shell.is_running():
1210            self.restart(focus=False)
1211        elif self.shell is None:
1212            self._update_title()

The project's folder: where the shell starts. A different one (another project opened) restarts a running shell there; the same one (the project reloaded) leaves it alone.

def start(self):
1223    def start(self):
1224        """Start the shell (if it isn't running)."""
1225        if self.shell is not None and self.shell.is_running():
1226            return
1227        view = self.view
1228        kind = WinptyShell if WINDOWS else PtyShell
1229        launch = shell_launch()
1230        self.shell = kind(launch, self.directory, view.screen.lines, view.screen.columns, self)
1231        self.shell.output.connect(view.feed)
1232        self.shell.exited.connect(self._exited)
1233        view.write = self.shell.write
1234        try:
1235            self.shell.start()
1236        except (OSError, ImportError) as exc:
1237            view.feed(f"Could not start {launch.argv[0]}: {exc}\r\n".encode())
1238            self.shell = None
1239            return
1240        self._update_title()

Start the shell (if it isn't running).

def restart(self, focus: bool = True):
1250    def restart(self, focus: bool = True):
1251        """A new shell (in ``directory``), on a clean screen."""
1252        self.shutdown()
1253        self.view.feed(b"\x1bc")  # (reset: a clean screen)
1254        self.view.screen.history.clear()
1255        self.start()
1256        if focus:
1257            self.focus()

A new shell (in directory), on a clean screen.

def focus(self):
1259    def focus(self):
1260        self.view.setFocus()
def type_command(self, command: str):
1262    def type_command(self, command: str):
1263        """Type ``command`` at the shell's prompt and press Return (starting
1264        the shell first if needed)."""
1265        self.start()
1266        if self.shell is not None:
1267            self.shell.write((command + "\r").encode())

Type command at the shell's prompt and press Return (starting the shell first if needed).

def showEvent(self, event):
1269    def showEvent(self, event):
1270        super().showEvent(event)
1271        QTimer.singleShot(0, self, self.start)  # (the first time the tab is shown: after it's laid out)

showEvent(self, event: PySide6.QtGui.QShowEvent, /) -> None

def refresh_colors(self):
1273    def refresh_colors(self):
1274        self.view.set_colors(*terminal_colors(QApplication.palette()))
1275        self.restart_button.setIcon(make_icon("refresh", QApplication.palette().color(QPalette.WindowText)))
def set_font(self, font: PySide6.QtGui.QFont):
1277    def set_font(self, font: QFont):
1278        self.view.set_terminal_font(font)
def set_word_wrap(self, on: bool):
1280    def set_word_wrap(self, on: bool):
1281        """View → Word Wrap (see `TerminalView.set_word_wrap`)."""
1282        self.view.set_word_wrap(on)

View → Word Wrap (see TerminalView.set_word_wrap).

def shutdown(self):
1284    def shutdown(self):
1285        if self.shell is not None:
1286            self.shell.exited.disconnect()
1287            self.shell.stop()
1288            self.shell = None
staticMetaObject = PySide6.QtCore.QMetaObject("TerminalPanel" inherits "QWidget": )