Coverage for vibefpmgui/terminal.py: 82%

951 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The *Terminal* tab: your shell, in the project's folder, with exactly the 

2environment VibeFPMGUI was started with. 

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: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true

75 return os.environ.get("COMSPEC", "cmd.exe") 

76 return os.environ.get("SHELL") or shutil.which("zsh") or shutil.which("bash") or "/bin/sh" 

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: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true

107 return Launch([shell], env) 

108 name = Path(shell).name 

109 if name in PROMPTS and "PS1" not in env: # (fish has its own prompt function) 

110 active = environment_name() 

111 env["PS1"] = (f"({active}) " if active else "") + PROMPTS[name] 

112 return Launch([shell, *NO_STARTUP_FILES.get(name, []), "-i"], env) 

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 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 try: 

154 os.chdir(cwd) 

155 except OSError: 

156 pass 

157 try: 

158 os.execve(program, argv, env) 

159 finally: 

160 os._exit(127) 

161 self.pid, self.fd = pid, fd 

162 os.set_blocking(fd, False) 

163 self.resize(self.rows, self.cols) 

164 self._notifier = QSocketNotifier(fd, QSocketNotifier.Read, self) 

165 self._notifier.activated.connect(self._read) 

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: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 return 

184 self._pending += data 

185 self._flush() 

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): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 os.kill(pid, signal.SIGKILL) 

253 os.waitpid(pid, 0) 

254 except (ChildProcessError, ProcessLookupError): 

255 pass 

256 QTimer.singleShot(1000, reap) 

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) 393 ↛ 395line 393 didn't jump to line 395 because the condition on line 393 was always true

394 self.buffer[self.cursor.y].wrapped = False 

395 super().erase_in_line(how, private) 

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) 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true

446 y, x = y - 1, columns 

447 cursor_pos = (start_row + y, x) 

448 while len(new_rows) <= cursor_pos[0]: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 new_rows.append(Row()) 

450 top = max(0, len(new_rows) - lines, cursor_pos[0] - lines + 1) 

451 top = min(top, cursor_pos[0]) 

452 self.history.clear() 

453 self.history.extend(new_rows[:top]) 

454 self.buffer.clear() 

455 for y, row in enumerate(new_rows[top:top + lines]): 

456 line = self.buffer[y] 

457 for x, char in enumerate(row): 

458 line[x] = char 

459 line.wrapped = row.wrapped 

460 self.lines, self.columns = lines, columns 

461 self.set_margins() 

462 self.cursor.y = cursor_pos[0] - top 

463 self.cursor.x = min(cursor_pos[1], columns) 

464 self.dirty.update(range(lines)) 

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): 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

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): 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true

676 end = len(line) 

677 while end > widest and line[end - 1] == blank: 

678 end -= 1 

679 else: # (a screen line: only the cells written are in it) 

680 end = max((x + 1 for x, cell in line.items() if cell != blank), default=0) 

681 widest = max(widest, min(end, screen.columns)) 

682 return widest 

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) 711 ↛ 714line 711 didn't jump to line 714 because the condition on line 711 was always true

712 layout.invalidate() 

713 layout.activate() 

714 bar.setVisible(maximum > 0) 

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: 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true

799 return "" 

800 (l1, c1), (l2, c2) = sorted(self._selection) 

801 if l1 == l2: 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true

802 return self._line_text(l1, c1, c2 + 1) 

803 parts = [self._line_text(l1, c1)] + [self._line_text(n) for n in range(l1 + 1, l2)] 

804 return "\n".join(parts + [self._line_text(l2, 0, c2 + 1)]) 

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: 818 ↛ exitline 818 didn't return from function 'copy' because the condition on line 818 was always true

819 QApplication.clipboard().setText(text) 

820 

821 def paste(self): 

822 text = QApplication.clipboard().text() 

823 if not text: 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true

824 return 

825 data = text.replace("\r\n", "\n").replace("\n", "\r").encode("utf-8") 

826 if BRACKETED_PASTE in self.screen.mode: # (so the shell doesn't run it line by line) 

827 data = b"\x1b[200~" + data + b"\x1b[201~" 

