├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── pyproject.toml └── src └── qtpyTerminal ├── __init__.py └── qtpyTerminal.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matias Guijarro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qtpyTerminal 2 | A Vt100 terminal widget for qtpy (PyQt, Pyside) 3 | 4 | # About 5 | 6 | qtpyTerminal is a QWidget derived from QPlainTextEdit, which can execute an interactive 7 | command to run in the default shell (`SHELL` environment variable). The shell process 8 | is forked, and input/output is redirected to the Qt widget. It is then possible to 9 | interact with the program, as in a VT100 terminal thanks to the use of Pyte for 10 | escape codes interpretation (see https://github.com/selectel/pyte). 11 | 12 | https://github.com/user-attachments/assets/03e76620-f880-453b-9539-939c006c0caf 13 | 14 | # Installation 15 | 16 | qtpyTerminal only requires pyte and qtpy - it should run seamlessly with different 17 | Python Qt backends like PyQt or Pyside. 18 | 19 | ```python 20 | pip install qtpyTerminal@git+https://github.com/mguijarr/qtpyTerminal.git 21 | ``` 22 | 23 | # Example 24 | 25 | ```python 26 | import sys 27 | from qtpy import QtGui, QtWidgets 28 | from qtpyTerminal import qtpyTerminal 29 | 30 | # Create the Qt application and console. 31 | app = QtWidgets.QApplication([]) 32 | mainwin = QtWidgets.QMainWindow() 33 | mainwin.setWindowTitle("qtpyTerminal example") 34 | container = QtWidgets.QWidget(mainwin) 35 | container.setLayout(QtWidgets.QVBoxLayout()) 36 | mainwin.setCentralWidget(container) 37 | 38 | console = qtpyTerminal(mainwin) 39 | 40 | def exit(): 41 | console.stop() 42 | app.quit() 43 | 44 | start_button = QtWidgets.QPushButton("Start shell", container) 45 | start_button.clicked.connect(console.start) 46 | container.layout().addWidget(start_button) 47 | container.layout().addWidget(console) 48 | quit_button = QtWidgets.QPushButton("Quit", container) 49 | quit_button.clicked.connect(exit) 50 | container.layout().addWidget(quit_button) 51 | 52 | # Show widget and launch Qt's event loop. 53 | mainwin.show() 54 | sys.exit(app.exec_()) 55 | 56 | ``` 57 | 58 | By default the process started by the terminal is defined by the `SHELL` environment variable. 59 | It is possible to call `.set_cmd()` to launch another command - for example `.set_cmd("python")` 60 | will start an interactive session of the Python interpreter in the Terminal widget. 61 | 62 | The widget size policy is `MinimumExpanding` on both directions. Vertical scrolling is handled. 63 | Please note that horizontal scrolling is not implemented yet. So, it is better to define a fixed 64 | number of columns. By default the terminal is 132 columns. 65 | 66 | The number of columns or default number of rows can be defined with `.set_cols()` or `.set_rows()`. 67 | It is also possible to give them directly as arguments of `qtpyTerminal` constructor. 68 | 69 | By default, the widgets uses "text" color (`QPalette`) for the main foreground color, and "base" 70 | color for the background. It can be changed by calling `set_fgcolor(QColor)` or `set_bgcolor(QColor)` 71 | respectively. 72 | 73 | # Contributing 74 | 75 | Contributions are welcome. Please submit merge requests. 76 | 77 | 78 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from qtpy import QtGui, QtWidgets 3 | from qtpyTerminal import qtpyTerminal 4 | 5 | # Create the Qt application and console. 6 | app = QtWidgets.QApplication([]) 7 | mainwin = QtWidgets.QMainWindow() 8 | mainwin.setWindowTitle("qtpyTerminal example") 9 | container = QtWidgets.QWidget(mainwin) 10 | container.setLayout(QtWidgets.QVBoxLayout()) 11 | mainwin.setCentralWidget(container) 12 | 13 | console = qtpyTerminal(mainwin) 14 | 15 | def exit(): 16 | console.stop() 17 | app.quit() 18 | 19 | start_button = QtWidgets.QPushButton("Start shell", container) 20 | start_button.clicked.connect(console.start) 21 | container.layout().addWidget(start_button) 22 | container.layout().addWidget(console) 23 | quit_button = QtWidgets.QPushButton("Quit", container) 24 | quit_button.clicked.connect(exit) 25 | container.layout().addWidget(quit_button) 26 | 27 | # Show widget and launch Qt's event loop. 28 | mainwin.show() 29 | sys.exit(app.exec_()) 30 | 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "qtpyTerminal" 7 | version = "0.1" 8 | description = "qtpyTerminal - Python Qt VT100 widget" 9 | requires-python = ">=3.10" 10 | classifiers = [ 11 | "Development Status :: 3 - Alpha", 12 | "Programming Language :: Python :: 3", 13 | "Topic :: Scientific/Engineering", 14 | ] 15 | dependencies = [ 16 | "qtpy~=2.4", 17 | "pyte", 18 | ] 19 | 20 | [tool.setuptools.packages] 21 | find = { where = ["src"] } 22 | 23 | [project.optional-dependencies] 24 | pyqt6 = ["PyQt6>=6.7", "PyQt6-WebEngine>=6.7"] 25 | pyside6 = ["PySide6>=6.7"] 26 | 27 | [tool.black] 28 | line-length = 100 29 | skip-magic-trailing-comma = true 30 | 31 | [tool.isort] 32 | profile = "black" 33 | line_length = 100 34 | multi_line_output = 3 35 | include_trailing_comma = true 36 | 37 | -------------------------------------------------------------------------------- /src/qtpyTerminal/__init__.py: -------------------------------------------------------------------------------- 1 | from . qtpyTerminal import qtpyTerminal 2 | -------------------------------------------------------------------------------- /src/qtpyTerminal/qtpyTerminal.py: -------------------------------------------------------------------------------- 1 | """ 2 | qtpyTerminal is a Qt widget that runs a Bash shell. 3 | 4 | qtpyTerminal VT100 emulation is powered by Pyte, 5 | (https://github.com/selectel/pyte). 6 | """ 7 | 8 | import collections 9 | import fcntl 10 | import functools 11 | import html 12 | import os 13 | import pty 14 | import signal 15 | import sys 16 | 17 | import pyte 18 | from pyte.screens import History 19 | from qtpy import QtCore, QtGui, QtWidgets 20 | from qtpy.QtCore import Property as pyqtProperty 21 | from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer 22 | from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot 23 | from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor 24 | from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy 25 | 26 | 27 | def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name 28 | """Function with args, acting like a decorator, to display errors instead of raising an exception 29 | """ 30 | def error_managed(method): 31 | @pyqtSlot(*slot_args, **slot_kwargs) 32 | @functools.wraps(method) 33 | def wrapper(*args, **kwargs): 34 | try: 35 | return method(*args, **kwargs) 36 | except Exception: 37 | sys.excepthook(*sys.exc_info()) 38 | 39 | return wrapper 40 | 41 | return error_managed 42 | 43 | 44 | ansi_colors = { 45 | "black": "#000000", 46 | "red": "#CD0000", 47 | "green": "#00CD00", 48 | "brown": "#996633", # Brown, replacing the yellow 49 | "blue": "#0000EE", 50 | "magenta": "#CD00CD", 51 | "cyan": "#00CDCD", 52 | "white": "#E5E5E5", 53 | "brightblack": "#7F7F7F", 54 | "brightred": "#FF0000", 55 | "brightgreen": "#00FF00", 56 | "brightyellow": "#FFFF00", 57 | "brightblue": "#5C5CFF", 58 | "brightmagenta": "#FF00FF", 59 | "brightcyan": "#00FFFF", 60 | "brightwhite": "#FFFFFF", 61 | } 62 | 63 | control_keys_mapping = { 64 | QtCore.Qt.Key_A: b"\x01", # Ctrl-A 65 | QtCore.Qt.Key_B: b"\x02", # Ctrl-B 66 | QtCore.Qt.Key_C: b"\x03", # Ctrl-C 67 | QtCore.Qt.Key_D: b"\x04", # Ctrl-D 68 | QtCore.Qt.Key_E: b"\x05", # Ctrl-E 69 | QtCore.Qt.Key_F: b"\x06", # Ctrl-F 70 | QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell) 71 | QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace) 72 | QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab) 73 | QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed) 74 | QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab) 75 | QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed) 76 | QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return) 77 | QtCore.Qt.Key_N: b"\x0E", # Ctrl-N 78 | QtCore.Qt.Key_O: b"\x0F", # Ctrl-O 79 | QtCore.Qt.Key_P: b"\x10", # Ctrl-P 80 | QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q 81 | QtCore.Qt.Key_R: b"\x12", # Ctrl-R 82 | QtCore.Qt.Key_S: b"\x13", # Ctrl-S 83 | QtCore.Qt.Key_T: b"\x14", # Ctrl-T 84 | QtCore.Qt.Key_U: b"\x15", # Ctrl-U 85 | QtCore.Qt.Key_V: b"\x16", # Ctrl-V 86 | QtCore.Qt.Key_W: b"\x17", # Ctrl-W 87 | QtCore.Qt.Key_X: b"\x18", # Ctrl-X 88 | QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y 89 | QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z 90 | QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape 91 | QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\ 92 | QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_ 93 | } 94 | 95 | normal_keys_mapping = { 96 | QtCore.Qt.Key_Return: b"\n", 97 | QtCore.Qt.Key_Space: b" ", 98 | QtCore.Qt.Key_Enter: b"\n", 99 | QtCore.Qt.Key_Tab: b"\t", 100 | QtCore.Qt.Key_Backspace: b"\x08", 101 | QtCore.Qt.Key_Home: b"\x47", 102 | QtCore.Qt.Key_End: b"\x4f", 103 | QtCore.Qt.Key_Left: b"\x02", 104 | QtCore.Qt.Key_Up: b"\x10", 105 | QtCore.Qt.Key_Right: b"\x06", 106 | QtCore.Qt.Key_Down: b"\x0E", 107 | QtCore.Qt.Key_PageUp: b"\x49", 108 | QtCore.Qt.Key_PageDown: b"\x51", 109 | QtCore.Qt.Key_F1: b"\x1b\x31", 110 | QtCore.Qt.Key_F2: b"\x1b\x32", 111 | QtCore.Qt.Key_F3: b"\x1b\x33", 112 | QtCore.Qt.Key_F4: b"\x1b\x34", 113 | QtCore.Qt.Key_F5: b"\x1b\x35", 114 | QtCore.Qt.Key_F6: b"\x1b\x36", 115 | QtCore.Qt.Key_F7: b"\x1b\x37", 116 | QtCore.Qt.Key_F8: b"\x1b\x38", 117 | QtCore.Qt.Key_F9: b"\x1b\x39", 118 | QtCore.Qt.Key_F10: b"\x1b\x30", 119 | QtCore.Qt.Key_F11: b"\x45", 120 | QtCore.Qt.Key_F12: b"\x46", 121 | } 122 | 123 | 124 | def QtKeyToAscii(event): 125 | """ 126 | Convert the Qt key event to the corresponding ASCII sequence for 127 | the terminal. This works fine for standard alphanumerical characters, but 128 | most other characters require terminal specific control sequences. 129 | """ 130 | if sys.platform == "darwin": 131 | # special case for MacOS 132 | # /!\ Qt maps ControlModifier to CMD 133 | # CMD-C, CMD-V for copy/paste 134 | # CTRL-C and other modifiers -> key mapping 135 | if event.modifiers() == QtCore.Qt.MetaModifier: 136 | if event.key() == Qt.Key_Backspace: 137 | return control_keys_mapping.get(Qt.Key_W) 138 | return control_keys_mapping.get(event.key()) 139 | elif event.modifiers() == QtCore.Qt.ControlModifier: 140 | if event.key() == Qt.Key_C: 141 | # copy 142 | return "copy" 143 | elif event.key() == Qt.Key_V: 144 | # paste 145 | return "paste" 146 | return None 147 | else: 148 | return normal_keys_mapping.get(event.key(), event.text().encode("utf8")) 149 | if event.modifiers() == QtCore.Qt.ControlModifier: 150 | return control_keys_mapping.get(event.key()) 151 | else: 152 | return normal_keys_mapping.get(event.key(), event.text().encode("utf8")) 153 | 154 | 155 | class Screen(pyte.HistoryScreen): 156 | def __init__(self, stdin_fd, cols, rows, historyLength): 157 | super().__init__(cols, rows, historyLength, ratio=1 / rows) 158 | self._fd = stdin_fd 159 | 160 | def write_process_input(self, data): 161 | """Response to CPR request (for example), 162 | this can be for other requests 163 | """ 164 | try: 165 | os.write(self._fd, data.encode("utf-8")) 166 | except (IOError, OSError): 167 | pass 168 | 169 | def resize(self, lines, columns): 170 | lines = lines or self.lines 171 | columns = columns or self.columns 172 | 173 | if lines == self.lines and columns == self.columns: 174 | return # No changes. 175 | 176 | self.dirty.clear() 177 | self.dirty.update(range(lines)) 178 | 179 | self.save_cursor() 180 | if lines < self.lines: 181 | if lines <= self.cursor.y: 182 | nlines_to_move_up = self.lines - lines 183 | for i in range(nlines_to_move_up): 184 | line = self.buffer[i] # .pop(0) 185 | self.history.top.append(line) 186 | self.cursor_position(0, 0) 187 | self.delete_lines(nlines_to_move_up) 188 | self.restore_cursor() 189 | self.cursor.y -= nlines_to_move_up 190 | else: 191 | self.restore_cursor() 192 | 193 | self.lines, self.columns = lines, columns 194 | self.history = History( 195 | self.history.top, 196 | self.history.bottom, 197 | 1 / self.lines, 198 | self.history.size, 199 | self.history.position, 200 | ) 201 | self.set_margins() 202 | 203 | 204 | class Backend(QtCore.QObject): 205 | """ 206 | This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the 207 | file descriptor of the underlying executed program. 208 | """ 209 | 210 | # Signals to communicate with ``_TerminalWidget``. 211 | dataReady = pyqtSignal(object) 212 | processExited = pyqtSignal() 213 | 214 | def __init__(self, fd, cols, rows): 215 | super().__init__() 216 | 217 | # File descriptor that connects to the process. 218 | self.fd = fd 219 | 220 | self.screen = Screen(self.fd, cols, rows, 10000) 221 | self.stream = pyte.ByteStream() 222 | self.stream.attach(self.screen) 223 | 224 | self.notifier = QSocketNotifier(fd, QSocketNotifier.Read) 225 | self.notifier.activated.connect(self._fd_readable) 226 | 227 | def _fd_readable(self): 228 | """ 229 | Poll the Bash output, run it through Pyte, and notify 230 | """ 231 | # Read the shell output until the file descriptor is closed. 232 | try: 233 | out = os.read(self.fd, 2**16) 234 | except OSError: 235 | self.processExited.emit() 236 | self.notifier.setEnabled(False) 237 | return 238 | 239 | # Feed output into Pyte's state machine and send the new screen 240 | # output to the GUI 241 | self.stream.feed(out) 242 | self.dataReady.emit(self.screen) 243 | 244 | 245 | class qtpyTerminal(QtWidgets.QWidget): 246 | """Container widget for the terminal text area""" 247 | def __init__(self, parent=None, cols=132): 248 | super().__init__(parent) 249 | 250 | self.term = _TerminalWidget(self, cols, rows=25) 251 | self.scroll_bar = QScrollBar(Qt.Vertical, self) 252 | # self.scroll_bar.hide() 253 | layout = QHBoxLayout(self) 254 | layout.addWidget(self.term) 255 | layout.addWidget(self.scroll_bar) 256 | layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) 257 | layout.setContentsMargins(0, 0, 0, 0) 258 | self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.MinimumExpanding) 259 | 260 | pal = QPalette() 261 | self.set_bgcolor(pal.window().color()) 262 | self.set_fgcolor(pal.windowText().color()) 263 | self.term.set_scroll_bar(self.scroll_bar) 264 | self.set_cmd("") # will execute the default shell 265 | 266 | def minimumSizeHint(self): 267 | size = self.term.sizeHint() 268 | size.setWidth(size.width() + self.scroll_bar.width()) 269 | return size 270 | 271 | def sizeHint(self): 272 | return self.minimumSizeHint() 273 | 274 | def get_rows(self): 275 | return self.term.rows 276 | 277 | def set_rows(self, rows): 278 | self.term.rows = rows 279 | self.adjustSize() 280 | self.updateGeometry() 281 | 282 | def get_cols(self): 283 | return self.term.cols 284 | 285 | def set_cols(self, cols): 286 | self.term.cols = cols 287 | self.adjustSize() 288 | self.updateGeometry() 289 | 290 | def get_bgcolor(self): 291 | return QColor.fromString(self.term.bg_color) 292 | 293 | def set_bgcolor(self, color): 294 | self.term.bg_color = color.name(QColor.HexRgb) 295 | 296 | def get_fgcolor(self): 297 | return QColor.fromString(self.term.fg_color) 298 | 299 | def set_fgcolor(self, color): 300 | self.term.fg_color = color.name(QColor.HexRgb) 301 | 302 | def get_cmd(self): 303 | return self.term._cmd 304 | 305 | def set_cmd(self, cmd): 306 | if not cmd: 307 | cmd = os.environ["SHELL"] 308 | self.term._cmd = cmd 309 | if self.term.fd is None: 310 | # not started yet 311 | self.term.clear() 312 | self.term.appendHtml(f"

