Skip to main content

anytermqt — terminal core + Qt widget

A terminal emulation core in plain C++17, a Qt terminal widget built on top of it, and a Python module that exposes the widget to PySide6.

Three folders, one build. The split is the point: pyte/ knows what a screen contains and nothing about how it looks; qtpyte/ knows how it looks and what the keyboard sends, and parses no escape sequences at all.

pyte/      the emulation core -- no Qt, no display, no dependencies to inherit
qtpyte/    a Qt terminal widget that consumes it
bindings/  the widget as a Python module, for PySide6
python/    the Python package the module ships inside

Each has its own README with the detail. This one covers the shape; docs/building.md covers building on each platform.

btop over ssh, in the widget, driven from Python on Windows windows cmd.exet, driven from Python on Windows)

That is btop running on a Linux box, over ssh from Windows, rendered by this widget inside a PySide6 application — no WebEngine, no xterm.js, no JavaScript bridge. Braille sparklines, box drawing, 24-bit colour and the alternate screen, all at once.

Worth being precise about that path, because it is a stronger claim than a local shell would be: ConPTY is running Windows' own ssh.exe, and what the core is parsing is the remote btop's escape stream relayed through it. The bytes crossed a network and two terminal conventions before reaching pyte::Stream, and nothing on the Windows side normalised them on the way.

Installing

pip install anytermqt-0.1.0-cp39-abi3-win_amd64.whl

Not on PyPI yet. Wheels are built from this tree -- scripts/wheel.sh or scripts\wheel.bat, and docs/building.md for what they do.

One wheel covers Python 3.9 and later on a given platform: the module is built against the limited API, so the tag is abi3 rather than a single Python version. It carries no Qt of its own and pulls an exact PySide6, which is the one thing about it worth knowing before pinning anything in an application -- the module links Qt and PySide6 ships Qt, and the two have to be the same build.

Building

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure
./build/qtpyte/qtpyte-term

Requires CMake 3.21+, a C++17 compiler, and Qt 6.2+ for the widget. On Ubuntu: apt install qt6-base-dev cmake ninja-build build-essential.

The core alone, with no Qt anywhere near it:

cmake -B build-core -G Ninja -DBUILD_WIDGET=OFF
cmake --build build-core && ctest --test-dir build-core

That configuration is worth running before a commit. It is what stops the core from quietly growing a dependency on the widget.

The Python module is off by default and adds requirements the C++ build does not have — Qt 6.10+, PySide6 and Shiboken, matched to each other:

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_BINDINGS=ON

On Windows, scripts/ wraps all of the above with the environment checks that account for most of the ways this goes wrong — the wrong Native Tools prompt, a --user pip install CMake cannot find, and a PySide6 whose Qt does not match the one being built against. scripts/README.md has the detail.

docs/building.md has the version-matching rules and the Windows specifics.

What it does

The core handles the escape-sequence vocabulary real applications use: SGR including 24-bit colour, scrolling regions, insert and delete, the alternate screen, and scrollback with paging and random access. It is verified against reference pyte on captured device sessions, not only against its own expectations.

The widget renders that to a grid of glyphs, with a scrollbar, resize propagated to whatever is on the far end, and a keymap that sends what a terminal sends.

It owns no pty, no shell and no SSH client. Bytes arrive through feed() and leave through dataReady(), so what produces them is the caller's business: a local pseudo-terminal on POSIX or ConPTY on Windows via qtpyte::PtySession, an SSH channel, a socket, or a recorded stream in a test.

Confirmed against htop, btop, neofetch and doom-ascii on Linux, cmd.exe and btop on Windows, and htop on macOS -- the same widget on all three, over a POSIX pty or ConPTY depending on where it is running.

Selection

Drag to select, double-click for a word, triple-click for a line, shift-click to extend. Dragging past the top or bottom edge scrolls and keeps extending.

A word here is wider than isalnum: _ - . / : ~ @ + = % # all count, because in a terminal the thing worth double-clicking is usually a path, a URL, an address or an identifier, and stopping at every dot or slash turns one double-click into six.

Copying is never automatic. Ctrl+Shift+C copies, Ctrl+Shift+V pastes, Ctrl+Shift+A selects all — shifted, so Ctrl+C stays SIGINT and Ctrl+V stays a literal control character. On X11 the primary selection follows the highlight and middle-click pastes it, which is the platform's own convention and costs nothing; the clipboard proper stays untouched until you ask.

The selection is held in absolute line coordinates and converted to viewport rows only at paint time, so a highlight does not crawl up the screen when output arrives underneath it.

For a host application: hasSelection(), selectedText(), setSelection(), selectAll(), clearSelection(), copySelection(), a selectionChanged(bool) signal for enabling a Copy menu item, and setSelectionColor().

Mouse reportingDECSET 1000/1002/1006, the modes a full-screen application uses to read the mouse itself — is not implemented yet. The mouse handlers already ask the question, so that when it lands an application gets the drag and Shift forces selection, which is xterm's convention.

From Python

The widget is a real QWidget subclass on the Python side, so it goes into a PySide6 layout like any other:

from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget

import anytermqt

app = QApplication([])
window = QWidget()
layout = QVBoxLayout(window)

terminal = anytermqt.TerminalWidget()
terminal.setTerminalFont(QFont("Consolas", 11))
layout.addWidget(terminal)

palette = terminal.terminalPalette()
palette.set_background(QColor("#101010"))
terminal.setTerminalPalette(palette)

session = anytermqt.PtySession.create(window)
session.dataReceived.connect(terminal.feed)     # bytes in
terminal.dataReady.connect(session.write)       # keystrokes and replies out
terminal.resized.connect(session.resize)        # the far end needs telling