828 self._input(data) 

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): 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true

884 return b"\x1bb" if key == Qt.Key_Left else b"\x1bf" # Option+arrow: a word 

885 if key in CURSOR_KEYS: 

886 final = CURSOR_KEYS[key] 

887 if modifier > 1: 

888 return f"\x1b[1;{modifier}{final}".encode() 

889 return f"\x1b{'O' if DECCKM in self.screen.mode else '['}{final}".encode() 

890 if key in TILDE_KEYS: 

891 number = TILDE_KEYS[key] 

892 return (f"\x1b[{number};{modifier}~" if modifier > 1 else f"\x1b[{number}~").encode() 

893 if key in SS3_KEYS: 

894 return (f"\x1b[1;{modifier}{SS3_KEYS[key]}" if modifier > 1 else f"\x1bO{SS3_KEYS[key]}").encode() 

895 if key == Qt.Key_Backtab: 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true

896 return b"\x1b[Z" 

897 if key in (Qt.Key_Return, Qt.Key_Enter): 

898 return b"\r" 

899 if key == Qt.Key_Backspace: 

900 return b"\x08" if ctrl else b"\x7f" 

901 if key == Qt.Key_Escape: 901 ↛ 902line 901 didn't jump to line 902 because the condition on line 901 was never true

902 return b"\x1b" 

903 if key == Qt.Key_Tab: 903 ↛ 904line 903 didn't jump to line 904 because the condition on line 903 was never true

904 return b"\t" 

905 if ctrl: 

906 if Qt.Key_A <= key <= Qt.Key_Z: 

907 return bytes([key - Qt.Key_A + 1]) 

908 control = {Qt.Key_Space: 0, Qt.Key_At: 0, Qt.Key_BracketLeft: 27, Qt.Key_Backslash: 28, 

909 Qt.Key_BracketRight: 29, Qt.Key_AsciiCircum: 30, Qt.Key_Underscore: 31, 

910 Qt.Key_Minus: 31, Qt.Key_Slash: 31} 

911 if key in control: 

912 return bytes([control[key]]) 

913 if text: 

914 data = text.encode("utf-8") 

915 # Alt+key: ESC first (on macOS Option types characters instead). 

916 return b"\x1b" + data if alt and not MAC else data 

917 return None 

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 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true

922 actions = {Qt.Key_C: self.copy, Qt.Key_V: self.paste, Qt.Key_K: self.clear_scrollback, 

923 Qt.Key_A: self.selectAll} 

924 if key in actions: 

925 actions[key]() 

926 else: 

927 event.ignore() 

928 return 

929 if not MAC and mods == (Qt.ControlModifier | Qt.ShiftModifier) and key in (Qt.Key_C, Qt.Key_V): 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true

930 self.copy() if key == Qt.Key_C else self.paste() 

931 return 

932 if mods == Qt.ShiftModifier and key in (Qt.Key_PageUp, Qt.Key_PageDown): # read the scrollback 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true

933 self.scroll_lines(self.screen.lines - 1 if key == Qt.Key_PageUp else 1 - self.screen.lines) 

934 return 

935 data = self.key_bytes(key, mods, text) 

936 if data: 

937 self._input(data) 

938 else: 

939 event.ignore() 

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: 996 ↛ 997line 996 didn't jump to line 997 because the condition on line 996 was never true

997 dx, dy = dx or dy, 0 # (Shift+wheel: sideways) 

998 if abs(dx) > abs(dy): # (mostly sideways: a vertical swipe's bit of sideways doesn't count) 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true

999 self.scroll_sideways(-dx) 

1000 return 

1001 # Up and down: a trackpad in its exact pixels; a mouse wheel 3 lines a notch. 

1002 distance = pixels.y() if pixels.y() else angle.y() / 120 * 3 * self.cell_height 

1003 if not distance: 1003 ↛ 1004line 1003 didn't jump to line 1004 because the condition on line 1003 was never true

1004 return 

1005 if not self.screen.alternate: 1005 ↛ 1009line 1005 didn't jump to line 1009 because the condition on line 1005 was always true

1006 self.scroll_pixels(distance) 