qtpyTerminal - {repr(cmd)}

") 313 | 314 | @SafeSlot(bool) 315 | def start(self, deactivate_ctrl_d=True): 316 | self.term.start(deactivate_ctrl_d=deactivate_ctrl_d) 317 | 318 | @SafeSlot() 319 | def stop(self): 320 | self.term.stop() 321 | 322 | @SafeSlot(str) 323 | def push(self, text): 324 | """Push some text to the terminal""" 325 | return self.term.push(text) 326 | 327 | cols = pyqtProperty(int, get_cols, set_cols) 328 | rows = pyqtProperty(int, get_rows, set_rows) 329 | bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor) 330 | fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor) 331 | cmd = pyqtProperty(str, get_cmd, set_cmd) 332 | 333 | 334 | class _TerminalWidget(QtWidgets.QPlainTextEdit): 335 | """ 336 | Start ``Backend`` process and render Pyte output as text. 337 | """ 338 | def __init__(self, parent, cols=125, rows=50, **kwargs): 339 | # file descriptor to communicate with the subprocess 340 | self.fd = None 341 | self.pid = None 342 | self.backend = None 343 | # command to execute 344 | self._cmd = "" 345 | # should ctrl-d be deactivated ? (prevent Python exit) 346 | self._deactivate_ctrl_d = False 347 | 348 | # Default colors 349 | pal = QPalette() 350 | self._fg_color = pal.text().color().name() 351 | self._bg_color = pal.base().color().name() 352 | 353 | # Specify the terminal size in terms of lines and columns. 354 | self._rows = rows 355 | self._cols = cols 356 | self.output = collections.deque() 357 | 358 | super().__init__(parent) 359 | 360 | self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) 361 | 362 | # Disable default scrollbars (we use our own, to be set via .set_scroll_bar()) 363 | self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 364 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 365 | self.scroll_bar = None 366 | 367 | # Use Monospace fonts and disable line wrapping. 368 | self.setFont(QtGui.QFont("Courier", 9)) 369 | self.setFont(QtGui.QFont("Monospace")) 370 | self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 371 | fmt = QtGui.QFontMetrics(self.font()) 372 | char_width = fmt.width("w") 373 | self.setCursorWidth(char_width) 374 | 375 | self.adjustSize() 376 | self.updateGeometry() 377 | self.update_stylesheet() 378 | 379 | @property 380 | def bg_color(self): 381 | return self._bg_color 382 | 383 | @bg_color.setter 384 | def bg_color(self, hexcolor): 385 | self._bg_color = hexcolor 386 | self.update_stylesheet() 387 | 388 | @property 389 | def fg_color(self): 390 | return self._fg_color 391 | 392 | @fg_color.setter 393 | def fg_color(self, hexcolor): 394 | self._fg_color = hexcolor 395 | self.update_stylesheet() 396 | 397 | def update_stylesheet(self): 398 | self.setStyleSheet( 399 | f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} " 400 | ) 401 | 402 | @property 403 | def rows(self): 404 | return self._rows 405 | 406 | @rows.setter 407 | def rows(self, rows: int): 408 | if self.backend is None: 409 | # not initialized yet, ok to change 410 | self._rows = rows 411 | self.adjustSize() 412 | self.updateGeometry() 413 | else: 414 | raise RuntimeError("Cannot change rows after console is started.") 415 | 416 | @property 417 | def cols(self): 418 | return self._cols 419 | 420 | @cols.setter 421 | def cols(self, cols: int): 422 | if self.fd is None: 423 | # not initialized yet, ok to change 424 | self._cols = cols 425 | self.adjustSize() 426 | self.updateGeometry() 427 | else: 428 | raise RuntimeError("Cannot change cols after console is started.") 429 | 430 | def stop(self): 431 | if self.fd: 432 | os.kill(self.pid, signal.SIGTERM) 433 | os.waitpid(self.pid, os.WNOHANG) 434 | 435 | def start(self, deactivate_ctrl_d: bool = False): 436 | self._deactivate_ctrl_d = deactivate_ctrl_d 437 | 438 | self.update_term_size() 439 | 440 | # Start the process 441 | self.fd, self.pid = self.fork_shell() 442 | 443 | if self.fd: 444 | # Create the ``Backend`` object 445 | self.backend = Backend(self.fd, self.cols, self.rows) 446 | self.backend.dataReady.connect(self.data_ready) 447 | self.backend.processExited.connect(self.process_exited) 448 | else: 449 | self.process_exited() 450 | 451 | @SafeSlot() 452 | def process_exited(self): 453 | self.fd = None 454 | self.clear() 455 | self.appendHtml(f"

{repr(self._cmd)} - Process exited.

") 456 | self.setReadOnly(True) 457 | 458 | @SafeSlot(object) 459 | def data_ready(self, screen): 460 | """Handle new screen: redraw, set scroll bar max and slider, move cursor to its position 461 | 462 | This method is triggered via a signal from ``Backend``. 463 | """ 464 | self.redraw_screen() 465 | self.adjust_scroll_bar() 466 | self.move_cursor() 467 | 468 | def minimumSizeHint(self): 469 | """Return minimum size for current cols and rows""" 470 | fmt = QtGui.QFontMetrics(self.font()) 471 | char_width = fmt.width("w") 472 | char_height = fmt.height() 473 | width = char_width * self.cols 474 | height = char_height * self.rows 475 | return QSize(width, height) 476 | 477 | def sizeHint(self): 478 | return self.minimumSizeHint() 479 | 480 | def set_scroll_bar(self, scroll_bar): 481 | self.scroll_bar = scroll_bar 482 | self.scroll_bar.setMinimum(0) 483 | self.scroll_bar.valueChanged.connect(self.scroll_value_change) 484 | 485 | def scroll_value_change(self, value, old={"value": -1}): 486 | if self.backend is None: 487 | return 488 | if old["value"] == -1: 489 | old["value"] = self.scroll_bar.maximum() 490 | if value <= old["value"]: 491 | # scroll up 492 | # value is number of lines from the start 493 | nlines = old["value"] - value 494 | # history ratio gives prev_page == 1 line 495 | for i in range(nlines): 496 | self.backend.screen.prev_page() 497 | else: 498 | # scroll down 499 | nlines = value - old["value"] 500 | for i in range(nlines): 501 | self.backend.screen.next_page() 502 | old["value"] = value 503 | self.redraw_screen() 504 | 505 | def adjust_scroll_bar(self): 506 | sb = self.scroll_bar 507 | sb.valueChanged.disconnect(self.scroll_value_change) 508 | tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom) 509 | sb.setMaximum(tmp if tmp > 0 else 0) 510 | sb.setSliderPosition(tmp if tmp > 0 else 0) 511 | # if tmp > 0: 512 | # # show scrollbar, but delayed - prevent recursion with widget size change 513 | # QTimer.singleShot(0, scrollbar.show) 514 | # else: 515 | # QTimer.singleShot(0, scrollbar.hide) 516 | sb.valueChanged.connect(self.scroll_value_change) 517 | 518 | def write(self, data): 519 | try: 520 | os.write(self.fd, data) 521 | except (IOError, OSError): 522 | self.process_exited() 523 | 524 | @SafeSlot(object) 525 | def keyPressEvent(self, event): 526 | """ 527 | Redirect all keystrokes to the terminal process. 528 | """ 529 | if self.fd is None: 530 | # not started 531 | return 532 | # Convert the Qt key to the correct ASCII code. 533 | if ( 534 | self._deactivate_ctrl_d 535 | and event.modifiers() == QtCore.Qt.ControlModifier 536 | and event.key() == QtCore.Qt.Key_D 537 | ): 538 | return None 539 | 540 | code = QtKeyToAscii(event) 541 | if code == "copy": 542 | # MacOS only: CMD-C handling 543 | self.copy() 544 | elif code == "paste": 545 | # MacOS only: CMD-V handling 546 | self._push_clipboard() 547 | elif code is not None: 548 | self.write(code) 549 | 550 | def push(self, text): 551 | """ 552 | Write 'text' to terminal 553 | """ 554 | self.write(text.encode("utf-8")) 555 | 556 | def contextMenuEvent(self, event): 557 | if self.fd is None: 558 | return 559 | menu = self.createStandardContextMenu() 560 | for action in menu.actions(): 561 | # remove all actions except copy and paste 562 | if "opy" in action.text(): 563 | # redefine text without shortcut 564 | # since it probably clashes with control codes (like CTRL-C etc) 565 | action.setText("Copy") 566 | continue 567 | if "aste" in action.text(): 568 | # redefine text without shortcut 569 | action.setText("Paste") 570 | # paste -> have to insert with self.push 571 | action.triggered.connect(self._push_clipboard) 572 | continue 573 | menu.removeAction(action) 574 | menu.exec_(event.globalPos()) 575 | 576 | @SafeSlot() 577 | def _push_clipboard(self): 578 | clipboard = QApplication.instance().clipboard() 579 | self.push(clipboard.text()) 580 | 581 | def move_cursor(self): 582 | textCursor = self.textCursor() 583 | textCursor.setPosition(0) 584 | textCursor.movePosition( 585 | QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y 586 | ) 587 | textCursor.movePosition( 588 | QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x 589 | ) 590 | self.setTextCursor(textCursor) 591 | 592 | def mouseReleaseEvent(self, event): 593 | if self.fd is None: 594 | return 595 | if event.button() == Qt.MiddleButton: 596 | # push primary selection buffer ("mouse clipboard") to terminal 597 | clipboard = QApplication.instance().clipboard() 598 | if clipboard.supportsSelection(): 599 | self.push(clipboard.text(QClipboard.Selection)) 600 | return None 601 | elif event.button() == Qt.LeftButton: 602 | # left button click 603 | textCursor = self.textCursor() 604 | if textCursor.selectedText(): 605 | # mouse was used to select text -> nothing to do 606 | pass 607 | else: 608 | # a simple 'click', move scrollbar to end 609 | self.scroll_bar.setSliderPosition(self.scroll_bar.maximum()) 610 | self.move_cursor() 611 | return None 612 | return super().mouseReleaseEvent(event) 613 | 614 | def redraw_screen(self): 615 | """ 616 | Render the screen as formatted text into the widget. 617 | """ 618 | screen = self.backend.screen 619 | 620 | # Clear the widget 621 | if screen.dirty: 622 | self.clear() 623 | while len(self.output) < (max(screen.dirty) + 1): 624 | self.output.append("") 625 | while len(self.output) > (max(screen.dirty) + 1): 626 | self.output.pop() 627 | 628 | # Prepare the HTML output 629 | for line_no in screen.dirty: 630 | line = text = "" 631 | style = old_style = "" 632 | old_idx = 0 633 | for idx, ch in screen.buffer[line_no].items(): 634 | text += " " * (idx - old_idx - 1) 635 | old_idx = idx 636 | style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}" 637 | if style != old_style: 638 | if old_style: 639 | line += f"{html.escape(text, quote=True)}" 640 | else: 641 | line += html.escape(text, quote=True) 642 | text = "" 643 | old_style = style 644 | text += ch.data 645 | if style: 646 | line += f"{html.escape(text, quote=True)}" 647 | else: 648 | line += html.escape(text, quote=True) 649 | # do a check at the cursor position: 650 | # it is possible x pos > output line length, 651 | # for example if last escape codes are "cursor forward" past end of text, 652 | # like IPython does for "..." prompt (in a block, like "for" loop or "while" for example) 653 | # In this case, cursor is at 12 but last text output is at 8 -> insert spaces 654 | if line_no == screen.cursor.y: 655 | llen = len(screen.buffer[line_no]) 656 | if llen < screen.cursor.x: 657 | line += " " * (screen.cursor.x - llen) 658 | self.output[line_no] = line 659 | # fill the text area with HTML contents in one go 660 | self.appendHtml(f"
{chr(10).join(self.output)}
") 661 | # did updates, all clean 662 | screen.dirty.clear() 663 | 664 | def update_term_size(self): 665 | fmt = QtGui.QFontMetrics(self.font()) 666 | char_width = fmt.width("w") 667 | char_height = fmt.height() 668 | self._cols = int(self.width() / char_width) 669 | self._rows = int(self.height() / char_height) 670 | 671 | def resizeEvent(self, event): 672 | self.update_term_size() 673 | if self.fd: 674 | self.backend.screen.resize(self._rows, self._cols) 675 | self.redraw_screen() 676 | self.adjust_scroll_bar() 677 | self.move_cursor() 678 | 679 | def wheelEvent(self, event): 680 | if not self.fd: 681 | return 682 | y = event.angleDelta().y() 683 | if y > 0: 684 | self.backend.screen.prev_page() 685 | else: 686 | self.backend.screen.next_page() 687 | self.redraw_screen() 688 | 689 | def fork_shell(self): 690 | """ 691 | Fork the current process and execute in shell. 692 | """ 693 | try: 694 | pid, fd = pty.fork() 695 | except (IOError, OSError): 696 | return False 697 | if pid == 0: 698 | try: 699 | ls = os.environ["LANG"].split(".") 700 | except KeyError: 701 | ls = [] 702 | if len(ls) < 2: 703 | ls = ["en_US", "UTF-8"] 704 | os.putenv("COLUMNS", str(self.cols)) 705 | os.putenv("LINES", str(self.rows)) 706 | os.putenv("TERM", "linux") 707 | os.putenv("LANG", ls[0] + ".UTF-8") 708 | if not self._cmd: 709 | self._cmd = os.environ["SHELL"] 710 | cmd = self._cmd 711 | if isinstance(cmd, str): 712 | cmd = cmd.split() 713 | try: 714 | os.execvp(cmd[0], cmd) 715 | except (IOError, OSError): 716 | pass 717 | os._exit(0) 718 | else: 719 | # We are in the parent process. 720 | # Set file control 721 | fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK) 722 | return fd, pid 723 | 724 | 725 | if __name__ == "__main__": 726 | import os 727 | import sys 728 | 729 | from qtpy import QtGui, QtWidgets 730 | 731 | # Create the Qt application and console. 732 | app = QtWidgets.QApplication([]) 733 | mainwin = QtWidgets.QMainWindow() 734 | title = "qtpyTerminal" 735 | mainwin.setWindowTitle(title) 736 | 737 | console = qtpyTerminal(mainwin) 738 | mainwin.setCentralWidget(console) 739 | console.start() 740 | 741 | # Show widget and launch Qt's event loop. 742 | mainwin.show() 743 | sys.exit(app.exec_()) 744 | --------------------------------------------------------------------------------