session.start("ssh", ["user@host"], [],
              terminal.columns(), terminal.terminalRows())

window.show()
terminal.setFocus()
app.exec()

Those three connections are what qtpyte::attach() does in C++, written out here because getting one wrong gives a terminal that looks almost right: typing works and full-screen applications never redraw, or everything renders until the window is resized. dataReady in particular carries the emulator's replies to Device Status Report and friends, not just keystrokes, and an application waiting on one of those answers hangs rather than failing.

terminal.setFocus() is worth keeping. The widget is Qt::StrongFocus and in a layout by itself will usually take focus anyway, but in a real host window with other focusable widgets it will not, and a terminal that ignores the keyboard reads as a broken terminal rather than as an unfocused one.

PtySession.create() gives a pseudo-terminal — a POSIX pty or ConPTY, whichever platform you are on — and starts whatever you name in it. A shell (/bin/bash, cmd.exe) gives a local terminal; ssh gives a remote one, and the widget neither knows nor cares which, because all it ever sees is bytes.

The session is parented to the window above, so the window owns it. Created without a parent it is owned by the Python name bound to it, and collecting that while the child is still running kills the child.

Nothing obliges you to use it. PtySession is an interface in terms of bytes, so a Python class can subclass it and implement start, write and resize over a paramiko channel or a socket — and a caller that would rather not subclass anything can skip the session entirely, connect dataReady to whatever sends bytes, and call feed() when bytes arrive.

Two differences from the C++ API, both deliberate:

terminalPalette() / setTerminalPalette() rather than palette(). The C++ accessor hands out a reference to mutate in place, which does not survive a language binding — the generated wrapper copies it, so the change would land on a temporary and nothing would report an error. The name also avoids colliding with QWidget.palette(), which PySide6 already provides and which returns a QPalette. Same reason setTerminalFont is not called setFont.

The module is tied to one PySide6 version. It links Qt, and PySide6 ships its own copy of Qt; the two must be the same build or the import crashes rather than erroring. See docs/building.md.

Bindings are PySide6 only. Shiboken generates against PySide6's type registry, so a PyQt6 application cannot import this module.

Not a fork of pyte

Named for it and reasoned against it, but written from scratch, and it diverges where the reference is worth diverging from -- resize anchors the bottom of the window so shrinking pushes rows into scrollback instead of dropping them, and CSI 3 J clears saved lines as xterm defines it. The ptdiff harness in pyte/tools/ documents the intentional divergences rather than tolerating them silently.

pyte is by Sergei Lebedev, under BSD-3-Clause: https://github.com/selectel/pyte. The row-store model the core uses came from the Go port in PathfinderSSH rather than from pyte itself.

Third-party

  • utf8proc -- character width and Unicode data, fetched at configure time
  • doctest -- the core's test framework, fetched at configure time
  • Qt 6 -- the widget only, found on the system
  • PySide6 and Shiboken -- the bindings only, found in site-packages

Licence

GPL-3.0. See LICENSE.

That is worth reading before building this into something: it is a copyleft licence, and linking the widget or importing the Python module puts your application under it. Open an issue if that is the only thing standing between you and using it.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

anytermqt-0.1.0-cp39-abi3-win_amd64.whl (206.4 kB view details)

Uploaded CPython 3.9+Windows x86-64

anytermqt-0.1.0-cp39-abi3-manylinux_2_34_x86_64.whl (250.7 kB view details)

Uploaded CPython 3.9+manylinux: glibc 2.34+ x86-64

anytermqt-0.1.0-cp39-abi3-macosx_26_0_arm64.whl (226.0 kB view details)

Uploaded CPython 3.9+macOS 26.0+ ARM64

File details

Details for the file anytermqt-0.1.0-cp39-abi3-win_amd64.whl.

File metadata

  • Download URL: anytermqt-0.1.0-cp39-abi3-win_amd64.whl
  • Upload date:
  • Size: 206.4 kB
  • Tags: CPython 3.9+, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.12

File hashes

Hashes for anytermqt-0.1.0-cp39-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 3772da67d047cba65167941f0b2dc65056a8a488b8c957de0a22bd87ba20dad5
MD5 344c0cae8ee7638fd92c9c0f0170e928
BLAKE2b-256 1ce4555614446027e1c4057f1dcae385c3f084aea6eddf728e671b9097297146

See more details on using hashes here.

File details

Details for the file anytermqt-0.1.0-cp39-abi3-manylinux_2_34_x86_64.whl.

File metadata

File hashes

Hashes for anytermqt-0.1.0-cp39-abi3-manylinux_2_34_x86_64.whl
Algorithm Hash digest
SHA256 3171339cb1e70c0ff069df5e75e555a5344bbe7df0a64355f3c9541215689b36
MD5 1411b358588c63e791f8578f42f942b5
BLAKE2b-256 b04695b0e62d8f1757b5086de35c2b7108f9d8393b5442494713a7a6d36cb249

See more details on using hashes here.

File details

Details for the file anytermqt-0.1.0-cp39-abi3-macosx_26_0_arm64.whl.

File metadata

File hashes

Hashes for anytermqt-0.1.0-cp39-abi3-macosx_26_0_arm64.whl
Algorithm Hash digest
SHA256 f0fffc75f7e34f37d30ca318a31930d282189cf6de6247281bd8983061f85bb6
MD5 a93f0ed386a8d3e220d97aa70f05b7b5
BLAKE2b-256 c4ee060dff60fdf72227a8c9b5288929ab612d94e3cd9f45eb92cbde2ac288b7

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

3 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page