1007 return 

1008 # Full-screen programs: the wheel is arrow keys (as in xterm), a line at a time. 

1009 self._wheel_rest += distance / max(1.0, self.cell_height) 

1010 whole = int(self._wheel_rest) # (towards zero: the rest waits for the next step) 

1011 self._wheel_rest -= whole 

1012 if whole: 

1013 key = Qt.Key_Up if whole > 0 else Qt.Key_Down 

1014 self.write(self.key_bytes(key, Qt.NoModifier, "") * abs(whole)) 

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: 1059 ↛ 1067line 1059 didn't jump to line 1067 because the condition on line 1059 was always true

1060 if name in ANSI_NAMES: 

1061 color = self.ansi[ANSI_NAMES[name]] 

1062 elif name in ANSI_HEX: 

1063 color = self.ansi[ANSI_HEX[name]] 

1064 else: 

1065 color = QColor("#" + name) if len(name) == 6 else QColor(default) 

1066 self._colors[name] = color 

1067 return color 

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: 1085 ↛ 1086line 1085 didn't jump to line 1086 because the condition on line 1085 was never true

1086 continue 

1087 line = self.line(n) 

1088 width = min(len(line), screen.columns) if isinstance(line, list) else screen.columns 

1089 span = self._row_selection(n, width) # (once per row: not per cell) 

1090 y = PADDING + row * ch 

1091 x = 0 

1092 while x < width: 

1093 cell = line[x] 

1094 selected = span is not None and span[0] <= x < span[1] 

1095 style = (cell.fg, cell.bg, cell.bold, cell.italics, cell.underscore, 

1096 cell.strikethrough, cell.reverse, selected) 

1097 start, chars = x, [cell.data] 

1098 x += 1 

1099 while x < width: # (a run of cells drawn alike) 

1100 other = line[x] 

1101 if (other.fg, other.bg, other.bold, other.italics, other.underscore, other.strikethrough, 

1102 other.reverse, span is not None and span[0] <= x < span[1]) != style: 

1103 break 

1104 chars.append(other.data) 

1105 x += 1 

1106 self._draw_run(painter, start, y, chars, cell, selected, select_bg, select_fg) 

1107 self._draw_cursor(painter, first) 

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: 1115 ↛ 1116line 1115 didn't jump to line 1116 because the condition on line 1115 was never true

1116 fg, bg = select_fg, select_bg 

1117 x0 = PADDING + column * cw 

1118 if bg != self.background: 

1119 painter.fillRect(QRectF(x0, y, len(chars) * cw, ch), bg) 

1120 text = "".join(chars) 

1121 if not text.strip(): 

1122 return 

1123 font = self.font_bold if cell.bold else self.font_normal 

1124 if cell.italics or cell.underscore or cell.strikethrough: 1124 ↛ 1125line 1124 didn't jump to line 1125 because the condition on line 1124 was never true

1125 font = QFont(font) 

1126 font.setItalic(cell.italics) 

1127 font.setUnderline(cell.underscore) 

1128 font.setStrikeOut(cell.strikethrough) 

1129 painter.setFont(font) 

1130 painter.setPen(fg) 

1131 baseline = y + self.ascent 

1132 if text.isascii(): 

1133 painter.drawText(QPointF(x0, baseline), text) 

1134 else: # (each character in its cell: wide ones take two, the next is empty) 

1135 for i, c in enumerate(chars): 

1136 if c: 

1137 painter.drawText(QPointF(x0 + i * cw, baseline), c) 

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(): 1151 ↛ 1152line 1151 didn't jump to line 1152 because the condition on line 1151 was never true

1152 painter.setFont(self.font_bold if cell.bold else self.font_normal) 

1153 painter.setPen(self.background) 

1154 painter.drawText(QPointF(x, y + self.ascent), cell.data) 

1155 else: 

1156 painter.setPen(self.foreground) 

1157 painter.setBrush(Qt.NoBrush) 

1158 painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5)) 

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: 1265 ↛ exitline 1265 didn't return from function 'type_command' because the condition on line 1265 was always true

1266 self.shell.write((command + "\r").encode()) 

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