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
Launch(argv, env)
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.
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.
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.
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.
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)
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.
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
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.
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.
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.
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()
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).
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).
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)
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.
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.
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.
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.2and3-- Erases complete display. All lines are erased and changed to single-width. Cursor does not move.
- bool private: when
Trueonly 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.
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
Trueonly characters marked as erasable are affected not implemented.
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.
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.
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.
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.
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).
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).
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.
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()
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).
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.
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).
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).
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.
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).
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.
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)])
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)
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.
863 def focusNextPrevChild(self, _next): 864 return False # Tab goes to the shell (completion), not to the next widget
focusNextPrevChild(self, next: bool, /) -> bool
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
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).
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
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
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
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
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.
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.
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
1043 def resizeEvent(self, event): 1044 super().resizeEvent(event) 1045 self._fit() 1046 self._update_hscroll_bar()
resizeEvent(self, event: PySide6.QtGui.QResizeEvent, /) -> None
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
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.
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.
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.
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).
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.
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).
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
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).